diff --git a/components/backend/git/operations.go b/components/backend/git/operations.go index c484fbdea..825d6dedd 100644 --- a/components/backend/git/operations.go +++ b/components/backend/git/operations.go @@ -40,6 +40,8 @@ type ProjectSettings struct { type DiffSummary struct { TotalAdded int `json:"total_added"` TotalRemoved int `json:"total_removed"` + FilesAdded int `json:"files_added"` + FilesRemoved int `json:"files_removed"` } // getProjectSettings retrieves the ProjectSettings CR for a project using the provided dynamic client @@ -871,7 +873,7 @@ func DiffRepo(ctx context.Context, repoDir string) (*DiffSummary, error) { summary := &DiffSummary{} - // Get numstat for modified files (working tree vs HEAD) + // Get numstat for modified tracked files (working tree vs HEAD) numstatOut, err := run("git", "diff", "--numstat", "HEAD") if err == nil && strings.TrimSpace(numstatOut) != "" { lines := strings.Split(strings.TrimSpace(numstatOut), "\n") @@ -896,11 +898,37 @@ func DiffRepo(ctx context.Context, repoDir string) (*DiffSummary, error) { fmt.Sscanf(removed, "%d", &n) summary.TotalRemoved += n } + // If file was deleted (0 added, all removed), count as removed file + if added == "0" && removed != "0" { + summary.FilesRemoved++ + } + } + } + + // Get untracked files (new files not yet added to git) + untrackedOut, err := run("git", "ls-files", "--others", "--exclude-standard") + if err == nil && strings.TrimSpace(untrackedOut) != "" { + untrackedFiles := strings.Split(strings.TrimSpace(untrackedOut), "\n") + for _, filePath := range untrackedFiles { + if filePath == "" { + continue + } + // Count lines in the untracked file + fullPath := filepath.Join(repoDir, filePath) + if data, err := os.ReadFile(fullPath); err == nil { + // Count lines (all lines in a new file are "added") + lineCount := strings.Count(string(data), "\n") + if len(data) > 0 && !strings.HasSuffix(string(data), "\n") { + lineCount++ // Count last line if it doesn't end with newline + } + summary.TotalAdded += lineCount + summary.FilesAdded++ + } } } - log.Printf("gitDiffRepo: total_added=%d total_removed=%d", - summary.TotalAdded, summary.TotalRemoved) + log.Printf("gitDiffRepo: files_added=%d files_removed=%d total_added=%d total_removed=%d", + summary.FilesAdded, summary.FilesRemoved, summary.TotalAdded, summary.TotalRemoved) return summary, nil } diff --git a/components/backend/handlers/content.go b/components/backend/handlers/content.go index eea730ee6..6dae788df 100644 --- a/components/backend/handlers/content.go +++ b/components/backend/handlers/content.go @@ -128,11 +128,22 @@ func ContentGitDiff(c *gin.Context) { summary, err := GitDiffRepo(c.Request.Context(), repoDir) if err != nil { - c.JSON(http.StatusOK, gin.H{"total_added": 0, "total_removed": 0}) + c.JSON(http.StatusOK, gin.H{ + "files": gin.H{ + "added": 0, + "removed": 0, + }, + "total_added": 0, + "total_removed": 0, + }) return } c.JSON(http.StatusOK, gin.H{ + "files": gin.H{ + "added": summary.FilesAdded, + "removed": summary.FilesRemoved, + }, "total_added": summary.TotalAdded, "total_removed": summary.TotalRemoved, }) @@ -146,16 +157,23 @@ func ContentWrite(c *gin.Context) { Encoding string `json:"encoding"` } if err := c.ShouldBindJSON(&req); err != nil { + log.Printf("ContentWrite: bind JSON failed: %v", err) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + log.Printf("ContentWrite: path=%q contentLen=%d encoding=%q StateBaseDir=%q", req.Path, len(req.Content), req.Encoding, StateBaseDir) + path := filepath.Clean("/" + strings.TrimSpace(req.Path)) if path == "/" || strings.Contains(path, "..") { + log.Printf("ContentWrite: invalid path rejected: path=%q", path) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"}) return } abs := filepath.Join(StateBaseDir, path) + log.Printf("ContentWrite: absolute path=%q", abs) + if err := os.MkdirAll(filepath.Dir(abs), 0755); err != nil { + log.Printf("ContentWrite: mkdir failed for %q: %v", filepath.Dir(abs), err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create directory"}) return } @@ -163,6 +181,7 @@ func ContentWrite(c *gin.Context) { if strings.EqualFold(req.Encoding, "base64") { b, err := base64.StdEncoding.DecodeString(req.Content) if err != nil { + log.Printf("ContentWrite: base64 decode failed: %v", err) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid base64 content"}) return } @@ -171,22 +190,31 @@ func ContentWrite(c *gin.Context) { data = []byte(req.Content) } if err := os.WriteFile(abs, data, 0644); err != nil { + log.Printf("ContentWrite: write failed for %q: %v", abs, err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write file"}) return } + log.Printf("ContentWrite: successfully wrote %d bytes to %q", len(data), abs) c.JSON(http.StatusOK, gin.H{"message": "ok"}) } // ContentRead handles GET /content/file?path= func ContentRead(c *gin.Context) { path := filepath.Clean("/" + strings.TrimSpace(c.Query("path"))) + log.Printf("ContentRead: requested path=%q StateBaseDir=%q", c.Query("path"), StateBaseDir) + log.Printf("ContentRead: cleaned path=%q", path) + if path == "/" || strings.Contains(path, "..") { + log.Printf("ContentRead: invalid path rejected: path=%q", path) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"}) return } abs := filepath.Join(StateBaseDir, path) + log.Printf("ContentRead: absolute path=%q", abs) + b, err := os.ReadFile(abs) if err != nil { + log.Printf("ContentRead: read failed for %q: %v", abs, err) if os.IsNotExist(err) { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) } else { @@ -194,19 +222,28 @@ func ContentRead(c *gin.Context) { } return } + log.Printf("ContentRead: successfully read %d bytes from %q", len(b), abs) c.Data(http.StatusOK, "application/octet-stream", b) } // ContentList handles GET /content/list?path= func ContentList(c *gin.Context) { path := filepath.Clean("/" + strings.TrimSpace(c.Query("path"))) + log.Printf("ContentList: requested path=%q", c.Query("path")) + log.Printf("ContentList: cleaned path=%q", path) + log.Printf("ContentList: StateBaseDir=%q", StateBaseDir) + if path == "/" || strings.Contains(path, "..") { + log.Printf("ContentList: invalid path rejected: path=%q", path) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"}) return } abs := filepath.Join(StateBaseDir, path) + log.Printf("ContentList: absolute path=%q", abs) + info, err := os.Stat(abs) if err != nil { + log.Printf("ContentList: stat failed for %q: %v", abs, err) if os.IsNotExist(err) { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) } else { @@ -241,5 +278,6 @@ func ContentList(c *gin.Context) { "modifiedAt": info.ModTime().UTC().Format(time.RFC3339), }) } + log.Printf("ContentList: returning %d items for path=%q", len(items), path) c.JSON(http.StatusOK, gin.H{"items": items}) } diff --git a/components/backend/handlers/sessions.go b/components/backend/handlers/sessions.go index 9949ade03..e6896e414 100644 --- a/components/backend/handlers/sessions.go +++ b/components/backend/handlers/sessions.go @@ -19,10 +19,12 @@ import ( corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/resource" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" ktypes "k8s.io/apimachinery/pkg/types" + intstr "k8s.io/apimachinery/pkg/util/intstr" "k8s.io/client-go/dynamic" "k8s.io/client-go/kubernetes" ) @@ -151,7 +153,7 @@ func parseSpec(spec map[string]interface{}) types.AgenticSessionSpec { ng.URL = s } if s, ok := in["branch"].(string); ok && strings.TrimSpace(s) != "" { - ng.Branch = StringPtr(s) + ng.Branch = types.StringPtr(s) } r.Input = ng } @@ -161,13 +163,13 @@ func parseSpec(spec map[string]interface{}) types.AgenticSessionSpec { og.URL = s } if s, ok := out["branch"].(string); ok && strings.TrimSpace(s) != "" { - og.Branch = StringPtr(s) + og.Branch = types.StringPtr(s) } r.Output = og } // Include per-repo status if present if st, ok := m["status"].(string); ok { - r.Status = StringPtr(st) + r.Status = types.StringPtr(st) } if strings.TrimSpace(r.Input.URL) != "" { repos = append(repos, r) @@ -359,9 +361,40 @@ func CreateSession(c *gin.Context) { } // Optional environment variables passthrough (always, independent of git config presence) - if len(req.EnvironmentVariables) > 0 { + envVars := make(map[string]string) + for k, v := range req.EnvironmentVariables { + envVars[k] = v + } + + // Handle session continuation + if req.ParentSessionID != "" { + envVars["PARENT_SESSION_ID"] = req.ParentSessionID + // Add annotation to track continuation lineage + if metadata["annotations"] == nil { + metadata["annotations"] = make(map[string]interface{}) + } + annotations := metadata["annotations"].(map[string]interface{}) + annotations["vteam.ambient-code/parent-session-id"] = req.ParentSessionID + log.Printf("Creating continuation session from parent %s", req.ParentSessionID) + + // Clean up temp-content pod from parent session to free the PVC + // This prevents Multi-Attach errors when the new session tries to mount the same workspace + reqK8s, _ := GetK8sClientsForRequest(c) + if reqK8s != nil { + tempPodName := fmt.Sprintf("temp-content-%s", req.ParentSessionID) + if err := reqK8s.CoreV1().Pods(project).Delete(c.Request.Context(), tempPodName, v1.DeleteOptions{}); err != nil { + if !errors.IsNotFound(err) { + log.Printf("CreateSession: failed to delete temp-content pod %s (non-fatal): %v", tempPodName, err) + } + } else { + log.Printf("CreateSession: deleted temp-content pod %s to free PVC for continuation", tempPodName) + } + } + } + + if len(envVars) > 0 { spec := session["spec"].(map[string]interface{}) - spec["environmentVariables"] = req.EnvironmentVariables + spec["environmentVariables"] = envVars } // Interactive flag @@ -574,7 +607,7 @@ func provisionRunnerTokenForSession(c *gin.Context, reqK8s *kubernetes.Clientset Kind: obj.GetKind(), Name: obj.GetName(), UID: obj.GetUID(), - Controller: BoolPtr(true), + Controller: types.BoolPtr(true), } // Create ServiceAccount @@ -593,7 +626,7 @@ func provisionRunnerTokenForSession(c *gin.Context, reqK8s *kubernetes.Clientset } } - // Create Role with least-privilege for updating AgenticSession status + // Create Role with least-privilege for updating AgenticSession status and annotations roleName := fmt.Sprintf("ambient-session-%s-role", sessionName) role := &rbacv1.Role{ ObjectMeta: v1.ObjectMeta{ @@ -610,12 +643,25 @@ func provisionRunnerTokenForSession(c *gin.Context, reqK8s *kubernetes.Clientset { APIGroups: []string{"vteam.ambient-code"}, Resources: []string{"agenticsessions"}, - Verbs: []string{"get", "list", "watch"}, + Verbs: []string{"get", "list", "watch", "update", "patch"}, // Added update, patch for annotations + }, + { + APIGroups: []string{"authorization.k8s.io"}, + Resources: []string{"selfsubjectaccessreviews"}, + Verbs: []string{"create"}, }, }, } + // Try to create or update the Role to ensure it has latest permissions if _, err := reqK8s.RbacV1().Roles(project).Create(c.Request.Context(), role, v1.CreateOptions{}); err != nil { - if !errors.IsAlreadyExists(err) { + if errors.IsAlreadyExists(err) { + // Role exists - update it to ensure it has the latest permissions (including update/patch) + log.Printf("Role %s already exists, updating with latest permissions", roleName) + if _, err := reqK8s.RbacV1().Roles(project).Update(c.Request.Context(), role, v1.UpdateOptions{}); err != nil { + return fmt.Errorf("update Role: %w", err) + } + log.Printf("Successfully updated Role %s with annotation update permissions", roleName) + } else { return fmt.Errorf("create Role: %w", err) } } @@ -653,7 +699,7 @@ func provisionRunnerTokenForSession(c *gin.Context, reqK8s *kubernetes.Clientset "k8s-token": k8sToken, } - // Store both tokens in a Secret + // Store token in a Secret (update if exists to refresh token) secretName := fmt.Sprintf("ambient-runner-token-%s", sessionName) sec := &corev1.Secret{ ObjectMeta: v1.ObjectMeta{ @@ -665,8 +711,17 @@ func provisionRunnerTokenForSession(c *gin.Context, reqK8s *kubernetes.Clientset Type: corev1.SecretTypeOpaque, StringData: secretData, } + + // Try to create the secret if _, err := reqK8s.CoreV1().Secrets(project).Create(c.Request.Context(), sec, v1.CreateOptions{}); err != nil { - if !errors.IsAlreadyExists(err) { + if errors.IsAlreadyExists(err) { + // Secret exists - update it with fresh token + log.Printf("Updating existing secret %s with fresh token", secretName) + if _, err := reqK8s.CoreV1().Secrets(project).Update(c.Request.Context(), sec, v1.UpdateOptions{}); err != nil { + return fmt.Errorf("update Secret: %w", err) + } + log.Printf("Successfully updated secret %s with fresh token", secretName) + } else { return fmt.Errorf("create Secret: %w", err) } } @@ -825,6 +880,55 @@ func MintSessionGitHubToken(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"token": tokenStr}) } +func PatchSession(c *gin.Context) { + project := c.GetString("project") + sessionName := c.Param("sessionName") + _, reqDyn := GetK8sClientsForRequest(c) + + var patch map[string]interface{} + if err := c.ShouldBindJSON(&patch); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + gvr := GetAgenticSessionV1Alpha1Resource() + + // Get current resource + item, err := reqDyn.Resource(gvr).Namespace(project).Get(context.TODO(), sessionName, v1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "Session not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get session"}) + return + } + + // Apply patch to metadata annotations + if metaPatch, ok := patch["metadata"].(map[string]interface{}); ok { + if annsPatch, ok := metaPatch["annotations"].(map[string]interface{}); ok { + metadata := item.Object["metadata"].(map[string]interface{}) + if metadata["annotations"] == nil { + metadata["annotations"] = make(map[string]interface{}) + } + anns := metadata["annotations"].(map[string]interface{}) + for k, v := range annsPatch { + anns[k] = v + } + } + } + + // Update the resource + updated, err := reqDyn.Resource(gvr).Namespace(project).Update(context.TODO(), item, v1.UpdateOptions{}) + if err != nil { + log.Printf("Failed to patch agentic session %s: %v", sessionName, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to patch session"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Session patched successfully", "annotations": updated.GetAnnotations()}) +} + func UpdateSession(c *gin.Context) { project := c.GetString("project") sessionName := c.Param("sessionName") @@ -1117,11 +1221,62 @@ func CloneSession(c *gin.Context) { c.JSON(http.StatusCreated, session) } +// ensureRunnerRolePermissions updates the runner role to ensure it has all required permissions +// This is useful for existing sessions that were created before we added new permissions +func ensureRunnerRolePermissions(c *gin.Context, reqK8s *kubernetes.Clientset, project string, sessionName string) error { + roleName := fmt.Sprintf("ambient-session-%s-role", sessionName) + + // Get existing role + existingRole, err := reqK8s.RbacV1().Roles(project).Get(c.Request.Context(), roleName, v1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + log.Printf("Role %s not found for session %s - will be created by operator", roleName, sessionName) + return nil + } + return fmt.Errorf("get role: %w", err) + } + + // Check if role has selfsubjectaccessreviews permission + hasSelfSubjectAccessReview := false + for _, rule := range existingRole.Rules { + for _, apiGroup := range rule.APIGroups { + if apiGroup == "authorization.k8s.io" { + for _, resource := range rule.Resources { + if resource == "selfsubjectaccessreviews" { + hasSelfSubjectAccessReview = true + break + } + } + } + } + } + + if hasSelfSubjectAccessReview { + log.Printf("Role %s already has selfsubjectaccessreviews permission", roleName) + return nil + } + + // Add missing permission + log.Printf("Updating role %s to add selfsubjectaccessreviews permission", roleName) + existingRole.Rules = append(existingRole.Rules, rbacv1.PolicyRule{ + APIGroups: []string{"authorization.k8s.io"}, + Resources: []string{"selfsubjectaccessreviews"}, + Verbs: []string{"create"}, + }) + + _, err = reqK8s.RbacV1().Roles(project).Update(c.Request.Context(), existingRole, v1.UpdateOptions{}) + if err != nil { + return fmt.Errorf("update role: %w", err) + } + + log.Printf("Successfully updated role %s with selfsubjectaccessreviews permission", roleName) + return nil +} + func StartSession(c *gin.Context) { project := c.GetString("project") sessionName := c.Param("sessionName") reqK8s, reqDyn := GetK8sClientsForRequest(c) - _ = reqK8s gvr := GetAgenticSessionV1Alpha1Resource() // Get current resource @@ -1136,18 +1291,119 @@ func StartSession(c *gin.Context) { return } - // Update status to trigger start + // Ensure runner role has required permissions (update if needed for existing sessions) + if err := ensureRunnerRolePermissions(c, reqK8s, project, sessionName); err != nil { + log.Printf("Warning: failed to ensure runner role permissions for %s: %v", sessionName, err) + // Non-fatal - continue with restart + } + + // Clean up temp-content pod if it exists to free the PVC + // This prevents Multi-Attach errors when the session job tries to mount the workspace + if reqK8s != nil { + tempPodName := fmt.Sprintf("temp-content-%s", sessionName) + if err := reqK8s.CoreV1().Pods(project).Delete(c.Request.Context(), tempPodName, v1.DeleteOptions{}); err != nil { + if !errors.IsNotFound(err) { + log.Printf("StartSession: failed to delete temp-content pod %s (non-fatal): %v", tempPodName, err) + } + } else { + log.Printf("StartSession: deleted temp-content pod %s to free PVC", tempPodName) + } + } + + // Check if this is a continuation (session is in a terminal phase) + // Terminal phases from CRD: Completed, Failed, Stopped, Error + isActualContinuation := false + currentPhase := "" + if currentStatus, ok := item.Object["status"].(map[string]interface{}); ok { + if phase, ok := currentStatus["phase"].(string); ok { + currentPhase = phase + terminalPhases := []string{"Completed", "Failed", "Stopped", "Error"} + for _, terminalPhase := range terminalPhases { + if phase == terminalPhase { + isActualContinuation = true + log.Printf("StartSession: Detected continuation - session is in terminal phase: %s", phase) + break + } + } + } + } + + if !isActualContinuation { + log.Printf("StartSession: Not a continuation - current phase is: %s (not in terminal phases)", currentPhase) + } + + // Only set parent session annotation if this is an actual continuation + // Don't set it on first start, even though StartSession can be called for initial creation + if isActualContinuation { + annotations := item.GetAnnotations() + if annotations == nil { + annotations = make(map[string]string) + } + annotations["vteam.ambient-code/parent-session-id"] = sessionName + item.SetAnnotations(annotations) + log.Printf("StartSession: Set parent-session-id annotation to %s for continuation (has completion time)", sessionName) + + // For headless sessions being continued, force interactive mode + if spec, ok := item.Object["spec"].(map[string]interface{}); ok { + if interactive, ok := spec["interactive"].(bool); !ok || !interactive { + // Session was headless, convert to interactive + spec["interactive"] = true + log.Printf("StartSession: Converting headless session to interactive for continuation") + } + } + + // Update the metadata and spec to persist the annotation and interactive flag + item, err = reqDyn.Resource(gvr).Namespace(project).Update(context.TODO(), item, v1.UpdateOptions{}) + if err != nil { + log.Printf("Failed to update agentic session metadata %s in project %s: %v", sessionName, project, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update session metadata"}) + return + } + + // Regenerate runner token for continuation (old token may have expired) + log.Printf("StartSession: Regenerating runner token for session continuation") + if err := provisionRunnerTokenForSession(c, reqK8s, reqDyn, project, sessionName); err != nil { + log.Printf("Warning: failed to regenerate runner token for session %s/%s: %v", project, sessionName, err) + // Non-fatal: continue anyway, operator may retry + } else { + log.Printf("StartSession: Successfully regenerated runner token for continuation") + + // Delete the old job so operator creates a new one + // This ensures fresh token and clean state + jobName := fmt.Sprintf("ambient-runner-%s", sessionName) + log.Printf("StartSession: Deleting old job %s to allow operator to create fresh one", jobName) + if err := reqK8s.BatchV1().Jobs(project).Delete(c.Request.Context(), jobName, v1.DeleteOptions{ + PropagationPolicy: func() *v1.DeletionPropagation { p := v1.DeletePropagationBackground; return &p }(), + }); err != nil { + if !errors.IsNotFound(err) { + log.Printf("Warning: failed to delete old job %s: %v", jobName, err) + } else { + log.Printf("StartSession: Job %s already gone", jobName) + } + } else { + log.Printf("StartSession: Successfully deleted old job %s", jobName) + } + } + } else { + log.Printf("StartSession: Not setting parent-session-id (first run, no completion time)") + } + + // Now update status to trigger start (using the fresh object from Update) if item.Object["status"] == nil { item.Object["status"] = make(map[string]interface{}) } status := item.Object["status"].(map[string]interface{}) - status["phase"] = "Creating" - status["message"] = "Session start requested" + // Set to Pending so operator will process it (operator only acts on Pending phase) + status["phase"] = "Pending" + status["message"] = "Session restart requested" + // Clear completion time from previous run + delete(status, "completionTime") + // Update start time for this run status["startTime"] = time.Now().Format(time.RFC3339) - // Update the resource - updated, err := reqDyn.Resource(gvr).Namespace(project).Update(context.TODO(), item, v1.UpdateOptions{}) + // Update the status subresource (must use UpdateStatus, not Update) + updated, err := reqDyn.Resource(gvr).Namespace(project).UpdateStatus(context.TODO(), item, v1.UpdateOptions{}) if err != nil { log.Printf("Failed to start agentic session %s in project %s: %v", sessionName, project, err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to start agentic session"}) @@ -1207,19 +1463,54 @@ func StopSession(c *gin.Context) { // Get job name from status jobName, jobExists := status["jobName"].(string) - if jobExists && jobName != "" { - // Delete the job - err := reqK8s.BatchV1().Jobs(project).Delete(context.TODO(), jobName, v1.DeleteOptions{}) - if err != nil && !errors.IsNotFound(err) { + if !jobExists || jobName == "" { + // Try to derive job name if not in status + jobName = fmt.Sprintf("%s-job", sessionName) + log.Printf("Job name not in status, trying derived name: %s", jobName) + } + + // Delete the job and its pods + log.Printf("Attempting to delete job %s for session %s", jobName, sessionName) + + // First, delete the job itself with foreground propagation + deletePolicy := v1.DeletePropagationForeground + err = reqK8s.BatchV1().Jobs(project).Delete(context.TODO(), jobName, v1.DeleteOptions{ + PropagationPolicy: &deletePolicy, + }) + if err != nil { + if errors.IsNotFound(err) { + log.Printf("Job %s not found (may have already completed or been deleted)", jobName) + } else { log.Printf("Failed to delete job %s: %v", jobName, err) // Don't fail the request if job deletion fails - continue with status update log.Printf("Continuing with status update despite job deletion failure") - } else { - log.Printf("Deleted job %s for agentic session %s", jobName, sessionName) } } else { - // Handle case where job was never created or jobName is missing - log.Printf("No job found to delete for agentic session %s", sessionName) + log.Printf("Successfully deleted job %s for agentic session %s", jobName, sessionName) + } + + // Then, explicitly delete all pods for this job (by job-name label) + podSelector := fmt.Sprintf("job-name=%s", jobName) + log.Printf("Deleting pods with job-name selector: %s", podSelector) + err = reqK8s.CoreV1().Pods(project).DeleteCollection(context.TODO(), v1.DeleteOptions{}, v1.ListOptions{ + LabelSelector: podSelector, + }) + if err != nil && !errors.IsNotFound(err) { + log.Printf("Failed to delete pods for job %s: %v (continuing anyway)", jobName, err) + } else { + log.Printf("Successfully deleted pods for job %s", jobName) + } + + // Also delete any pods labeled with this session (in case owner refs are lost) + sessionPodSelector := fmt.Sprintf("agentic-session=%s", sessionName) + log.Printf("Deleting pods with agentic-session selector: %s", sessionPodSelector) + err = reqK8s.CoreV1().Pods(project).DeleteCollection(context.TODO(), v1.DeleteOptions{}, v1.ListOptions{ + LabelSelector: sessionPodSelector, + }) + if err != nil && !errors.IsNotFound(err) { + log.Printf("Failed to delete session pods: %v (continuing anyway)", err) + } else { + log.Printf("Successfully deleted session-labeled pods") } // Update status to Stopped @@ -1227,8 +1518,22 @@ func StopSession(c *gin.Context) { status["message"] = "Session stopped by user" status["completionTime"] = time.Now().Format(time.RFC3339) - // Update the resource - updated, err := reqDyn.Resource(gvr).Namespace(project).Update(context.TODO(), item, v1.UpdateOptions{}) + // Also set interactive: true in spec so session can be restarted + if spec, ok := item.Object["spec"].(map[string]interface{}); ok { + if interactive, ok := spec["interactive"].(bool); !ok || !interactive { + log.Printf("Setting interactive: true for stopped session %s to allow restart", sessionName) + spec["interactive"] = true + // Update spec first (must use Update, not UpdateStatus) + item, err = reqDyn.Resource(gvr).Namespace(project).Update(context.TODO(), item, v1.UpdateOptions{}) + if err != nil { + log.Printf("Failed to update session spec for %s: %v (continuing with status update)", sessionName, err) + // Continue anyway - status update is more important + } + } + } + + // Update the resource using UpdateStatus for status subresource + updated, err := reqDyn.Resource(gvr).Namespace(project).UpdateStatus(context.TODO(), item, v1.UpdateOptions{}) if err != nil { if errors.IsNotFound(err) { // Session was deleted while we were trying to update it @@ -1320,44 +1625,497 @@ func UpdateSessionStatus(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "agentic session status updated"}) } -// setRepoStatus updates spec.repos[idx].status to a new value +// SpawnContentPod creates a temporary pod for workspace access on completed sessions +// POST /api/projects/:projectName/agentic-sessions/:sessionName/spawn-content-pod +func SpawnContentPod(c *gin.Context) { + // Get project from context (set by middleware) or param + project := c.GetString("project") + if project == "" { + project = c.Param("projectName") + } + sessionName := c.Param("sessionName") + + reqK8s, _ := GetK8sClientsForRequest(c) + if reqK8s == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + + podName := fmt.Sprintf("temp-content-%s", sessionName) + + // Check if already exists + if existing, err := reqK8s.CoreV1().Pods(project).Get(c.Request.Context(), podName, v1.GetOptions{}); err == nil { + ready := false + for _, cond := range existing.Status.Conditions { + if cond.Type == corev1.PodReady && cond.Status == corev1.ConditionTrue { + ready = true + break + } + } + c.JSON(http.StatusOK, gin.H{"status": "exists", "podName": podName, "ready": ready}) + return + } + + // Verify PVC exists + pvcName := fmt.Sprintf("ambient-workspace-%s", sessionName) + if _, err := reqK8s.CoreV1().PersistentVolumeClaims(project).Get(c.Request.Context(), pvcName, v1.GetOptions{}); err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "workspace PVC not found"}) + return + } + + // Get content service image from env + contentImage := os.Getenv("CONTENT_SERVICE_IMAGE") + if contentImage == "" { + contentImage = "quay.io/ambient_code/vteam_backend:latest" + } + imagePullPolicy := corev1.PullIfNotPresent + if os.Getenv("IMAGE_PULL_POLICY") == "Always" { + imagePullPolicy = corev1.PullAlways + } + + // Create temporary pod + pod := &corev1.Pod{ + ObjectMeta: v1.ObjectMeta{ + Name: podName, + Namespace: project, + Labels: map[string]string{ + "app": "temp-content-service", + "temp-content-for-session": sessionName, + }, + Annotations: map[string]string{ + "vteam.ambient-code/ttl": "900", + "vteam.ambient-code/created-at": time.Now().Format(time.RFC3339), + }, + }, + Spec: corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyNever, + Containers: []corev1.Container{ + { + Name: "content", + Image: contentImage, + ImagePullPolicy: imagePullPolicy, + Env: []corev1.EnvVar{ + {Name: "CONTENT_SERVICE_MODE", Value: "true"}, + {Name: "STATE_BASE_DIR", Value: "/workspace"}, + }, + Ports: []corev1.ContainerPort{{ContainerPort: 8080, Name: "http"}}, + ReadinessProbe: &corev1.Probe{ + ProbeHandler: corev1.ProbeHandler{ + HTTPGet: &corev1.HTTPGetAction{ + Path: "/health", + Port: intstr.FromString("http"), + }, + }, + InitialDelaySeconds: 2, + PeriodSeconds: 2, + }, + VolumeMounts: []corev1.VolumeMount{ + { + Name: "workspace", + MountPath: "/workspace", + ReadOnly: false, + }, + }, + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("100m"), + corev1.ResourceMemory: resource.MustParse("128Mi"), + }, + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("500m"), + corev1.ResourceMemory: resource.MustParse("512Mi"), + }, + }, + }, + }, + Volumes: []corev1.Volume{ + { + Name: "workspace", + VolumeSource: corev1.VolumeSource{ + PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ + ClaimName: pvcName, + }, + }, + }, + }, + }, + } + + created, err := reqK8s.CoreV1().Pods(project).Create(c.Request.Context(), pod, v1.CreateOptions{}) + if err != nil { + log.Printf("Failed to create temp content pod: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to create pod: %v", err)}) + return + } + + // Create service + svc := &corev1.Service{ + ObjectMeta: v1.ObjectMeta{ + Name: fmt.Sprintf("temp-content-%s", sessionName), + Namespace: project, + Labels: map[string]string{ + "app": "temp-content-service", + "temp-content-for-session": sessionName, + }, + OwnerReferences: []v1.OwnerReference{ + { + APIVersion: "v1", + Kind: "Pod", + Name: podName, + UID: created.UID, + Controller: types.BoolPtr(true), + }, + }, + }, + Spec: corev1.ServiceSpec{ + Selector: map[string]string{ + "temp-content-for-session": sessionName, + }, + Ports: []corev1.ServicePort{ + {Port: 8080, TargetPort: intstr.FromString("http")}, + }, + }, + } + + if _, err := reqK8s.CoreV1().Services(project).Create(c.Request.Context(), svc, v1.CreateOptions{}); err != nil && !errors.IsAlreadyExists(err) { + log.Printf("Failed to create temp service: %v", err) + } + + c.JSON(http.StatusOK, gin.H{ + "status": "creating", + "podName": podName, + }) +} + +// GetContentPodStatus checks if temporary content pod is ready +// GET /api/projects/:projectName/agentic-sessions/:sessionName/content-pod-status +func GetContentPodStatus(c *gin.Context) { + // Get project from context (set by middleware) or param + project := c.GetString("project") + if project == "" { + project = c.Param("projectName") + } + sessionName := c.Param("sessionName") + + reqK8s, _ := GetK8sClientsForRequest(c) + if reqK8s == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + + podName := fmt.Sprintf("temp-content-%s", sessionName) + pod, err := reqK8s.CoreV1().Pods(project).Get(c.Request.Context(), podName, v1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + c.JSON(http.StatusNotFound, gin.H{"status": "not_found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get pod"}) + return + } + + ready := false + for _, cond := range pod.Status.Conditions { + if cond.Type == corev1.PodReady && cond.Status == corev1.ConditionTrue { + ready = true + break + } + } + + c.JSON(http.StatusOK, gin.H{ + "status": string(pod.Status.Phase), + "ready": ready, + "podName": podName, + "createdAt": pod.CreationTimestamp.Format(time.RFC3339), + }) +} + +// DeleteContentPod removes temporary content pod +// DELETE /api/projects/:projectName/agentic-sessions/:sessionName/content-pod +func DeleteContentPod(c *gin.Context) { + // Get project from context (set by middleware) or param + project := c.GetString("project") + if project == "" { + project = c.Param("projectName") + } + sessionName := c.Param("sessionName") + + reqK8s, _ := GetK8sClientsForRequest(c) + if reqK8s == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + + podName := fmt.Sprintf("temp-content-%s", sessionName) + err := reqK8s.CoreV1().Pods(project).Delete(c.Request.Context(), podName, v1.DeleteOptions{}) + if err != nil && !errors.IsNotFound(err) { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete pod"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "content pod deleted"}) +} + +// GetSessionK8sResources returns job, pod, and PVC information for a session +// GET /api/projects/:projectName/agentic-sessions/:sessionName/k8s-resources +func GetSessionK8sResources(c *gin.Context) { + // Get project from context (set by middleware) or param + project := c.GetString("project") + if project == "" { + project = c.Param("projectName") + } + sessionName := c.Param("sessionName") + + reqK8s, reqDyn := GetK8sClientsForRequest(c) + if reqK8s == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + + // Get session to find job name + gvr := GetAgenticSessionV1Alpha1Resource() + session, err := reqDyn.Resource(gvr).Namespace(project).Get(c.Request.Context(), sessionName, v1.GetOptions{}) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "session not found"}) + return + } + + status, _ := session.Object["status"].(map[string]interface{}) + jobName, _ := status["jobName"].(string) + if jobName == "" { + jobName = fmt.Sprintf("%s-job", sessionName) + } + + result := map[string]interface{}{} + + // Get Job status + job, err := reqK8s.BatchV1().Jobs(project).Get(c.Request.Context(), jobName, v1.GetOptions{}) + jobExists := err == nil + + if jobExists { + result["jobName"] = jobName + jobStatus := "Unknown" + if job.Status.Active > 0 { + jobStatus = "Active" + } else if job.Status.Succeeded > 0 { + jobStatus = "Succeeded" + } else if job.Status.Failed > 0 { + jobStatus = "Failed" + } + result["jobStatus"] = jobStatus + result["jobConditions"] = job.Status.Conditions + } else if errors.IsNotFound(err) { + // Job not found - don't return job info at all + log.Printf("GetSessionK8sResources: Job %s not found, omitting from response", jobName) + // Don't include jobName or jobStatus in result + } else { + // Other error - still show job name but with error status + result["jobName"] = jobName + result["jobStatus"] = "Error" + log.Printf("GetSessionK8sResources: Error getting job %s: %v", jobName, err) + } + + // Get Pods for this job (only if job exists) + podInfos := []map[string]interface{}{} + if jobExists { + pods, err := reqK8s.CoreV1().Pods(project).List(c.Request.Context(), v1.ListOptions{ + LabelSelector: fmt.Sprintf("job-name=%s", jobName), + }) + if err == nil { + for _, pod := range pods.Items { + // Check if pod is terminating (has DeletionTimestamp) + podPhase := string(pod.Status.Phase) + if pod.DeletionTimestamp != nil { + podPhase = "Terminating" + } + + containerInfos := []map[string]interface{}{} + for _, cs := range pod.Status.ContainerStatuses { + state := "Unknown" + var exitCode *int32 + var reason string + if cs.State.Running != nil { + state = "Running" + // If pod is terminating but container still shows running, mark it as terminating + if pod.DeletionTimestamp != nil { + state = "Terminating" + } + } else if cs.State.Terminated != nil { + state = "Terminated" + exitCode = &cs.State.Terminated.ExitCode + reason = cs.State.Terminated.Reason + } else if cs.State.Waiting != nil { + state = "Waiting" + reason = cs.State.Waiting.Reason + } + containerInfos = append(containerInfos, map[string]interface{}{ + "name": cs.Name, + "state": state, + "exitCode": exitCode, + "reason": reason, + }) + } + podInfos = append(podInfos, map[string]interface{}{ + "name": pod.Name, + "phase": podPhase, + "containers": containerInfos, + }) + } + } + } + + // Check for temp-content pod + tempPodName := fmt.Sprintf("temp-content-%s", sessionName) + tempPod, err := reqK8s.CoreV1().Pods(project).Get(c.Request.Context(), tempPodName, v1.GetOptions{}) + if err == nil { + tempPodPhase := string(tempPod.Status.Phase) + if tempPod.DeletionTimestamp != nil { + tempPodPhase = "Terminating" + } + + containerInfos := []map[string]interface{}{} + for _, cs := range tempPod.Status.ContainerStatuses { + state := "Unknown" + var exitCode *int32 + var reason string + if cs.State.Running != nil { + state = "Running" + // If pod is terminating but container still shows running, mark as terminating + if tempPod.DeletionTimestamp != nil { + state = "Terminating" + } + } else if cs.State.Terminated != nil { + state = "Terminated" + exitCode = &cs.State.Terminated.ExitCode + reason = cs.State.Terminated.Reason + } else if cs.State.Waiting != nil { + state = "Waiting" + reason = cs.State.Waiting.Reason + } + containerInfos = append(containerInfos, map[string]interface{}{ + "name": cs.Name, + "state": state, + "exitCode": exitCode, + "reason": reason, + }) + } + podInfos = append(podInfos, map[string]interface{}{ + "name": tempPod.Name, + "phase": tempPodPhase, + "containers": containerInfos, + "isTempPod": true, + }) + } + + result["pods"] = podInfos + + // Get PVC info - always use session's own PVC name + // Note: If session was created with parent_session_id (via API), the operator handles PVC reuse + pvcName := fmt.Sprintf("ambient-workspace-%s", sessionName) + pvc, err := reqK8s.CoreV1().PersistentVolumeClaims(project).Get(c.Request.Context(), pvcName, v1.GetOptions{}) + result["pvcName"] = pvcName + if err == nil { + result["pvcExists"] = true + if storage, ok := pvc.Status.Capacity[corev1.ResourceStorage]; ok { + result["pvcSize"] = storage.String() + } + } else { + result["pvcExists"] = false + } + + c.JSON(http.StatusOK, result) +} + +// setRepoStatus updates status.repos[idx] with status and diff info func setRepoStatus(dyn dynamic.Interface, project, sessionName string, repoIndex int, newStatus string) error { gvr := GetAgenticSessionV1Alpha1Resource() item, err := dyn.Resource(gvr).Namespace(project).Get(context.TODO(), sessionName, v1.GetOptions{}) if err != nil { return err } + + // Get repo name from spec.repos[repoIndex] spec, _ := item.Object["spec"].(map[string]interface{}) - if spec == nil { - spec = map[string]interface{}{} - } - repos, _ := spec["repos"].([]interface{}) - if repoIndex < 0 || repoIndex >= len(repos) { + specRepos, _ := spec["repos"].([]interface{}) + if repoIndex < 0 || repoIndex >= len(specRepos) { return fmt.Errorf("repo index out of range") } - rm, _ := repos[repoIndex].(map[string]interface{}) - if rm == nil { - rm = map[string]interface{}{} + specRepo, _ := specRepos[repoIndex].(map[string]interface{}) + repoName := "" + if name, ok := specRepo["name"].(string); ok { + repoName = name + } else if input, ok := specRepo["input"].(map[string]interface{}); ok { + if url, ok := input["url"].(string); ok { + repoName = DeriveRepoFolderFromURL(url) + } } - rm["status"] = newStatus - repos[repoIndex] = rm - spec["repos"] = repos - item.Object["spec"] = spec - updated, err := dyn.Resource(gvr).Namespace(project).Update(context.TODO(), item, v1.UpdateOptions{}) + if repoName == "" { + repoName = fmt.Sprintf("repo-%d", repoIndex) + } + + // Ensure status.repos exists + if item.Object["status"] == nil { + item.Object["status"] = make(map[string]interface{}) + } + status := item.Object["status"].(map[string]interface{}) + statusRepos, _ := status["repos"].([]interface{}) + if statusRepos == nil { + statusRepos = []interface{}{} + } + + // Find or create status entry for this repo + repoStatus := map[string]interface{}{ + "name": repoName, + "status": newStatus, + "last_updated": time.Now().Format(time.RFC3339), + } + + // Update existing or append new + found := false + for i, r := range statusRepos { + if rm, ok := r.(map[string]interface{}); ok { + if n, ok := rm["name"].(string); ok && n == repoName { + rm["status"] = newStatus + rm["last_updated"] = time.Now().Format(time.RFC3339) + statusRepos[i] = rm + found = true + break + } + } + } + if !found { + statusRepos = append(statusRepos, repoStatus) + } + + status["repos"] = statusRepos + item.Object["status"] = status + + updated, err := dyn.Resource(gvr).Namespace(project).UpdateStatus(context.TODO(), item, v1.UpdateOptions{}) if err != nil { log.Printf("setRepoStatus: update failed project=%s session=%s repoIndex=%d status=%s err=%v", project, sessionName, repoIndex, newStatus, err) return err } if updated != nil { - log.Printf("setRepoStatus: update ok project=%s session=%s repoIndex=%d status=%s", project, sessionName, repoIndex, newStatus) + log.Printf("setRepoStatus: update ok project=%s session=%s repo=%s status=%s", project, sessionName, repoName, newStatus) } return nil } // listSessionWorkspace proxies to per-job content service for directory listing func ListSessionWorkspace(c *gin.Context) { - project := c.Param("projectName") + // Get project from context (set by middleware) or param + project := c.GetString("project") + if project == "" { + project = c.Param("projectName") + } session := c.Param("sessionName") + + if project == "" { + log.Printf("ListSessionWorkspace: project is empty, session=%s", session) + c.JSON(http.StatusBadRequest, gin.H{"error": "Project namespace required"}) + return + } + rel := strings.TrimSpace(c.Query("path")) // Build absolute workspace path using plain session (no url.PathEscape to match FS paths) absPath := "/sessions/" + session + "/workspace" @@ -1365,18 +2123,27 @@ func ListSessionWorkspace(c *gin.Context) { absPath += "/" + rel } - // Call per-job service directly to avoid any default base that targets per-namespace service + // Call per-job service or temp service for completed sessions token := c.GetHeader("Authorization") if strings.TrimSpace(token) == "" { token = c.GetHeader("X-Forwarded-Access-Token") } - base := os.Getenv("SESSION_CONTENT_SERVICE_BASE") - if base == "" { - // Per-job Service name created by operator: ambient-content- in project namespace - base = "http://ambient-content-%s.%s.svc:8080" + + // Try temp service first (for completed sessions), then regular service + serviceName := fmt.Sprintf("temp-content-%s", session) + reqK8s, _ := GetK8sClientsForRequest(c) + if reqK8s != nil { + if _, err := reqK8s.CoreV1().Services(project).Get(c.Request.Context(), serviceName, v1.GetOptions{}); err != nil { + // Temp service doesn't exist, use regular service + serviceName = fmt.Sprintf("ambient-content-%s", session) + } + } else { + serviceName = fmt.Sprintf("ambient-content-%s", session) } - endpoint := fmt.Sprintf(base, session, project) + + endpoint := fmt.Sprintf("http://%s.%s.svc:8080", serviceName, project) u := fmt.Sprintf("%s/content/list?path=%s", endpoint, url.QueryEscape(absPath)) + log.Printf("ListSessionWorkspace: project=%s session=%s endpoint=%s", project, session, endpoint) req, _ := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, u, nil) if strings.TrimSpace(token) != "" { req.Header.Set("Authorization", token) @@ -1384,30 +2151,59 @@ func ListSessionWorkspace(c *gin.Context) { client := &http.Client{Timeout: 4 * time.Second} resp, err := client.Do(req) if err != nil { + log.Printf("ListSessionWorkspace: content service request failed: %v", err) // Soften error to 200 with empty list so UI doesn't spam c.JSON(http.StatusOK, gin.H{"items": []any{}}) return } defer resp.Body.Close() b, _ := io.ReadAll(resp.Body) + + // If content service returns 404, check if it's because workspace doesn't exist yet + if resp.StatusCode == http.StatusNotFound { + log.Printf("ListSessionWorkspace: workspace not found (may not be created yet by runner)") + // Return empty list instead of error for better UX during session startup + c.JSON(http.StatusOK, gin.H{"items": []any{}}) + return + } + c.Data(resp.StatusCode, resp.Header.Get("Content-Type"), b) } // getSessionWorkspaceFile reads a file via content service func GetSessionWorkspaceFile(c *gin.Context) { - project := c.Param("projectName") + // Get project from context (set by middleware) or param + project := c.GetString("project") + if project == "" { + project = c.Param("projectName") + } session := c.Param("sessionName") + + if project == "" { + log.Printf("GetSessionWorkspaceFile: project is empty, session=%s", session) + c.JSON(http.StatusBadRequest, gin.H{"error": "Project namespace required"}) + return + } + sub := strings.TrimPrefix(c.Param("path"), "/") absPath := "/sessions/" + session + "/workspace/" + sub token := c.GetHeader("Authorization") if strings.TrimSpace(token) == "" { token = c.GetHeader("X-Forwarded-Access-Token") } - base := os.Getenv("SESSION_CONTENT_SERVICE_BASE") - if base == "" { - base = "http://ambient-content-%s.%s.svc:8080" + + // Try temp service first (for completed sessions), then regular service + serviceName := fmt.Sprintf("temp-content-%s", session) + reqK8s, _ := GetK8sClientsForRequest(c) + if reqK8s != nil { + if _, err := reqK8s.CoreV1().Services(project).Get(c.Request.Context(), serviceName, v1.GetOptions{}); err != nil { + serviceName = fmt.Sprintf("ambient-content-%s", session) + } + } else { + serviceName = fmt.Sprintf("ambient-content-%s", session) } - endpoint := fmt.Sprintf(base, session, project) + + endpoint := fmt.Sprintf("http://%s.%s.svc:8080", serviceName, project) u := fmt.Sprintf("%s/content/file?path=%s", endpoint, url.QueryEscape(absPath)) req, _ := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, u, nil) if strings.TrimSpace(token) != "" { @@ -1426,19 +2222,39 @@ func GetSessionWorkspaceFile(c *gin.Context) { // putSessionWorkspaceFile writes a file via content service func PutSessionWorkspaceFile(c *gin.Context) { - project := c.Param("projectName") + // Get project from context (set by middleware) or param + project := c.GetString("project") + if project == "" { + project = c.Param("projectName") + } session := c.Param("sessionName") + + if project == "" { + log.Printf("PutSessionWorkspaceFile: project is empty, session=%s", session) + c.JSON(http.StatusBadRequest, gin.H{"error": "Project namespace required"}) + return + } sub := strings.TrimPrefix(c.Param("path"), "/") absPath := "/sessions/" + session + "/workspace/" + sub token := c.GetHeader("Authorization") if strings.TrimSpace(token) == "" { token = c.GetHeader("X-Forwarded-Access-Token") } - base := os.Getenv("SESSION_CONTENT_SERVICE_BASE") - if base == "" { - base = "http://ambient-content-%s.%s.svc:8080" + + // Try temp service first (for completed sessions), then regular service + serviceName := fmt.Sprintf("temp-content-%s", session) + reqK8s, _ := GetK8sClientsForRequest(c) + if reqK8s != nil { + if _, err := reqK8s.CoreV1().Services(project).Get(c.Request.Context(), serviceName, v1.GetOptions{}); err != nil { + // Temp service doesn't exist, use regular service + serviceName = fmt.Sprintf("ambient-content-%s", session) + } + } else { + serviceName = fmt.Sprintf("ambient-content-%s", session) } - endpoint := fmt.Sprintf(base, session, project) + + endpoint := fmt.Sprintf("http://%s.%s.svc:8080", serviceName, project) + log.Printf("PutSessionWorkspaceFile: using service %s for session %s", serviceName, session) payload, _ := io.ReadAll(c.Request.Body) wreq := struct { Path string `json:"path"` @@ -1479,12 +2295,18 @@ func PushSessionRepo(c *gin.Context) { } log.Printf("pushSessionRepo: request project=%s session=%s repoIndex=%d commitLen=%d", project, session, body.RepoIndex, len(strings.TrimSpace(body.CommitMessage))) - base := os.Getenv("SESSION_CONTENT_SERVICE_BASE") - if base == "" { - // Default: per-job service name ambient-content- in the project namespace - base = "http://ambient-content-%s.%s.svc:8080" + // Try temp service first (for completed sessions), then regular service + serviceName := fmt.Sprintf("temp-content-%s", session) + reqK8s, _ := GetK8sClientsForRequest(c) + if reqK8s != nil { + if _, err := reqK8s.CoreV1().Services(project).Get(c.Request.Context(), serviceName, v1.GetOptions{}); err != nil { + serviceName = fmt.Sprintf("ambient-content-%s", session) + } + } else { + serviceName = fmt.Sprintf("ambient-content-%s", session) } - endpoint := fmt.Sprintf(base, session, project) + endpoint := fmt.Sprintf("http://%s.%s.svc:8080", serviceName, project) + log.Printf("pushSessionRepo: using service %s", serviceName) // Simplified: 1) get session; 2) compute repoPath from INPUT repo folder; 3) get output url/branch; 4) proxy resolvedRepoPath := "" @@ -1631,11 +2453,19 @@ func AbandonSessionRepo(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON body"}) return } - base := os.Getenv("SESSION_CONTENT_SERVICE_BASE") - if base == "" { - base = "http://ambient-content-%s.%s.svc:8080" + + // Try temp service first (for completed sessions), then regular service + serviceName := fmt.Sprintf("temp-content-%s", session) + reqK8s, _ := GetK8sClientsForRequest(c) + if reqK8s != nil { + if _, err := reqK8s.CoreV1().Services(project).Get(c.Request.Context(), serviceName, v1.GetOptions{}); err != nil { + serviceName = fmt.Sprintf("ambient-content-%s", session) + } + } else { + serviceName = fmt.Sprintf("ambient-content-%s", session) } - endpoint := fmt.Sprintf(base, session, project) + endpoint := fmt.Sprintf("http://%s.%s.svc:8080", serviceName, project) + log.Printf("AbandonSessionRepo: using service %s", serviceName) repoPath := strings.TrimSpace(body.RepoPath) if repoPath == "" { if body.RepoIndex >= 0 { @@ -1693,11 +2523,19 @@ func DiffSessionRepo(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "missing repoPath/repoIndex"}) return } - base := os.Getenv("SESSION_CONTENT_SERVICE_BASE") - if base == "" { - base = "http://ambient-content-%s.%s.svc:8080" + + // Try temp service first (for completed sessions), then regular service + serviceName := fmt.Sprintf("temp-content-%s", session) + reqK8s, _ := GetK8sClientsForRequest(c) + if reqK8s != nil { + if _, err := reqK8s.CoreV1().Services(project).Get(c.Request.Context(), serviceName, v1.GetOptions{}); err != nil { + serviceName = fmt.Sprintf("ambient-content-%s", session) + } + } else { + serviceName = fmt.Sprintf("ambient-content-%s", session) } - endpoint := fmt.Sprintf(base, session, project) + endpoint := fmt.Sprintf("http://%s.%s.svc:8080", serviceName, project) + log.Printf("DiffSessionRepo: using service %s", serviceName) url := fmt.Sprintf("%s/content/github/diff?repoPath=%s", endpoint, url.QueryEscape(repoPath)) req, _ := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, url, nil) if v := c.GetHeader("Authorization"); v != "" { diff --git a/components/backend/main.go b/components/backend/main.go index cd43f357c..1d6168a7d 100644 --- a/components/backend/main.go +++ b/components/backend/main.go @@ -24,6 +24,30 @@ func main() { _ = godotenv.Overload(".env.local") _ = godotenv.Overload(".env") + // Content service mode - minimal initialization, no K8s access needed + if os.Getenv("CONTENT_SERVICE_MODE") == "true" { + log.Println("Starting in CONTENT_SERVICE_MODE (no K8s client initialization)") + + // Initialize config to set StateBaseDir from environment + server.InitConfig() + + // Only initialize what content service needs + handlers.StateBaseDir = server.StateBaseDir + handlers.GitPushRepo = git.PushRepo + handlers.GitAbandonRepo = git.AbandonRepo + handlers.GitDiffRepo = git.DiffRepo + + log.Printf("Content service using StateBaseDir: %s", server.StateBaseDir) + + if err := server.RunContentService(registerContentRoutes); err != nil { + log.Fatalf("Content service error: %v", err) + } + return + } + + // Normal server mode - full initialization + log.Println("Starting in normal server mode with K8s client initialization") + // Initialize components github.InitializeTokenManager() @@ -91,14 +115,6 @@ func main() { // Initialize websocket package websocket.StateBaseDir = server.StateBaseDir - // Content service mode - if os.Getenv("CONTENT_SERVICE_MODE") == "true" { - if err := server.RunContentService(registerContentRoutes); err != nil { - log.Fatalf("Content service error: %v", err) - } - return - } - // Normal server mode - create closure to capture jiraHandler registerRoutesWithJira := func(r *gin.Engine) { registerRoutes(r, jiraHandler) diff --git a/components/backend/routes.go b/components/backend/routes.go index d0894de56..440215e9d 100644 --- a/components/backend/routes.go +++ b/components/backend/routes.go @@ -36,6 +36,7 @@ func registerRoutes(r *gin.Engine, jiraHandler *jira.Handler) { projectGroup.POST("/agentic-sessions", handlers.CreateSession) projectGroup.GET("/agentic-sessions/:sessionName", handlers.GetSession) projectGroup.PUT("/agentic-sessions/:sessionName", handlers.UpdateSession) + projectGroup.PATCH("/agentic-sessions/:sessionName", handlers.PatchSession) projectGroup.DELETE("/agentic-sessions/:sessionName", handlers.DeleteSession) projectGroup.POST("/agentic-sessions/:sessionName/clone", handlers.CloneSession) projectGroup.POST("/agentic-sessions/:sessionName/start", handlers.StartSession) @@ -47,6 +48,10 @@ func registerRoutes(r *gin.Engine, jiraHandler *jira.Handler) { projectGroup.POST("/agentic-sessions/:sessionName/github/push", handlers.PushSessionRepo) projectGroup.POST("/agentic-sessions/:sessionName/github/abandon", handlers.AbandonSessionRepo) projectGroup.GET("/agentic-sessions/:sessionName/github/diff", handlers.DiffSessionRepo) + projectGroup.GET("/agentic-sessions/:sessionName/k8s-resources", handlers.GetSessionK8sResources) + projectGroup.POST("/agentic-sessions/:sessionName/spawn-content-pod", handlers.SpawnContentPod) + projectGroup.GET("/agentic-sessions/:sessionName/content-pod-status", handlers.GetContentPodStatus) + projectGroup.DELETE("/agentic-sessions/:sessionName/content-pod", handlers.DeleteContentPod) projectGroup.GET("/rfe-workflows", handlers.ListProjectRFEWorkflows) projectGroup.POST("/rfe-workflows", handlers.CreateProjectRFEWorkflow) @@ -60,6 +65,7 @@ func registerRoutes(r *gin.Engine, jiraHandler *jira.Handler) { projectGroup.GET("/sessions/:sessionId/ws", websocket.HandleSessionWebSocket) projectGroup.GET("/sessions/:sessionId/messages", websocket.GetSessionMessagesWS) + // Removed: /messages/claude-format - Using SDK's built-in resume with persisted ~/.claude state projectGroup.POST("/sessions/:sessionId/messages", websocket.PostSessionMessageWS) projectGroup.POST("/rfe-workflows/:id/jira", jiraHandler.PublishWorkflowFileToJira) projectGroup.GET("/rfe-workflows/:id/jira", handlers.GetWorkflowJira) diff --git a/components/backend/types/common.go b/components/backend/types/common.go index ab81280e4..ea1ca771b 100644 --- a/components/backend/types/common.go +++ b/components/backend/types/common.go @@ -39,3 +39,16 @@ type Paths struct { Messages string `json:"messages,omitempty"` Inbox string `json:"inbox,omitempty"` } + +// Helper functions for pointer types +func BoolPtr(b bool) *bool { + return &b +} + +func StringPtr(s string) *string { + return &s +} + +func IntPtr(i int) *int { + return &i +} diff --git a/components/backend/types/session.go b/components/backend/types/session.go index e7a5de5b6..be275ce7a 100644 --- a/components/backend/types/session.go +++ b/components/backend/types/session.go @@ -61,12 +61,13 @@ type AgenticSessionStatus struct { } type CreateAgenticSessionRequest struct { - Prompt string `json:"prompt" binding:"required"` - DisplayName string `json:"displayName,omitempty"` - LLMSettings *LLMSettings `json:"llmSettings,omitempty"` - Timeout *int `json:"timeout,omitempty"` - Interactive *bool `json:"interactive,omitempty"` - WorkspacePath string `json:"workspacePath,omitempty"` + Prompt string `json:"prompt" binding:"required"` + DisplayName string `json:"displayName,omitempty"` + LLMSettings *LLMSettings `json:"llmSettings,omitempty"` + Timeout *int `json:"timeout,omitempty"` + Interactive *bool `json:"interactive,omitempty"` + WorkspacePath string `json:"workspacePath,omitempty"` + ParentSessionID string `json:"parent_session_id,omitempty"` // Multi-repo support (unified mapping) Repos []SessionRepoMapping `json:"repos,omitempty"` MainRepoIndex *int `json:"mainRepoIndex,omitempty"` diff --git a/components/backend/websocket/handlers.go b/components/backend/websocket/handlers.go index b504bdcd3..956e98742 100644 --- a/components/backend/websocket/handlers.go +++ b/components/backend/websocket/handlers.go @@ -217,3 +217,7 @@ func PostSessionMessageWS(c *gin.Context) { c.JSON(http.StatusAccepted, gin.H{"status": "queued"}) } + +// NOTE: GetSessionMessagesClaudeFormat removed - session continuation now uses +// SDK's built-in resume functionality with persisted ~/.claude state +// See: https://docs.claude.com/en/api/agent-sdk/sessions diff --git a/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/content-pod-status/route.ts b/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/content-pod-status/route.ts new file mode 100644 index 000000000..4515e631b --- /dev/null +++ b/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/content-pod-status/route.ts @@ -0,0 +1,17 @@ +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)}/content-pod-status`, + { headers } + ); + const data = await resp.text(); + return new Response(data, { status: resp.status, headers: { 'Content-Type': 'application/json' } }); +} + diff --git a/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/content-pod/route.ts b/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/content-pod/route.ts new file mode 100644 index 000000000..111aa2ba4 --- /dev/null +++ b/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/content-pod/route.ts @@ -0,0 +1,17 @@ +import { BACKEND_URL } from '@/lib/config'; +import { buildForwardHeadersAsync } from '@/lib/auth'; + +export async function DELETE( + 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)}/content-pod`, + { method: 'DELETE', headers } + ); + const data = await resp.text(); + return new Response(data, { status: resp.status, headers: { 'Content-Type': 'application/json' } }); +} + diff --git a/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/k8s-resources/route.ts b/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/k8s-resources/route.ts new file mode 100644 index 000000000..ff61c5e5b --- /dev/null +++ b/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/k8s-resources/route.ts @@ -0,0 +1,17 @@ +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)}/k8s-resources`, + { headers } + ); + const data = await resp.text(); + return new Response(data, { status: resp.status, headers: { 'Content-Type': 'application/json' } }); +} + diff --git a/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/spawn-content-pod/route.ts b/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/spawn-content-pod/route.ts new file mode 100644 index 000000000..61d141272 --- /dev/null +++ b/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/spawn-content-pod/route.ts @@ -0,0 +1,17 @@ +import { BACKEND_URL } from '@/lib/config'; +import { buildForwardHeadersAsync } from '@/lib/auth'; + +export async function POST( + 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)}/spawn-content-pod`, + { method: 'POST', headers } + ); + const data = await resp.text(); + return new Response(data, { status: resp.status, headers: { 'Content-Type': 'application/json' } }); +} + diff --git a/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/start/route.ts b/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/start/route.ts new file mode 100644 index 000000000..f5a0bdb44 --- /dev/null +++ b/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/start/route.ts @@ -0,0 +1,17 @@ +import { BACKEND_URL } from '@/lib/config'; +import { buildForwardHeadersAsync } from '@/lib/auth'; + +export async function POST( + 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)}/start`, + { method: 'POST', headers } + ); + const data = await resp.text(); + return new Response(data, { status: resp.status, headers: { 'Content-Type': 'application/json' } }); +} + diff --git a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/k8s-resource-tree.tsx b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/k8s-resource-tree.tsx new file mode 100644 index 000000000..c48585a61 --- /dev/null +++ b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/components/k8s-resource-tree.tsx @@ -0,0 +1,217 @@ +'use client'; + +import { useState } from 'react'; +import { ChevronRight, ChevronDown, Box, Container, HardDrive, AlertCircle, CheckCircle2, Clock } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; + +type K8sResourceTreeProps = { + jobName?: string; + jobStatus?: string; + pods?: Array<{ + name: string; + phase: string; + containers: Array<{ + name: string; + state: string; + exitCode?: number; + reason?: string; + }>; + events?: string[]; + }>; + pvcName?: string; + pvcExists?: boolean; + pvcSize?: string; + events?: string[]; +}; + +export function K8sResourceTree({ + jobName, + jobStatus = 'Unknown', + pods = [], + pvcName, + pvcExists, + pvcSize, + events = [], +}: K8sResourceTreeProps) { + const [expandedJob, setExpandedJob] = useState(true); + const [expandedPods, setExpandedPods] = useState>({}); + + const getStatusColor = (status: string) => { + const lower = status.toLowerCase(); + if (lower.includes('running') || lower.includes('active')) return 'bg-blue-100 text-blue-800 border-blue-300'; + if (lower.includes('succeeded') || lower.includes('completed')) return 'bg-green-100 text-green-800 border-green-300'; + if (lower.includes('failed') || lower.includes('error')) return 'bg-red-100 text-red-800 border-red-300'; + if (lower.includes('waiting') || lower.includes('pending')) return 'bg-yellow-100 text-yellow-800 border-yellow-300'; + return 'bg-gray-100 text-gray-800 border-gray-300'; + }; + + const getStatusIcon = (status: string) => { + const lower = status.toLowerCase(); + if (lower.includes('running') || lower.includes('active')) return ; + if (lower.includes('succeeded') || lower.includes('completed')) return ; + if (lower.includes('failed') || lower.includes('error')) return ; + return ; + }; + + const EventsDialog = ({ events, title }: { events: string[]; title: string }) => { + const [open, setOpen] = useState(false); + return ( + + + + + {title} + Kubernetes events for this resource + +
+ {events.length === 0 ? ( +

No events

+ ) : ( + events.map((event, idx) => ( +
+ {event} +
+ )) + )} +
+
+
+ ); + }; + + if (!jobName) { + return ( + + + Kubernetes Resources + + +

No job information available

+
+
+ ); + } + + return ( + + + Kubernetes Resources + + + {/* Job */} +
+
+ + + + Job + + {jobName} + + {getStatusIcon(jobStatus)} + {jobStatus} + + {events.length > 0 && } +
+ + {expandedJob && ( +
+ {/* Pods */} + {pods.map((pod) => ( +
+
+ + + + Pod + + + {pod.name} + + + {getStatusIcon(pod.phase)} + {pod.phase} + + {pod.events && pod.events.length > 0 && ( + + )} +
+ + {expandedPods[pod.name] && ( +
+ {/* Containers */} + {pod.containers.map((container) => ( +
+ + + Container + + {container.name} + + {getStatusIcon(container.state)} + {container.state} + + {container.exitCode !== undefined && ( + Exit: {container.exitCode} + )} + {container.reason && ( + ({container.reason}) + )} +
+ ))} +
+ )} +
+ ))} + + {/* PVC */} + {pvcName && ( +
+ + + PVC + + {pvcName} + + {pvcExists ? 'Exists' : 'Not Found'} + + {pvcSize && {pvcSize}} +
+ )} +
+ )} +
+
+
+ ); +} + diff --git a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/not-found.tsx b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/not-found.tsx index 9d5195449..48d756470 100644 --- a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/not-found.tsx +++ b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/not-found.tsx @@ -1,3 +1,5 @@ +'use client'; + import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { FileQuestion } from 'lucide-react'; diff --git a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx index 461c9e9eb..21f9011de 100644 --- a/components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx +++ b/components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx @@ -3,7 +3,7 @@ import { useState, useEffect, useMemo, useCallback } from "react"; import Link from "next/link"; import { formatDistanceToNow } from "date-fns"; -import { ArrowLeft, Square, Trash2, Copy } from "lucide-react"; +import { ArrowLeft, Square, Trash2, Copy, Play, MoreVertical } from "lucide-react"; import { useRouter } from "next/navigation"; // Custom components @@ -18,6 +18,7 @@ import { Badge } from "@/components/ui/badge"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { CloneSessionDialog } from "@/components/clone-session-dialog"; import { Breadcrumbs } from "@/components/breadcrumbs"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator } from "@/components/ui/dropdown-menu"; import type { FileTreeNode } from "@/components/file-tree"; import type { SessionMessage } from "@/types"; @@ -37,6 +38,8 @@ import { useWorkspaceList, useWriteWorkspaceFile, useAllSessionGitHubDiffs, + useSessionK8sResources, + useContinueSession, workspaceKeys, } from "@/services/queries"; import { successToast, errorToast } from "@/hooks/use-toast"; @@ -57,6 +60,9 @@ export default function ProjectSessionDetailPage({ const [chatInput, setChatInput] = useState(""); const [backHref, setBackHref] = useState(null); const [backLabel, setBackLabel] = useState(null); + const [contentPodSpawning, setContentPodSpawning] = useState(false); + const [contentPodReady, setContentPodReady] = useState(false); + const [contentPodError, setContentPodError] = useState(null); // Extract params useEffect(() => { @@ -73,9 +79,11 @@ export default function ProjectSessionDetailPage({ // React Query hooks const { data: session, isLoading, error, refetch: refetchSession } = useSession(projectName, sessionName); - const { data: messages = [] } = useSessionMessages(projectName, sessionName); + const { data: messages = [] } = useSessionMessages(projectName, sessionName, session?.status?.phase); + const { data: k8sResources } = useSessionK8sResources(projectName, sessionName); const stopMutation = useStopSession(); const deleteMutation = useDeleteSession(); + const continueMutation = useContinueSession(); const sendChatMutation = useSendChatMessage(); const sendControlMutation = useSendControlMessage(); const pushToGitHubMutation = usePushSessionToGitHub(); @@ -158,7 +166,7 @@ export default function ProjectSessionDetailPage({ session?.spec?.repos as Array<{ input: { url: string; branch: string }; output?: { url: string; branch: string } }> | undefined, deriveRepoFolderFromUrl, { - enabled: !!session?.spec?.repos && activeTab === 'overview', + enabled: !!session?.spec?.repos, sessionPhase: session?.status?.phase } ); @@ -296,15 +304,42 @@ export default function ProjectSessionDetailPage({ break; } case "system.message": { - const text = (typeof envelope.payload === 'string') ? String(envelope.payload) : ""; - if (text) { - agenticMessages.push({ - type: "system_message", - subtype: "system.message", - data: { message: text }, - timestamp: innerTs, - }); + let text = ""; + let isDebug = false; + + // The envelope object might have message/payload at different levels + // Try envelope.payload first, then fall back to envelope itself + const envelopeObj = envelope as { message?: string; payload?: string | { message?: string; payload?: string; debug?: boolean }; debug?: boolean }; + + // Check if envelope.payload is a string + if (typeof envelopeObj.payload === 'string') { + text = envelopeObj.payload; + } + // Check if envelope.payload is an object with message or payload + else if (typeof envelopeObj.payload === 'object' && envelopeObj.payload !== null) { + const payloadObj = envelopeObj.payload as { message?: string; payload?: string; debug?: boolean }; + text = payloadObj.message || (typeof payloadObj.payload === 'string' ? payloadObj.payload : ""); + isDebug = payloadObj.debug === true; } + // Fall back to envelope.message directly + else if (typeof envelopeObj.message === 'string') { + text = envelopeObj.message; + } + + if (envelopeObj.debug === true) { + isDebug = true; + } + + // Always create a system message - show the raw envelope if we couldn't extract text + agenticMessages.push({ + type: "system_message", + subtype: "system.message", + data: { + message: text || `[system event: ${JSON.stringify(envelope)}]`, + debug: isDebug + }, + timestamp: innerTs, + }); break; } case "user.message": @@ -395,6 +430,18 @@ export default function ProjectSessionDetailPage({ ); }; + const handleContinue = () => { + continueMutation.mutate( + { projectName, parentSessionName: sessionName }, + { + onSuccess: () => { + successToast("Session restarted successfully"); + }, + onError: (err) => errorToast(err instanceof Error ? err.message : "Failed to restart session"), + } + ); + }; + const sendChat = () => { if (!chatInput.trim()) return; @@ -430,6 +477,88 @@ export default function ProjectSessionDetailPage({ ); }; + // Check if session is completed + const sessionCompleted = ( + session?.status?.phase === 'Completed' || + session?.status?.phase === 'Failed' || + session?.status?.phase === 'Stopped' + ); + + // Auto-spawn content pod when workspace tab clicked on completed session + // Don't auto-retry if we already encountered an error - user must explicitly retry + useEffect(() => { + if (activeTab === 'workspace' && sessionCompleted && !contentPodReady && !contentPodSpawning && !contentPodError) { + spawnContentPodAsync(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeTab, sessionCompleted, contentPodReady, contentPodSpawning, contentPodError]); + + const spawnContentPodAsync = async () => { + if (!projectName || !sessionName) return; + + setContentPodSpawning(true); + setContentPodError(null); // Clear any previous errors + + try { + // Import API function + const { spawnContentPod, getContentPodStatus } = await import('@/services/api/sessions'); + + // Spawn pod + const spawnResult = await spawnContentPod(projectName, sessionName); + + // If already exists and ready, we're done + if (spawnResult.status === 'exists' && spawnResult.ready) { + setContentPodReady(true); + setContentPodSpawning(false); + setContentPodError(null); + return; + } + + // Poll for readiness + let attempts = 0; + const maxAttempts = 30; // 30 seconds + + const pollInterval = setInterval(async () => { + attempts++; + + try { + const status = await getContentPodStatus(projectName, sessionName); + + if (status.ready) { + clearInterval(pollInterval); + setContentPodReady(true); + setContentPodSpawning(false); + setContentPodError(null); + successToast('Workspace viewer ready'); + } + + if (attempts >= maxAttempts) { + clearInterval(pollInterval); + setContentPodSpawning(false); + const errorMsg = 'Workspace viewer failed to start within 30 seconds'; + setContentPodError(errorMsg); + errorToast(errorMsg); + } + } catch { + // Not found yet, keep polling + if (attempts >= maxAttempts) { + clearInterval(pollInterval); + setContentPodSpawning(false); + const errorMsg = 'Workspace viewer failed to start'; + setContentPodError(errorMsg); + errorToast(errorMsg); + } + } + }, 1000); + + } catch (error) { + setContentPodSpawning(false); + const errorMsg = error instanceof Error ? error.message : 'Failed to spawn workspace viewer'; + setContentPodError(errorMsg); + errorToast(errorMsg); + } + }; + // Workspace operations - using React Query with queryClient for imperative fetching const onWsToggle = useCallback(async (node: FileTreeNode) => { if (node.type !== "folder") return; @@ -600,38 +729,58 @@ export default function ProjectSessionDetailPage({
- refetchSession()} - trigger={ - - } - /> - - {session.status?.phase !== "Running" && session.status?.phase !== "Creating" && ( + {/* Continue button for completed sessions (converts headless to interactive) */} + {(session.status?.phase === "Completed" || session.status?.phase === "Failed" || session.status?.phase === "Stopped") && ( )} + {/* Stop button for active sessions */} {(session.status?.phase === "Pending" || session.status?.phase === "Creating" || session.status?.phase === "Running") && ( + )} + + {/* Actions dropdown menu */} + + + + + + refetchSession()} + trigger={ + e.preventDefault()}> + + Clone + + } + /> + + + + {deleteMutation.isPending ? "Deleting..." : "Delete"} + + +
@@ -673,6 +822,7 @@ export default function ProjectSessionDetailPage({ setPromptExpanded={setPromptExpanded} latestLiveMessage={latestLiveMessage as SessionMessage | null} diffTotals={diffTotals} + k8sResources={k8sResources} onPush={async (idx) => { const repo = session.spec.repos?.[idx]; if (!repo) return; @@ -729,24 +879,52 @@ export default function ProjectSessionDetailPage({ onInterrupt={() => Promise.resolve(handleInterrupt())} onEndSession={() => Promise.resolve(handleEndSession())} onGoToResults={() => setActiveTab('results')} - isEndingSession={sendControlMutation.isPending} + onContinue={handleContinue} /> - + {sessionCompleted && !contentPodReady ? ( + +
+ {contentPodSpawning ? ( + <> +
+
+
+

Starting workspace viewer...

+

This may take up to 30 seconds

+ + ) : ( + <> +

+ Session has completed. To view and edit your workspace files, please start a workspace viewer. +

+ + + )} +
+ + ) : ( + + )} diff --git a/components/frontend/src/app/projects/[name]/sessions/page.tsx b/components/frontend/src/app/projects/[name]/sessions/page.tsx index fd5393e4a..5cbec04bf 100644 --- a/components/frontend/src/app/projects/[name]/sessions/page.tsx +++ b/components/frontend/src/app/projects/[name]/sessions/page.tsx @@ -3,7 +3,7 @@ import Link from 'next/link'; import { useParams } from 'next/navigation'; import { formatDistanceToNow } from 'date-fns'; -import { Plus, RefreshCw, MoreVertical, Square, RefreshCcw, Trash2, Play } from 'lucide-react'; +import { Plus, RefreshCw, MoreVertical, Square, Trash2, ArrowRight, Brain } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; @@ -15,7 +15,7 @@ import { ErrorMessage } from '@/components/error-message'; import { SessionPhaseBadge } from '@/components/status-badge'; import { Breadcrumbs } from '@/components/breadcrumbs'; -import { useSessions, useStopSession, useStartSession, useDeleteSession } from '@/services/queries'; +import { useSessions, useStopSession, useDeleteSession, useContinueSession } from '@/services/queries'; import { successToast, errorToast } from '@/hooks/use-toast'; export default function ProjectSessionsListPage() { @@ -25,8 +25,8 @@ export default function ProjectSessionsListPage() { // React Query hooks replace all manual state management const { data: sessions = [], isLoading, error, refetch } = useSessions(projectName); const stopSessionMutation = useStopSession(); - const startSessionMutation = useStartSession(); const deleteSessionMutation = useDeleteSession(); + const continueSessionMutation = useContinueSession(); const handleStop = async (sessionName: string) => { stopSessionMutation.mutate( @@ -42,30 +42,31 @@ export default function ProjectSessionsListPage() { ); }; - const handleRestart = async (sessionName: string) => { - startSessionMutation.mutate( + + const handleDelete = async (sessionName: string) => { + if (!confirm(`Delete agentic session "${sessionName}"? This action cannot be undone.`)) return; + deleteSessionMutation.mutate( { projectName, sessionName }, { onSuccess: () => { - successToast(`Session "${sessionName}" restarted successfully`); + successToast(`Session "${sessionName}" deleted successfully`); }, onError: (error) => { - errorToast(error instanceof Error ? error.message : 'Failed to restart session'); + errorToast(error instanceof Error ? error.message : 'Failed to delete session'); }, } ); }; - const handleDelete = async (sessionName: string) => { - if (!confirm(`Delete agentic session "${sessionName}"? This action cannot be undone.`)) return; - deleteSessionMutation.mutate( - { projectName, sessionName }, + const handleContinue = async (sessionName: string) => { + continueSessionMutation.mutate( + { projectName, parentSessionName: sessionName }, { onSuccess: () => { - successToast(`Session "${sessionName}" deleted successfully`); + successToast(`Session "${sessionName}" restarted successfully`); }, onError: (error) => { - errorToast(error instanceof Error ? error.message : 'Failed to delete session'); + errorToast(error instanceof Error ? error.message : 'Failed to restart session'); }, } ); @@ -130,7 +131,7 @@ export default function ProjectSessionsListPage() { {sessions.length === 0 ? ( )} @@ -232,11 +233,11 @@ type SessionActionsProps = { sessionName: string; phase: string; onStop: (sessionName: string) => void; - onRestart: (sessionName: string) => void; + onContinue: (sessionName: string) => void; onDelete: (sessionName: string) => void; }; -function SessionActions({ sessionName, phase, onStop, onRestart, onDelete }: SessionActionsProps) { +function SessionActions({ sessionName, phase, onStop, onContinue, onDelete }: SessionActionsProps) { type RowAction = { key: string; label: string; @@ -257,17 +258,19 @@ function SessionActions({ sessionName, phase, onStop, onRestart, onDelete }: Ses }); } + // Allow continue for all completed sessions (converts headless to interactive) if (phase === 'Completed' || phase === 'Failed' || phase === 'Stopped' || phase === 'Error') { actions.push({ - key: 'restart', - label: 'Restart', - onClick: () => onRestart(sessionName), - icon: , - className: 'text-blue-600', + key: 'continue', + label: 'Continue', + onClick: () => onContinue(sessionName), + icon: , + className: 'text-green-600', }); } - if (phase !== 'Running' && phase !== 'Creating') { + // Delete is always available except when Creating + if (phase !== 'Creating') { actions.push({ key: 'delete', label: 'Delete', diff --git a/components/frontend/src/components/session/MessagesTab.tsx b/components/frontend/src/components/session/MessagesTab.tsx index c4afaac9a..b5ce8ebd4 100644 --- a/components/frontend/src/components/session/MessagesTab.tsx +++ b/components/frontend/src/components/session/MessagesTab.tsx @@ -1,9 +1,15 @@ "use client"; -import React from "react"; +import React, { useState } from "react"; import { Button } from "@/components/ui/button"; -import { Brain, Loader2 } from "lucide-react"; +import { Brain, Loader2, Settings } from "lucide-react"; import { StreamMessage } from "@/components/ui/stream-message"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuCheckboxItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import type { AgenticSession, MessageObject, ToolUseMessages } from "@/types/agentic-session"; export type MessagesTabProps = { @@ -15,27 +21,117 @@ export type MessagesTabProps = { onInterrupt: () => Promise; onEndSession: () => Promise; onGoToResults?: () => void; - isEndingSession?: boolean; + onContinue: () => void; }; -const MessagesTab: React.FC = ({ session, streamMessages, chatInput, setChatInput, onSendChat, onInterrupt, onEndSession, onGoToResults, isEndingSession }) => { + +const MessagesTab: React.FC = ({ session, streamMessages, chatInput, setChatInput, onSendChat, onInterrupt, onEndSession, onGoToResults, onContinue}) => { + const [sendingChat, setSendingChat] = useState(false); + const [interrupting, setInterrupting] = useState(false); + const [ending, setEnding] = useState(false); + const [showSystemMessages, setShowSystemMessages] = useState(false); + + const phase = session?.status?.phase || ""; + const isInteractive = session?.spec?.interactive; + + // Only show chat interface when session is interactive AND in Running state + const showChatInterface = isInteractive && phase === "Running"; + + // Determine if session is in a terminal state + const isTerminalState = ["Completed", "Failed", "Stopped"].includes(phase); + const isCreating = ["Creating", "Pending"].includes(phase); + + // Filter out system messages unless showSystemMessages is true + const filteredMessages = streamMessages.filter((msg) => { + if (showSystemMessages) return true; + + // Hide system_message type by default + // Check if msg has a type property and if it's a system_message + if ('type' in msg && msg.type === "system_message") { + return false; + } + + return true; + }); + + const handleSendChat = async () => { + setSendingChat(true); + try { + await onSendChat(); + } finally { + setSendingChat(false); + } + }; + + const handleInterrupt = async () => { + setInterrupting(true); + try { + await onInterrupt(); + } finally { + setInterrupting(false); + } + }; + + const handleEndSession = async () => { + setEnding(true); + try { + await onEndSession(); + } finally { + setEnding(false); + } + }; + return ( -
- {streamMessages.map((m, idx) => ( - - ))} - - {streamMessages.length === 0 && ( -
- -

No messages yet

-

- {session.spec?.interactive ? "Start by sending a message below." : "This session is not interactive."} -

+
+
+ {filteredMessages.map((m, idx) => ( + + ))} + + {filteredMessages.length === 0 && ( +
+ +

No messages yet

+

+ {isInteractive + ? isCreating + ? "Session is starting..." + : isTerminalState + ? `Session has ${phase.toLowerCase()}.` + : "Start by sending a message below." + : "This session is not interactive."} +

+
+ )} +
+ + {/* Settings for non-interactive sessions with messages */} + {!isInteractive && filteredMessages.length > 0 && ( +
+
+
+ + + + + + + {showSystemMessages ? 'Hide' : 'Show'} system messages + + + +

Non-interactive session

+
+
)} - {session.spec?.interactive && ( + {showChatInterface && (
@@ -47,28 +143,112 @@ const MessagesTab: React.FC = ({ session, streamMessages, chat onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); - if (chatInput.trim()) { - onSendChat(); + if (chatInput.trim() && !sendingChat) { + handleSendChat(); } } }} rows={3} + disabled={sendingChat} />
-
Interactive session
+
+ + + + + + + {showSystemMessages ? 'Hide' : 'Show'} system messages + + + +
Interactive session
+
- - + + -
)} + + {isInteractive && !showChatInterface && streamMessages.length > 0 && ( +
+
+
+
+ + + + + + + {showSystemMessages ? 'Hide' : 'Show'} system messages + + + +

+ {isCreating && "Chat will be available once the session is running..."} + {isTerminalState && ( + <> + This session has {phase.toLowerCase()}. Chat is no longer available. + {onContinue && ( + <> + {" "} + + {" "}to restart it. + + )} + + )} +

+
+
+
+
+ )}
); }; diff --git a/components/frontend/src/components/session/OverviewTab.tsx b/components/frontend/src/components/session/OverviewTab.tsx index 4b630bd84..f07cdf0dc 100644 --- a/components/frontend/src/components/session/OverviewTab.tsx +++ b/components/frontend/src/components/session/OverviewTab.tsx @@ -4,7 +4,7 @@ import React from "react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { Brain, Clock, RefreshCw, Sparkle, ExternalLink } from "lucide-react"; +import { Brain, Clock, RefreshCw, Sparkle, ExternalLink, Box, Container, HardDrive } from "lucide-react"; import { format } from "date-fns"; import { cn } from "@/lib/utils"; import type { AgenticSession } from "@/types/agentic-session"; @@ -21,10 +21,68 @@ type Props = { busyRepo: Record; buildGithubCompareUrl: (inUrl: string, inBranch?: string, outUrl?: string, outBranch?: string) => string | null; onRefreshDiff: () => Promise; + k8sResources?: { + jobName?: string; + jobStatus?: string; + pods?: Array<{ + name: string; + phase: string; + containers: Array<{ + name: string; + state: string; + exitCode?: number; + reason?: string; + }>; + isTempPod?: boolean; + }>; + pvcName?: string; + pvcExists?: boolean; + pvcSize?: string; + }; }; -export const OverviewTab: React.FC = ({ session, promptExpanded, setPromptExpanded, latestLiveMessage, diffTotals, onPush, onAbandon, busyRepo, buildGithubCompareUrl, onRefreshDiff }) => { +// Utility to generate OpenShift console URLs +const getOpenShiftConsoleUrl = (namespace: string, resourceType: 'Job' | 'Pod' | 'PVC', resourceName: string): string | null => { + // Try to derive console URL from current window location + // OpenShift console is typically at console-openshift-console.apps. + const hostname = window.location.hostname; + + // Check if we're on an OpenShift route (apps.*) + if (hostname.includes('.apps.')) { + const clusterDomain = hostname.split('.apps.')[1]; + const consoleUrl = `https://console-openshift-console.apps.${clusterDomain}`; + + const resourceMap = { + 'Job': 'batch~v1~Job', + 'Pod': 'core~v1~Pod', + 'PVC': 'core~v1~PersistentVolumeClaim', + }; + + return `${consoleUrl}/k8s/ns/${namespace}/${resourceMap[resourceType]}/${resourceName}`; + } + + // Fallback: For local development or non-standard setups, return null + return null; +}; + +export const OverviewTab: React.FC = ({ session, promptExpanded, setPromptExpanded, latestLiveMessage, diffTotals, onPush, onAbandon, busyRepo, buildGithubCompareUrl, onRefreshDiff, k8sResources }) => { const [refreshingDiff, setRefreshingDiff] = React.useState(false); + const [expandedPods, setExpandedPods] = React.useState>({}); + + const projectNamespace = session.metadata?.namespace || ''; + + const getStatusColor = (status: string) => { + const lower = status.toLowerCase(); + if (lower.includes('running') || lower.includes('active')) return 'bg-blue-100 text-blue-800 border-blue-300'; + if (lower.includes('succeeded') || lower.includes('completed')) return 'bg-green-100 text-green-800 border-green-300'; + if (lower.includes('failed') || lower.includes('error')) return 'bg-red-100 text-red-800 border-red-300'; + if (lower.includes('waiting') || lower.includes('pending')) return 'bg-yellow-100 text-yellow-800 border-yellow-300'; + if (lower.includes('terminating')) return 'bg-purple-100 text-purple-800 border-purple-300'; + if (lower.includes('notfound') || lower.includes('not found')) return 'bg-orange-100 text-orange-800 border-orange-300'; + if (lower.includes('terminated')) return 'bg-gray-100 text-gray-800 border-gray-300'; + return 'bg-gray-100 text-gray-800 border-gray-300'; + }; + return (
@@ -155,6 +213,211 @@ export const OverviewTab: React.FC = ({ session, promptExpanded, setPromp
+ {k8sResources && ( +
+
Kubernetes Resources
+
+ {/* PVC - Always shown at root level (owned by AgenticSession CR) */} + {k8sResources.pvcName && ( +
+ + + PVC + + {(() => { + const consoleUrl = getOpenShiftConsoleUrl(projectNamespace, 'PVC', k8sResources.pvcName); + return consoleUrl ? ( + + {k8sResources.pvcName} + + + ) : ( + {k8sResources.pvcName} + ); + })()} + + {k8sResources.pvcExists ? 'Exists' : 'Not Found'} + + {k8sResources.pvcSize && {k8sResources.pvcSize}} +
+ )} + + {/* Temp Content Pods - Always at root level (for completed sessions) */} + {k8sResources.pods && k8sResources.pods.filter(p => p.isTempPod).map((pod) => ( +
+
+ + + + Temp Pod + + {(() => { + const consoleUrl = getOpenShiftConsoleUrl(projectNamespace, 'Pod', pod.name); + return consoleUrl ? ( + + {pod.name} + + + ) : ( + + {pod.name} + + ); + })()} + + {pod.phase} + + + Workspace viewer + +
+ + {/* Temp pod containers */} + {expandedPods[pod.name] && pod.containers && pod.containers.length > 0 && ( +
+ {pod.containers.map((container) => ( +
+ + + {container.name} + + + {container.state} + + {container.exitCode !== undefined && ( + Exit: {container.exitCode} + )} + {container.reason && ( + ({container.reason}) + )} +
+ ))} +
+ )} +
+ ))} + + {/* Job - Only shown when job exists */} + {k8sResources.jobName && ( +
+
+ + + Job + + {(() => { + const consoleUrl = getOpenShiftConsoleUrl(projectNamespace, 'Job', k8sResources.jobName); + return consoleUrl ? ( + + {k8sResources.jobName} + + + ) : ( + {k8sResources.jobName} + ); + })()} + + {k8sResources.jobStatus || 'Unknown'} + +
+ + {/* Job Pods - Only non-temp pods */} + {k8sResources.pods && k8sResources.pods.filter(p => !p.isTempPod).length > 0 && ( +
+ {k8sResources.pods.filter(p => !p.isTempPod).map((pod) => ( +
+
+ + + + Pod + + {(() => { + const consoleUrl = getOpenShiftConsoleUrl(projectNamespace, 'Pod', pod.name); + return consoleUrl ? ( + + {pod.name} + + + ) : ( + + {pod.name} + + ); + })()} + + {pod.phase} + + {pod.isTempPod && ( + + Workspace viewer + + )} +
+ + {expandedPods[pod.name] && pod.containers && ( +
+ {pod.containers.map((container) => ( +
+ + + {container.name} + + + {container.state} + + {container.exitCode !== undefined && ( + Exit: {container.exitCode} + )} + {container.reason && ( + ({container.reason}) + )} +
+ ))} +
+ )} +
+ ))} +
+ )} +
+ )} +
+
+ )} +
Repositories
@@ -184,8 +447,13 @@ export const OverviewTab: React.FC = ({ session, promptExpanded, setPromp ? repo.output.branch : `sessions/${session.metadata.name}`; const compareUrl = buildGithubCompareUrl(repo.input.url, repo.input.branch || 'main', repo.output?.url, outBranch); + + // Check if temp pod is running and ready + const tempPod = k8sResources?.pods?.find(p => p.isTempPod); + const tempPodReady = tempPod?.phase === 'Running'; + const br = diffTotals[idx] || { total_added: 0, total_removed: 0 }; - const hasChanges = br.total_added > 0 || br.total_removed > 0; + const hasChanges = tempPodReady && (br.total_added > 0 || br.total_removed > 0); return (
{isMain && MAIN} @@ -209,7 +477,12 @@ export const OverviewTab: React.FC = ({ session, promptExpanded, setPromp )} - {!hasChanges ? ( + + {!tempPodReady ? ( + + (read-only - temp service not running) + + ) : !hasChanges ? ( repo.status === 'pushed' && compareUrl ? ( = ({ session, promptExpanded, setPromp ) : null} - {hasChanges && ( + {hasChanges && tempPodReady && ( repo.output?.url ? (
- - + +
) : ( - + ) )}
diff --git a/components/frontend/src/components/session/WorkspaceTab.tsx b/components/frontend/src/components/session/WorkspaceTab.tsx index 426ad3eb5..dbefb3e02 100644 --- a/components/frontend/src/components/session/WorkspaceTab.tsx +++ b/components/frontend/src/components/session/WorkspaceTab.tsx @@ -2,7 +2,7 @@ import React from "react"; import { Button } from "@/components/ui/button"; -import { RefreshCw, FolderOpen, FileText } from "lucide-react"; +import { RefreshCw, FolderOpen, FileText, HardDrive } from "lucide-react"; import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { FileTree, type FileTreeNode } from "@/components/file-tree"; @@ -21,9 +21,16 @@ export type WorkspaceTabProps = { onToggle: (node: FileTreeNode) => void; onSave: (path: string, content: string) => Promise; setWsFileContent: (v: string) => void; + k8sResources?: { + pvcName?: string; + pvcExists?: boolean; + pvcSize?: string; + }; + contentPodError?: string | null; + onRetrySpawn?: () => void; }; -const WorkspaceTab: React.FC = ({ session, wsLoading, wsUnavailable, wsTree, wsSelectedPath, wsFileContent, onRefresh, onSelect, onToggle, onSave, setWsFileContent }) => { +const WorkspaceTab: React.FC = ({ session, wsLoading, wsUnavailable, wsTree, wsSelectedPath, wsFileContent, onRefresh, onSelect, onToggle, onSave, setWsFileContent, k8sResources, contentPodError, onRetrySpawn }) => { if (wsLoading) { return (
@@ -31,6 +38,22 @@ const WorkspaceTab: React.FC = ({ session, wsLoading, wsUnava
); } + + // Show error with retry button if content pod failed to spawn + if (contentPodError) { + return ( +
+
Workspace Viewer Error
+
{contentPodError}
+ {onRetrySpawn && ( + + )} +
+ ); + } + if (wsUnavailable) { return (
@@ -52,9 +75,24 @@ const WorkspaceTab: React.FC = ({ session, wsLoading, wsUnava
-
-

Files

-

{wsTree.length} items

+
+ {k8sResources?.pvcName ? ( +
+ + + PVC + + {k8sResources.pvcName} + + {k8sResources.pvcExists ? 'Exists' : 'Not Found'} + + {k8sResources.pvcSize && ( + {k8sResources.pvcSize} + )} +
+ ) : ( +

{wsTree.length} items

+ )}