diff --git a/components/backend/crd/rfe.go b/components/backend/crd/rfe.go deleted file mode 100644 index ee8a145d6..000000000 --- a/components/backend/crd/rfe.go +++ /dev/null @@ -1,104 +0,0 @@ -// Package crd provides Custom Resource Definition utilities and helpers. -package crd - -import ( - "context" - "fmt" - - "ambient-code-backend/types" - - "k8s.io/apimachinery/pkg/api/errors" - v1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/client-go/dynamic" -) - -// GetRFEWorkflowResourceFunc is a function type that returns the RFEWorkflow GVR -type GetRFEWorkflowResourceFunc func() schema.GroupVersionResource - -// GetRFEWorkflowResource is set by main package -var GetRFEWorkflowResource GetRFEWorkflowResourceFunc - -// RFEWorkflowToCRObject converts an RFEWorkflow to a Kubernetes CR object -func RFEWorkflowToCRObject(workflow *types.RFEWorkflow) map[string]interface{} { - // Build spec - spec := map[string]interface{}{ - "title": workflow.Title, - "description": workflow.Description, - "branchName": workflow.BranchName, - "workspacePath": workflow.WorkspacePath, - } - if len(workflow.JiraLinks) > 0 { - links := make([]map[string]interface{}, 0, len(workflow.JiraLinks)) - for _, l := range workflow.JiraLinks { - links = append(links, map[string]interface{}{"path": l.Path, "jiraKey": l.JiraKey}) - } - spec["jiraLinks"] = links - } - if workflow.ParentOutcome != nil && *workflow.ParentOutcome != "" { - spec["parentOutcome"] = *workflow.ParentOutcome - } - - // Prefer umbrellaRepo/supportingRepos; fallback to legacy repositories array - if workflow.UmbrellaRepo != nil { - u := map[string]interface{}{"url": workflow.UmbrellaRepo.URL} - if workflow.UmbrellaRepo.Branch != nil { - u["branch"] = *workflow.UmbrellaRepo.Branch - } - spec["umbrellaRepo"] = u - } - if len(workflow.SupportingRepos) > 0 { - items := make([]map[string]interface{}, 0, len(workflow.SupportingRepos)) - for _, r := range workflow.SupportingRepos { - rm := map[string]interface{}{"url": r.URL} - if r.Branch != nil { - rm["branch"] = *r.Branch - } - items = append(items, rm) - } - spec["supportingRepos"] = items - } - - labels := map[string]string{ - "project": workflow.Project, - "rfe-workflow": workflow.ID, - } - - return map[string]interface{}{ - "apiVersion": "vteam.ambient-code/v1alpha1", - "kind": "RFEWorkflow", - "metadata": map[string]interface{}{ - "name": workflow.ID, - "namespace": workflow.Project, - "labels": labels, - }, - "spec": spec, - } -} - -// UpsertProjectRFEWorkflowCR creates or updates an RFEWorkflow custom resource -func UpsertProjectRFEWorkflowCR(dyn dynamic.Interface, workflow *types.RFEWorkflow) error { - if workflow.Project == "" { - // Only manage CRD for project-scoped workflows - return nil - } - if dyn == nil { - return fmt.Errorf("no dynamic client provided") - } - gvr := GetRFEWorkflowResource() - obj := &unstructured.Unstructured{Object: RFEWorkflowToCRObject(workflow)} - // Try create, if exists then update - _, err := dyn.Resource(gvr).Namespace(workflow.Project).Create(context.TODO(), obj, v1.CreateOptions{}) - if err != nil { - if errors.IsAlreadyExists(err) { - _, uerr := dyn.Resource(gvr).Namespace(workflow.Project).Update(context.TODO(), obj, v1.UpdateOptions{}) - if uerr != nil { - return fmt.Errorf("failed to update RFEWorkflow CR: %v", uerr) - } - return nil - } - return fmt.Errorf("failed to create RFEWorkflow CR: %v", err) - } - return nil -} diff --git a/components/backend/git/operations.go b/components/backend/git/operations.go index 0559bdee0..bc3bcf69b 100644 --- a/components/backend/git/operations.go +++ b/components/backend/git/operations.go @@ -105,47 +105,39 @@ func GetGitHubToken(ctx context.Context, k8sClient *kubernetes.Clientset, dynCli } } - // Fall back to project runner secret GIT_TOKEN + // Fall back to project integration secret GITHUB_TOKEN (hardcoded secret name) if k8sClient == nil { - log.Printf("Cannot read runner secret: k8s client is nil") - return "", fmt.Errorf("no GitHub credentials available. Either connect GitHub App or configure GIT_TOKEN in project runner secret") + log.Printf("Cannot read integration secret: k8s client is nil") + return "", fmt.Errorf("no GitHub credentials available. Either connect GitHub App or configure GITHUB_TOKEN in integration secrets") } - settings, err := getProjectSettings(ctx, dynClient, project) + const secretName = "ambient-non-vertex-integrations" - // Default to "ambient-runner-secrets" if not configured - secretName := "ambient-runner-secrets" - if err != nil { - log.Printf("Failed to get ProjectSettings for %s (using default secret name): %v", project, err) - } else if settings != nil && settings.RunnerSecret != "" { - secretName = settings.RunnerSecret - } - - log.Printf("Attempting to read GIT_TOKEN from secret %s/%s", project, secretName) + log.Printf("Attempting to read GITHUB_TOKEN from secret %s/%s", project, secretName) secret, err := k8sClient.CoreV1().Secrets(project).Get(ctx, secretName, v1.GetOptions{}) if err != nil { - log.Printf("Failed to get runner secret %s/%s: %v", project, secretName, err) - return "", fmt.Errorf("no GitHub credentials available. Either connect GitHub App or configure GIT_TOKEN in project runner secret") + log.Printf("Failed to get integration secret %s/%s: %v", project, secretName, err) + return "", fmt.Errorf("no GitHub credentials available. Either connect GitHub App or configure GITHUB_TOKEN in integration secrets") } if secret.Data == nil { log.Printf("Secret %s/%s exists but Data is nil", project, secretName) - return "", fmt.Errorf("no GitHub credentials available. Either connect GitHub App or configure GIT_TOKEN in project runner secret") + return "", fmt.Errorf("no GitHub credentials available. Either connect GitHub App or configure GITHUB_TOKEN in integration secrets") } - token, ok := secret.Data["GIT_TOKEN"] + token, ok := secret.Data["GITHUB_TOKEN"] if !ok { - log.Printf("Secret %s/%s exists but has no GIT_TOKEN key (available keys: %v)", project, secretName, getSecretKeys(secret.Data)) - return "", fmt.Errorf("no GitHub credentials available. Either connect GitHub App or configure GIT_TOKEN in project runner secret") + log.Printf("Secret %s/%s exists but has no GITHUB_TOKEN key (available keys: %v)", project, secretName, getSecretKeys(secret.Data)) + return "", fmt.Errorf("no GitHub credentials available. Either connect GitHub App or configure GITHUB_TOKEN in integration secrets") } if len(token) == 0 { - log.Printf("Secret %s/%s has GIT_TOKEN key but value is empty", project, secretName) - return "", fmt.Errorf("no GitHub credentials available. Either connect GitHub App or configure GIT_TOKEN in project runner secret") + log.Printf("Secret %s/%s has GITHUB_TOKEN key but value is empty", project, secretName) + return "", fmt.Errorf("no GitHub credentials available. Either connect GitHub App or configure GITHUB_TOKEN in integration secrets") } - log.Printf("Using GIT_TOKEN from project runner secret %s/%s", project, secretName) + log.Printf("Using GITHUB_TOKEN from integration secret %s/%s", project, secretName) return string(token), nil } @@ -979,82 +971,6 @@ func CheckBranchExists(ctx context.Context, repoURL, branchName, githubToken str return false, fmt.Errorf("GitHub API error: %s (body: %s)", resp.Status, string(body)) } -// validatePushAccess checks if the user has push access to a repository via GitHub API -// For GitHub App tokens, we test actual push capability since permissions.push may not be reliable -func validatePushAccess(ctx context.Context, repoURL, githubToken string) error { - owner, repo, err := ParseGitHubURL(repoURL) - if err != nil { - return fmt.Errorf("invalid repository URL: %w", err) - } - - // Use GitHub API to check repository access - log.Printf("Validating push access to %s with token (len=%d)", repoURL, len(githubToken)) - apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s", owner, repo) - - req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil) - if err != nil { - return fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("Authorization", "Bearer "+githubToken) - req.Header.Set("Accept", "application/vnd.github.v3+json") - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return fmt.Errorf("failed to check repository access: %w", err) - } - defer resp.Body.Close() - - // Read response body once - body, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("failed to read response body: %w", err) - } - - if resp.StatusCode == http.StatusNotFound { - return fmt.Errorf("repository %s/%s not found or you don't have access to it", owner, repo) - } - - if resp.StatusCode == http.StatusTooManyRequests { - resetTime := resp.Header.Get("X-RateLimit-Reset") - if resetTime != "" { - return fmt.Errorf("GitHub API rate limit exceeded. Rate limit will reset at %s. Please try again later", resetTime) - } - return fmt.Errorf("GitHub API rate limit exceeded. Please try again later") - } - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("GitHub API error: %s (body: %s)", resp.Status, string(body)) - } - - // Parse response to check permissions - var repoInfo struct { - Permissions struct { - Push bool `json:"push"` - } `json:"permissions"` - // Check if this is a GitHub App token by looking for installation info - // GitHub App tokens may not have accurate permissions.push, so we test actual capability - } - - if err := json.Unmarshal(body, &repoInfo); err != nil { - return fmt.Errorf("failed to parse repository info: %w (body: %s)", err, string(body)) - } - - // If permissions.push is true, we're good - if repoInfo.Permissions.Push { - log.Printf("Validated push access to %s (permissions.push=true)", repoURL) - return nil - } - - // If permissions.push is false, it might be a GitHub App token with inaccurate permissions - // GitHub App tokens may report permissions.push=false even when they have write access - // If we successfully accessed the repository (status 200), assume we have write access - // since GitHub Apps have "Read and write access to code and pull requests" permission - log.Printf("permissions.push=false for %s, but repository is accessible - assuming GitHub App write access", repoURL) - log.Printf("Validated push access to %s (repository accessible, assuming GitHub App write permissions)", repoURL) - return nil -} - // createBranchInRepo creates a feature branch in a supporting repository // Follows the same pattern as umbrella repo seeding but without adding files // Note: This function assumes push access has already been validated by the caller @@ -1116,7 +1032,8 @@ func createBranchInRepo(ctx context.Context, repo GitRepo, branchName, githubTok return fmt.Errorf("failed to set remote URL: %w (output: %s)", err, string(out)) } - cmd = exec.CommandContext(ctx, "git", "-C", repoDir, "push", "-u", "origin", branchName) + // Push using HEAD:branchName refspec to ensure the newly created local branch is pushed + cmd = exec.CommandContext(ctx, "git", "-C", repoDir, "push", "-u", "origin", fmt.Sprintf("HEAD:%s", branchName)) if out, err := cmd.CombinedOutput(); err != nil { // Check if it's a permission error errMsg := string(out) @@ -1129,3 +1046,311 @@ func createBranchInRepo(ctx context.Context, repo GitRepo, branchName, githubTok log.Printf("Successfully created and pushed branch '%s' in %s", branchName, repoURL) return nil } + +// InitRepo initializes a new git repository +func InitRepo(ctx context.Context, repoDir string) error { + cmd := exec.CommandContext(ctx, "git", "init") + cmd.Dir = repoDir + + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to init git repo: %w (output: %s)", err, string(out)) + } + + // Configure default user if not set + cmd = exec.CommandContext(ctx, "git", "config", "user.name", "Ambient Code Bot") + cmd.Dir = repoDir + _ = cmd.Run() // Best effort + + cmd = exec.CommandContext(ctx, "git", "config", "user.email", "bot@ambient-code.local") + cmd.Dir = repoDir + _ = cmd.Run() // Best effort + + return nil +} + +// ConfigureRemote adds or updates a git remote +func ConfigureRemote(ctx context.Context, repoDir, remoteName, remoteURL string) error { + // Try to remove existing remote first + cmd := exec.CommandContext(ctx, "git", "remote", "remove", remoteName) + cmd.Dir = repoDir + _ = cmd.Run() // Ignore error if remote doesn't exist + + // Add the remote + cmd = exec.CommandContext(ctx, "git", "remote", "add", remoteName, remoteURL) + cmd.Dir = repoDir + + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to add remote: %w (output: %s)", err, string(out)) + } + + return nil +} + +// MergeStatus contains information about merge conflict status +type MergeStatus struct { + CanMergeClean bool `json:"canMergeClean"` + LocalChanges int `json:"localChanges"` + RemoteCommitsAhead int `json:"remoteCommitsAhead"` + ConflictingFiles []string `json:"conflictingFiles"` + RemoteBranchExists bool `json:"remoteBranchExists"` +} + +// CheckMergeStatus checks if local and remote can merge cleanly +func CheckMergeStatus(ctx context.Context, repoDir, branch string) (*MergeStatus, error) { + if branch == "" { + branch = "main" + } + + status := &MergeStatus{ + ConflictingFiles: []string{}, + } + + run := func(args ...string) (string, error) { + cmd := exec.CommandContext(ctx, args[0], args[1:]...) + cmd.Dir = repoDir + var stdout bytes.Buffer + cmd.Stdout = &stdout + if err := cmd.Run(); err != nil { + return stdout.String(), err + } + return stdout.String(), nil + } + + // Fetch remote branch + _, err := run("git", "fetch", "origin", branch) + if err != nil { + // Remote branch doesn't exist yet + status.RemoteBranchExists = false + status.CanMergeClean = true + return status, nil + } + status.RemoteBranchExists = true + + // Count local uncommitted changes + statusOut, _ := run("git", "status", "--porcelain") + status.LocalChanges = len(strings.Split(strings.TrimSpace(statusOut), "\n")) + if strings.TrimSpace(statusOut) == "" { + status.LocalChanges = 0 + } + + // Count commits on remote but not local + countOut, _ := run("git", "rev-list", "--count", "HEAD..origin/"+branch) + fmt.Sscanf(strings.TrimSpace(countOut), "%d", &status.RemoteCommitsAhead) + + // Test merge to detect conflicts (dry run) + mergeBase, err := run("git", "merge-base", "HEAD", "origin/"+branch) + if err != nil { + // No common ancestor - unrelated histories + // This is NOT a conflict - we can merge with --allow-unrelated-histories + // which is already used in PullRepo and SyncRepo + status.CanMergeClean = true + status.ConflictingFiles = []string{} + return status, nil + } + + // Use git merge-tree to simulate merge without touching working directory + mergeTreeOut, err := run("git", "merge-tree", strings.TrimSpace(mergeBase), "HEAD", "origin/"+branch) + if err == nil && strings.TrimSpace(mergeTreeOut) != "" { + // Check for conflict markers in output + if strings.Contains(mergeTreeOut, "<<<<<<<") { + status.CanMergeClean = false + // Parse conflicting files from merge-tree output + for _, line := range strings.Split(mergeTreeOut, "\n") { + if strings.HasPrefix(line, "--- a/") || strings.HasPrefix(line, "+++ b/") { + file := strings.TrimPrefix(strings.TrimPrefix(line, "--- a/"), "+++ b/") + if file != "" && !contains(status.ConflictingFiles, file) { + status.ConflictingFiles = append(status.ConflictingFiles, file) + } + } + } + } else { + status.CanMergeClean = true + } + } else { + status.CanMergeClean = true + } + + return status, nil +} + +// PullRepo pulls changes from remote branch +func PullRepo(ctx context.Context, repoDir, branch string) error { + if branch == "" { + branch = "main" + } + + cmd := exec.CommandContext(ctx, "git", "pull", "--allow-unrelated-histories", "origin", branch) + cmd.Dir = repoDir + + if out, err := cmd.CombinedOutput(); err != nil { + outStr := string(out) + if strings.Contains(outStr, "CONFLICT") { + return fmt.Errorf("merge conflicts detected: %s", outStr) + } + return fmt.Errorf("failed to pull: %w (output: %s)", err, outStr) + } + + log.Printf("Successfully pulled from origin/%s", branch) + return nil +} + +// PushToRepo pushes local commits to specified branch +func PushToRepo(ctx context.Context, repoDir, branch, commitMessage string) error { + if branch == "" { + branch = "main" + } + + run := func(args ...string) (string, error) { + cmd := exec.CommandContext(ctx, args[0], args[1:]...) + cmd.Dir = repoDir + var stdout bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stdout + err := cmd.Run() + return stdout.String(), err + } + + // Ensure we're on the correct branch (create if needed) + // This handles fresh git init repos that don't have a branch yet + if _, err := run("git", "checkout", "-B", branch); err != nil { + return fmt.Errorf("failed to checkout branch: %w", err) + } + + // Stage all changes + if _, err := run("git", "add", "."); err != nil { + return fmt.Errorf("failed to stage changes: %w", err) + } + + // Commit if there are changes + if out, err := run("git", "commit", "-m", commitMessage); err != nil { + if !strings.Contains(out, "nothing to commit") { + return fmt.Errorf("failed to commit: %w", err) + } + } + + // Push to branch + if out, err := run("git", "push", "-u", "origin", branch); err != nil { + return fmt.Errorf("failed to push: %w (output: %s)", err, out) + } + + log.Printf("Successfully pushed to origin/%s", branch) + return nil +} + +// CreateBranch creates a new branch and pushes it to remote +func CreateBranch(ctx context.Context, repoDir, branchName string) error { + run := func(args ...string) (string, error) { + cmd := exec.CommandContext(ctx, args[0], args[1:]...) + cmd.Dir = repoDir + var stdout bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stdout + err := cmd.Run() + return stdout.String(), err + } + + // Create and checkout new branch + if _, err := run("git", "checkout", "-b", branchName); err != nil { + return fmt.Errorf("failed to create branch: %w", err) + } + + // Push to remote using HEAD:branchName refspec + if out, err := run("git", "push", "-u", "origin", fmt.Sprintf("HEAD:%s", branchName)); err != nil { + return fmt.Errorf("failed to push new branch: %w (output: %s)", err, out) + } + + log.Printf("Successfully created and pushed branch %s", branchName) + return nil +} + +// ListRemoteBranches lists all branches in the remote repository +func ListRemoteBranches(ctx context.Context, repoDir string) ([]string, error) { + cmd := exec.CommandContext(ctx, "git", "ls-remote", "--heads", "origin") + cmd.Dir = repoDir + + var stdout bytes.Buffer + cmd.Stdout = &stdout + + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("failed to list remote branches: %w", err) + } + + branches := []string{} + for _, line := range strings.Split(stdout.String(), "\n") { + if strings.TrimSpace(line) == "" { + continue + } + // Format: "commit-hash refs/heads/branch-name" + parts := strings.Fields(line) + if len(parts) >= 2 { + ref := parts[1] + branchName := strings.TrimPrefix(ref, "refs/heads/") + branches = append(branches, branchName) + } + } + + return branches, nil +} + +// SyncRepo commits, pulls, and pushes changes +func SyncRepo(ctx context.Context, repoDir, commitMessage, branch string) error { + if branch == "" { + branch = "main" + } + + // Stage all changes + cmd := exec.CommandContext(ctx, "git", "add", ".") + cmd.Dir = repoDir + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to stage changes: %w (output: %s)", err, string(out)) + } + + // Commit changes (only if there are changes) + cmd = exec.CommandContext(ctx, "git", "commit", "-m", commitMessage) + cmd.Dir = repoDir + if out, err := cmd.CombinedOutput(); err != nil { + // Check if error is "nothing to commit" + outStr := string(out) + if !strings.Contains(outStr, "nothing to commit") && !strings.Contains(outStr, "no changes added") { + return fmt.Errorf("failed to commit: %w (output: %s)", err, outStr) + } + // Nothing to commit is not an error + log.Printf("SyncRepo: nothing to commit in %s", repoDir) + } + + // Pull with rebase to sync with remote + cmd = exec.CommandContext(ctx, "git", "pull", "--rebase", "origin", branch) + cmd.Dir = repoDir + if out, err := cmd.CombinedOutput(); err != nil { + outStr := string(out) + // Check if it's just "no tracking information" (first push) + if !strings.Contains(outStr, "no tracking information") && !strings.Contains(outStr, "couldn't find remote ref") { + return fmt.Errorf("failed to pull: %w (output: %s)", err, outStr) + } + log.Printf("SyncRepo: pull skipped (no remote tracking): %s", outStr) + } + + // Push to remote + cmd = exec.CommandContext(ctx, "git", "push", "-u", "origin", branch) + cmd.Dir = repoDir + if out, err := cmd.CombinedOutput(); err != nil { + outStr := string(out) + if strings.Contains(outStr, "Permission denied") || strings.Contains(outStr, "403") { + return fmt.Errorf("permission denied: no push access to remote") + } + return fmt.Errorf("failed to push: %w (output: %s)", err, outStr) + } + + log.Printf("Successfully synchronized %s to %s", repoDir, branch) + return nil +} + +// Helper function to check if string slice contains a value +func contains(slice []string, str string) bool { + for _, s := range slice { + if s == str { + return true + } + } + return false +} diff --git a/components/backend/handlers/content.go b/components/backend/handlers/content.go index 6dae788df..92c3364b8 100644 --- a/components/backend/handlers/content.go +++ b/components/backend/handlers/content.go @@ -3,9 +3,11 @@ package handlers import ( "context" "encoding/base64" + "encoding/json" "log" "net/http" "os" + "os/exec" "path/filepath" "strings" "time" @@ -22,9 +24,14 @@ var StateBaseDir string // Git operation functions - set by main package during initialization // These are set to the actual implementations from git package var ( - GitPushRepo func(ctx context.Context, repoDir, commitMessage, outputRepoURL, branch, githubToken string) (string, error) - GitAbandonRepo func(ctx context.Context, repoDir string) error - GitDiffRepo func(ctx context.Context, repoDir string) (*git.DiffSummary, error) + GitPushRepo func(ctx context.Context, repoDir, commitMessage, outputRepoURL, branch, githubToken string) (string, error) + GitAbandonRepo func(ctx context.Context, repoDir string) error + GitDiffRepo func(ctx context.Context, repoDir string) (*git.DiffSummary, error) + GitCheckMergeStatus func(ctx context.Context, repoDir, branch string) (*git.MergeStatus, error) + GitPullRepo func(ctx context.Context, repoDir, branch string) error + GitPushToRepo func(ctx context.Context, repoDir, branch, commitMessage string) error + GitCreateBranch func(ctx context.Context, repoDir, branchName string) error + GitListRemoteBranches func(ctx context.Context, repoDir string) ([]string, error) ) // ContentGitPush handles POST /content/github/push in CONTENT_SERVICE_MODE @@ -149,6 +156,178 @@ func ContentGitDiff(c *gin.Context) { }) } +// ContentGitStatus handles GET /content/git-status?path= +func ContentGitStatus(c *gin.Context) { + path := filepath.Clean("/" + strings.TrimSpace(c.Query("path"))) + if path == "/" || strings.Contains(path, "..") { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"}) + return + } + + abs := filepath.Join(StateBaseDir, path) + + // Check if directory exists + if info, err := os.Stat(abs); err != nil || !info.IsDir() { + c.JSON(http.StatusOK, gin.H{ + "initialized": false, + "hasChanges": false, + }) + return + } + + // Check if git repo exists + gitDir := filepath.Join(abs, ".git") + if _, err := os.Stat(gitDir); err != nil { + c.JSON(http.StatusOK, gin.H{ + "initialized": false, + "hasChanges": false, + }) + return + } + + // Get git status using existing git package + summary, err := GitDiffRepo(c.Request.Context(), abs) + if err != nil { + log.Printf("ContentGitStatus: git diff failed: %v", err) + c.JSON(http.StatusOK, gin.H{ + "initialized": true, + "hasChanges": false, + }) + return + } + + hasChanges := summary.FilesAdded > 0 || summary.FilesRemoved > 0 || summary.TotalAdded > 0 || summary.TotalRemoved > 0 + + c.JSON(http.StatusOK, gin.H{ + "initialized": true, + "hasChanges": hasChanges, + "filesAdded": summary.FilesAdded, + "filesRemoved": summary.FilesRemoved, + "uncommittedFiles": summary.FilesAdded + summary.FilesRemoved, + "totalAdded": summary.TotalAdded, + "totalRemoved": summary.TotalRemoved, + }) +} + +// ContentGitConfigureRemote handles POST /content/git-configure-remote +// Body: { path: string, remoteURL: string, branch: string } +func ContentGitConfigureRemote(c *gin.Context) { + var body struct { + Path string `json:"path"` + RemoteURL string `json:"remoteUrl"` + Branch string `json:"branch"` + } + + if err := c.BindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + + path := filepath.Clean("/" + body.Path) + if path == "/" || strings.Contains(path, "..") { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"}) + return + } + + abs := filepath.Join(StateBaseDir, path) + + // Check if directory exists + if info, err := os.Stat(abs); err != nil || !info.IsDir() { + c.JSON(http.StatusBadRequest, gin.H{"error": "directory not found"}) + return + } + + // Initialize git if not already + gitDir := filepath.Join(abs, ".git") + if _, err := os.Stat(gitDir); err != nil { + if err := git.InitRepo(c.Request.Context(), abs); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to initialize git"}) + return + } + log.Printf("Initialized git repository at %s", abs) + } + + // Get GitHub token and inject into URL for authentication + remoteURL := body.RemoteURL + gitHubToken := strings.TrimSpace(c.GetHeader("X-GitHub-Token")) + if gitHubToken != "" { + if authenticatedURL, err := git.InjectGitHubToken(remoteURL, gitHubToken); err == nil { + remoteURL = authenticatedURL + log.Printf("Injected GitHub token into remote URL") + } + } + + // Configure remote with authenticated URL + if err := git.ConfigureRemote(c.Request.Context(), abs, "origin", remoteURL); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to configure remote"}) + return + } + + log.Printf("Configured remote for %s: %s", abs, body.RemoteURL) + + // Fetch from remote so merge status can be checked + // This is best-effort - don't fail if fetch fails + branch := body.Branch + if branch == "" { + branch = "main" + } + cmd := exec.CommandContext(c.Request.Context(), "git", "fetch", "origin", branch) + cmd.Dir = abs + if out, err := cmd.CombinedOutput(); err != nil { + log.Printf("Initial fetch after configure remote failed (non-fatal): %v (output: %s)", err, string(out)) + } else { + log.Printf("Fetched origin/%s after configuring remote", branch) + } + + c.JSON(http.StatusOK, gin.H{ + "message": "remote configured", + "remote": body.RemoteURL, + "branch": body.Branch, + }) +} + +// ContentGitSync handles POST /content/git-sync +// Body: { path: string, message: string, branch: string } +func ContentGitSync(c *gin.Context) { + var body struct { + Path string `json:"path"` + Message string `json:"message"` + Branch string `json:"branch"` + } + + if err := c.BindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + + path := filepath.Clean("/" + body.Path) + if path == "/" || strings.Contains(path, "..") { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"}) + return + } + + abs := filepath.Join(StateBaseDir, path) + + // Check if git repo exists + gitDir := filepath.Join(abs, ".git") + if _, err := os.Stat(gitDir); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "git repository not initialized"}) + return + } + + // Perform git sync operations + if err := git.SyncRepo(c.Request.Context(), abs, body.Message, body.Branch); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + log.Printf("Synchronized git repository at %s to branch %s", abs, body.Branch) + c.JSON(http.StatusOK, gin.H{ + "message": "synchronized successfully", + "branch": body.Branch, + }) +} + // ContentWrite handles POST /content/write when running in CONTENT_SERVICE_MODE func ContentWrite(c *gin.Context) { var req struct { @@ -281,3 +460,367 @@ func ContentList(c *gin.Context) { log.Printf("ContentList: returning %d items for path=%q", len(items), path) c.JSON(http.StatusOK, gin.H{"items": items}) } + +// ContentWorkflowMetadata handles GET /content/workflow-metadata?session= +// Parses .claude/commands/*.md and .claude/agents/*.md files from active workflow +func ContentWorkflowMetadata(c *gin.Context) { + sessionName := c.Query("session") + if sessionName == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "missing session parameter"}) + return + } + + log.Printf("ContentWorkflowMetadata: session=%q", sessionName) + + // Find active workflow directory + workflowDir := findActiveWorkflowDir(sessionName) + if workflowDir == "" { + log.Printf("ContentWorkflowMetadata: no active workflow found for session=%q", sessionName) + c.JSON(http.StatusOK, gin.H{ + "commands": []interface{}{}, + "agents": []interface{}{}, + "config": gin.H{"artifactsDir": "artifacts"}, // Default platform folder when no workflow + }) + return + } + + log.Printf("ContentWorkflowMetadata: found workflow at %q", workflowDir) + + // Parse ambient.json configuration + ambientConfig := parseAmbientConfig(workflowDir) + + // Parse commands from .claude/commands/*.md + commandsDir := filepath.Join(workflowDir, ".claude", "commands") + commands := []map[string]interface{}{} + + if files, err := os.ReadDir(commandsDir); err == nil { + for _, file := range files { + if !file.IsDir() && strings.HasSuffix(file.Name(), ".md") { + filePath := filepath.Join(commandsDir, file.Name()) + metadata := parseFrontmatter(filePath) + commandName := strings.TrimSuffix(file.Name(), ".md") + + displayName := metadata["displayName"] + if displayName == "" { + displayName = commandName + } + + commands = append(commands, map[string]interface{}{ + "id": commandName, + "name": displayName, + "description": metadata["description"], + "slashCommand": "/" + commandName, + }) + } + } + log.Printf("ContentWorkflowMetadata: found %d commands", len(commands)) + } else { + log.Printf("ContentWorkflowMetadata: commands directory not found or unreadable: %v", err) + } + + // Parse agents from .claude/agents/*.md + agentsDir := filepath.Join(workflowDir, ".claude", "agents") + agents := []map[string]interface{}{} + + if files, err := os.ReadDir(agentsDir); err == nil { + for _, file := range files { + if !file.IsDir() && strings.HasSuffix(file.Name(), ".md") { + filePath := filepath.Join(agentsDir, file.Name()) + metadata := parseFrontmatter(filePath) + agentID := strings.TrimSuffix(file.Name(), ".md") + + agents = append(agents, map[string]interface{}{ + "id": agentID, + "name": metadata["name"], + "description": metadata["description"], + "tools": metadata["tools"], + }) + } + } + log.Printf("ContentWorkflowMetadata: found %d agents", len(agents)) + } else { + log.Printf("ContentWorkflowMetadata: agents directory not found or unreadable: %v", err) + } + + c.JSON(http.StatusOK, gin.H{ + "commands": commands, + "agents": agents, + "config": gin.H{ + "name": ambientConfig.Name, + "description": ambientConfig.Description, + "systemPrompt": ambientConfig.SystemPrompt, + "artifactsDir": ambientConfig.ArtifactsDir, + }, + }) +} + +// parseFrontmatter extracts YAML frontmatter from a markdown file +func parseFrontmatter(filePath string) map[string]string { + content, err := os.ReadFile(filePath) + if err != nil { + log.Printf("parseFrontmatter: failed to read %q: %v", filePath, err) + return map[string]string{} + } + + str := string(content) + if !strings.HasPrefix(str, "---\n") { + return map[string]string{} + } + + // Find end of frontmatter + endIdx := strings.Index(str[4:], "\n---") + if endIdx == -1 { + return map[string]string{} + } + + frontmatter := str[4 : 4+endIdx] + result := map[string]string{} + + // Simple key: value parsing + for _, line := range strings.Split(frontmatter, "\n") { + if strings.TrimSpace(line) == "" { + continue + } + parts := strings.SplitN(line, ":", 2) + if len(parts) == 2 { + key := strings.TrimSpace(parts[0]) + value := strings.Trim(strings.TrimSpace(parts[1]), "\"'") + result[key] = value + } + } + + return result +} + +// AmbientConfig represents the ambient.json configuration +type AmbientConfig struct { + Name string `json:"name"` + Description string `json:"description"` + SystemPrompt string `json:"systemPrompt"` + ArtifactsDir string `json:"artifactsDir"` +} + +// parseAmbientConfig reads and parses ambient.json from workflow directory +// Returns default config if file doesn't exist (not an error) +// For custom workflows without ambient.json, returns empty artifactsDir (root directory) +// allowing them to manage their own structure +func parseAmbientConfig(workflowDir string) *AmbientConfig { + configPath := filepath.Join(workflowDir, ".ambient", "ambient.json") + + // Check if file exists + if _, err := os.Stat(configPath); os.IsNotExist(err) { + log.Printf("parseAmbientConfig: no ambient.json found at %q, using defaults", configPath) + return &AmbientConfig{ + ArtifactsDir: "", // Empty string means root (custom workflows manage their own structure) + } + } + + // Read file + data, err := os.ReadFile(configPath) + if err != nil { + log.Printf("parseAmbientConfig: failed to read %q: %v", configPath, err) + return &AmbientConfig{ArtifactsDir: ""} + } + + // Parse JSON + var config AmbientConfig + if err := json.Unmarshal(data, &config); err != nil { + log.Printf("parseAmbientConfig: failed to parse JSON from %q: %v", configPath, err) + return &AmbientConfig{ArtifactsDir: ""} + } + + log.Printf("parseAmbientConfig: loaded config: name=%q artifactsDir=%q", config.Name, config.ArtifactsDir) + return &config +} + +// findActiveWorkflowDir finds the active workflow directory for a session +func findActiveWorkflowDir(sessionName string) string { + // Workflows are stored at {StateBaseDir}/sessions/{session-name}/workspace/workflows/{workflow-name} + // The runner creates this nested structure + workflowsBase := filepath.Join(StateBaseDir, "sessions", sessionName, "workspace", "workflows") + + entries, err := os.ReadDir(workflowsBase) + if err != nil { + log.Printf("findActiveWorkflowDir: failed to read workflows directory %q: %v", workflowsBase, err) + return "" + } + + // Find first directory that has .claude subdirectory (excluding temp clones) + for _, entry := range entries { + if entry.IsDir() && entry.Name() != "default" && !strings.HasSuffix(entry.Name(), "-clone-temp") { + claudeDir := filepath.Join(workflowsBase, entry.Name(), ".claude") + if stat, err := os.Stat(claudeDir); err == nil && stat.IsDir() { + return filepath.Join(workflowsBase, entry.Name()) + } + } + } + + return "" +} + +// ContentGitMergeStatus handles GET /content/git-merge-status?path=&branch= +func ContentGitMergeStatus(c *gin.Context) { + path := filepath.Clean("/" + strings.TrimSpace(c.Query("path"))) + branch := strings.TrimSpace(c.Query("branch")) + + if path == "/" || strings.Contains(path, "..") { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"}) + return + } + + if branch == "" { + branch = "main" + } + + abs := filepath.Join(StateBaseDir, path) + + // Check if git repo exists + gitDir := filepath.Join(abs, ".git") + if _, err := os.Stat(gitDir); err != nil { + c.JSON(http.StatusOK, gin.H{ + "canMergeClean": true, + "localChanges": 0, + "remoteCommitsAhead": 0, + "conflictingFiles": []string{}, + "remoteBranchExists": false, + }) + return + } + + status, err := GitCheckMergeStatus(c.Request.Context(), abs, branch) + if err != nil { + log.Printf("ContentGitMergeStatus: check failed: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, status) +} + +// ContentGitPull handles POST /content/git-pull +// Body: { path: string, branch: string } +func ContentGitPull(c *gin.Context) { + var body struct { + Path string `json:"path"` + Branch string `json:"branch"` + } + + if err := c.BindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + + path := filepath.Clean("/" + body.Path) + if path == "/" || strings.Contains(path, "..") { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"}) + return + } + + if body.Branch == "" { + body.Branch = "main" + } + + abs := filepath.Join(StateBaseDir, path) + + if err := GitPullRepo(c.Request.Context(), abs, body.Branch); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + log.Printf("Pulled changes from origin/%s in %s", body.Branch, abs) + c.JSON(http.StatusOK, gin.H{"message": "pulled successfully", "branch": body.Branch}) +} + +// ContentGitPushToBranch handles POST /content/git-push +// Body: { path: string, branch: string, message: string } +func ContentGitPushToBranch(c *gin.Context) { + var body struct { + Path string `json:"path"` + Branch string `json:"branch"` + Message string `json:"message"` + } + + if err := c.BindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + + path := filepath.Clean("/" + body.Path) + if path == "/" || strings.Contains(path, "..") { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"}) + return + } + + if body.Branch == "" { + body.Branch = "main" + } + + if body.Message == "" { + body.Message = "Session artifacts update" + } + + abs := filepath.Join(StateBaseDir, path) + + if err := GitPushToRepo(c.Request.Context(), abs, body.Branch, body.Message); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + log.Printf("Pushed changes to origin/%s in %s", body.Branch, abs) + c.JSON(http.StatusOK, gin.H{"message": "pushed successfully", "branch": body.Branch}) +} + +// ContentGitCreateBranch handles POST /content/git-create-branch +// Body: { path: string, branchName: string } +func ContentGitCreateBranch(c *gin.Context) { + var body struct { + Path string `json:"path"` + BranchName string `json:"branchName"` + } + + if err := c.BindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + + path := filepath.Clean("/" + body.Path) + if path == "/" || strings.Contains(path, "..") { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"}) + return + } + + if body.BranchName == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "branchName is required"}) + return + } + + abs := filepath.Join(StateBaseDir, path) + + if err := GitCreateBranch(c.Request.Context(), abs, body.BranchName); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + log.Printf("Created branch %s in %s", body.BranchName, abs) + c.JSON(http.StatusOK, gin.H{"message": "branch created", "branchName": body.BranchName}) +} + +// ContentGitListBranches handles GET /content/git-list-branches?path= +func ContentGitListBranches(c *gin.Context) { + path := filepath.Clean("/" + strings.TrimSpace(c.Query("path"))) + + if path == "/" || strings.Contains(path, "..") { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"}) + return + } + + abs := filepath.Join(StateBaseDir, path) + + branches, err := GitListRemoteBranches(c.Request.Context(), abs) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"branches": branches}) +} diff --git a/components/backend/handlers/rfe.go b/components/backend/handlers/rfe.go deleted file mode 100644 index f1681010e..000000000 --- a/components/backend/handlers/rfe.go +++ /dev/null @@ -1,1208 +0,0 @@ -package handlers - -import ( - "context" - "encoding/base64" - "encoding/json" - "fmt" - "io" - "log" - "net/http" - "net/url" - "os" - "strings" - "time" - - "ambient-code-backend/git" - "ambient-code-backend/types" - - "github.com/gin-gonic/gin" - "k8s.io/apimachinery/pkg/api/errors" - v1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/client-go/dynamic" -) - -// Package-level variables for dependency injection (RFE-specific) -var ( - GetRFEWorkflowResource func() schema.GroupVersionResource - UpsertProjectRFEWorkflowCR func(dynamic.Interface, *types.RFEWorkflow) error - PerformRepoSeeding func(context.Context, *types.RFEWorkflow, string, string, string, string, string, string, string, string) (bool, error) - CheckRepoSeeding func(context.Context, string, *string, string) (bool, map[string]interface{}, error) - CheckBranchExists func(context.Context, string, string, string) (bool, error) - RfeFromUnstructured func(*unstructured.Unstructured) *types.RFEWorkflow -) - -// RFEWorkflow is a type alias for types.RFEWorkflow. -type RFEWorkflow = types.RFEWorkflow -type CreateRFEWorkflowRequest = types.CreateRFEWorkflowRequest -type GitRepository = types.GitRepository -type WorkflowJiraLink = types.WorkflowJiraLink - -// rfeLinkSessionRequest holds the request body for linking a session to an RFE workflow -type rfeLinkSessionRequest struct { - ExistingName string `json:"existingName"` - Phase string `json:"phase"` -} - -// normalizeRepoURL normalizes a repository URL for comparison -func normalizeRepoURL(repoURL string) string { - normalized := strings.ToLower(strings.TrimSpace(repoURL)) - // Remove .git suffix - normalized = strings.TrimSuffix(normalized, ".git") - // Remove trailing slash - normalized = strings.TrimSuffix(normalized, "/") - return normalized -} - -// validateUniqueRepositories checks that all repository URLs are unique -func validateUniqueRepositories(umbrellaRepo *GitRepository, supportingRepos []GitRepository) error { - seen := make(map[string]bool) - - // Check umbrella repo - if umbrellaRepo != nil && umbrellaRepo.URL != "" { - normalized := normalizeRepoURL(umbrellaRepo.URL) - seen[normalized] = true - } - - // Check supporting repos - for _, repo := range supportingRepos { - if repo.URL == "" { - continue - } - normalized := normalizeRepoURL(repo.URL) - if seen[normalized] { - return fmt.Errorf("duplicate repository URL detected: %s", repo.URL) - } - seen[normalized] = true - } - - return nil -} - -// ListProjectRFEWorkflows lists all RFE workflows for a project -func ListProjectRFEWorkflows(c *gin.Context) { - project := c.Param("projectName") - var workflows []RFEWorkflow - // Prefer CRD list with request-scoped client; fallback to file scan if unavailable or fails - gvr := GetRFEWorkflowResource() - _, reqDyn := GetK8sClientsForRequest(c) - if reqDyn != nil { - if list, err := reqDyn.Resource(gvr).Namespace(project).List(c.Request.Context(), v1.ListOptions{LabelSelector: fmt.Sprintf("project=%s", project)}); err == nil { - for _, item := range list.Items { - wf := RfeFromUnstructured(&item) - if wf == nil { - continue - } - workflows = append(workflows, *wf) - } - } - } - if workflows == nil { - workflows = []RFEWorkflow{} - } - // Return slim summaries: omit artifacts/agentSessions/phaseResults/status/currentPhase - summaries := make([]map[string]interface{}, 0, len(workflows)) - for _, w := range workflows { - item := map[string]interface{}{ - "id": w.ID, - "title": w.Title, - "description": w.Description, - "branchName": w.BranchName, - "project": w.Project, - "workspacePath": w.WorkspacePath, - "createdAt": w.CreatedAt, - "updatedAt": w.UpdatedAt, - } - if w.UmbrellaRepo != nil { - u := map[string]interface{}{"url": w.UmbrellaRepo.URL} - if w.UmbrellaRepo.Branch != nil { - u["branch"] = *w.UmbrellaRepo.Branch - } - item["umbrellaRepo"] = u - } - if len(w.SupportingRepos) > 0 { - repos := make([]map[string]interface{}, 0, len(w.SupportingRepos)) - for _, r := range w.SupportingRepos { - rm := map[string]interface{}{"url": r.URL} - if r.Branch != nil { - rm["branch"] = *r.Branch - } - repos = append(repos, rm) - } - item["supportingRepos"] = repos - } - summaries = append(summaries, item) - } - c.JSON(http.StatusOK, gin.H{"workflows": summaries}) -} - -// CreateProjectRFEWorkflow creates a new RFE workflow for a project -func CreateProjectRFEWorkflow(c *gin.Context) { - project := c.Param("projectName") - var req CreateRFEWorkflowRequest - bodyBytes, _ := c.GetRawData() - c.Request.Body = io.NopCloser(strings.NewReader(string(bodyBytes))) - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Validation failed: " + err.Error()}) - return - } - now := time.Now().UTC().Format(time.RFC3339) - workflowID := fmt.Sprintf("rfe-%d", time.Now().Unix()) - - // Branch name is required and generated by frontend (auto-populated from title, user-editable) - // Frontend generates: ambient-{first-three-words-from-title} - // Backend only validates that it's not empty and not a protected branch - branchName := strings.TrimSpace(req.BranchName) - if err := git.ValidateBranchName(branchName); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // Validate no duplicate repository URLs - if err := validateUniqueRepositories(&req.UmbrellaRepo, req.SupportingRepos); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - workflow := &RFEWorkflow{ - ID: workflowID, - Title: req.Title, - Description: req.Description, - BranchName: branchName, - UmbrellaRepo: &req.UmbrellaRepo, - SupportingRepos: req.SupportingRepos, - WorkspacePath: req.WorkspacePath, - Project: project, - CreatedAt: now, - UpdatedAt: now, - } - _, reqDyn := GetK8sClientsForRequest(c) - if err := UpsertProjectRFEWorkflowCR(reqDyn, workflow); err != nil { - log.Printf("⚠️ Failed to upsert RFEWorkflow CR: %v", err) - } - - // Seeding (spec-kit + agents) is now handled by POST /seed endpoint after creation - - c.JSON(http.StatusCreated, workflow) -} - -// UpdateProjectRFEWorkflow updates an existing RFE workflow's repository configuration -// This is primarily used to fix repository URLs before seeding -func UpdateProjectRFEWorkflow(c *gin.Context) { - project := c.Param("projectName") - id := c.Param("id") - - var req types.UpdateRFEWorkflowRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Validation failed: " + err.Error()}) - return - } - - // Get the workflow - gvr := GetRFEWorkflowResource() - _, reqDyn := GetK8sClientsForRequest(c) - if reqDyn == nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) - return - } - - item, err := reqDyn.Resource(gvr).Namespace(project).Get(c.Request.Context(), id, v1.GetOptions{}) - if err != nil { - if errors.IsNotFound(err) { - c.JSON(http.StatusNotFound, gin.H{"error": "Workflow not found"}) - } else if errors.IsForbidden(err) { - c.JSON(http.StatusForbidden, gin.H{"error": "You don't have permission to access this workflow"}) - } else { - log.Printf("Failed to get workflow %s: %v", id, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve workflow"}) - } - return - } - - wf := RfeFromUnstructured(item) - if wf == nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid workflow"}) - return - } - - // Validate no duplicate repository URLs if repositories are being updated - if req.UmbrellaRepo != nil || req.SupportingRepos != nil { - umbrellaRepo := req.UmbrellaRepo - if umbrellaRepo == nil { - umbrellaRepo = wf.UmbrellaRepo - } - supportingRepos := req.SupportingRepos - if supportingRepos == nil { - supportingRepos = wf.SupportingRepos - } - if err := validateUniqueRepositories(umbrellaRepo, supportingRepos); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - } - - // Update the CR - obj := item.DeepCopy() - spec, ok := obj.Object["spec"].(map[string]interface{}) - if !ok { - spec = make(map[string]interface{}) - obj.Object["spec"] = spec - } - - // Update fields if provided - if req.Title != nil { - spec["title"] = *req.Title - } - if req.Description != nil { - spec["description"] = *req.Description - } - if req.UmbrellaRepo != nil { - spec["umbrellaRepo"] = map[string]interface{}{ - "url": req.UmbrellaRepo.URL, - "branch": req.UmbrellaRepo.Branch, - } - } - if req.SupportingRepos != nil { - repos := make([]interface{}, len(req.SupportingRepos)) - for i, r := range req.SupportingRepos { - repos[i] = map[string]interface{}{ - "url": r.URL, - "branch": r.Branch, - } - } - spec["supportingRepos"] = repos - } - if req.ParentOutcome != nil { - spec["parentOutcome"] = *req.ParentOutcome - } - - // Update the CR - updated, err := reqDyn.Resource(gvr).Namespace(project).Update(c.Request.Context(), obj, v1.UpdateOptions{}) - if err != nil { - log.Printf("Failed to update RFEWorkflow CR: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update workflow"}) - return - } - - // Convert back to RFEWorkflow type - updatedWf := RfeFromUnstructured(updated) - if updatedWf == nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to parse updated workflow"}) - return - } - - c.JSON(http.StatusOK, updatedWf) -} - -// SeedProjectRFEWorkflow seeds the umbrella repo with spec-kit and agents via direct git operations -func SeedProjectRFEWorkflow(c *gin.Context) { - project := c.Param("projectName") - id := c.Param("id") - - // Get the workflow - gvr := GetRFEWorkflowResource() - reqK8s, reqDyn := GetK8sClientsForRequest(c) - if reqDyn == nil || reqK8s == nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) - return - } - - item, err := reqDyn.Resource(gvr).Namespace(project).Get(c.Request.Context(), id, v1.GetOptions{}) - if err != nil { - if errors.IsNotFound(err) { - c.JSON(http.StatusNotFound, gin.H{"error": "Workflow not found"}) - } else if errors.IsForbidden(err) { - c.JSON(http.StatusForbidden, gin.H{"error": "You don't have permission to access this workflow"}) - } else { - log.Printf("Failed to get workflow %s: %v", id, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve workflow"}) - } - return - } - wf := RfeFromUnstructured(item) - if wf == nil || wf.UmbrellaRepo == nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "No spec repo configured"}) - return - } - - // Ensure we have a branch name - if wf.BranchName == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Workflow missing branch name"}) - return - } - - // Get user ID from forwarded identity middleware - userID, _ := c.Get("userID") - userIDStr, ok := userID.(string) - if !ok || userIDStr == "" { - c.JSON(http.StatusUnauthorized, gin.H{"error": "User identity required"}) - return - } - - githubToken, err := GetGitHubToken(c.Request.Context(), reqK8s, reqDyn, project, userIDStr) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // Read request body for optional agent source and spec-kit settings - type SeedRequest struct { - AgentSourceURL string `json:"agentSourceUrl,omitempty"` - AgentSourceBranch string `json:"agentSourceBranch,omitempty"` - AgentSourcePath string `json:"agentSourcePath,omitempty"` - SpecKitRepo string `json:"specKitRepo,omitempty"` - SpecKitVersion string `json:"specKitVersion,omitempty"` - SpecKitTemplate string `json:"specKitTemplate,omitempty"` - } - var req SeedRequest - _ = c.ShouldBindJSON(&req) - - // Defaults - agentURL := req.AgentSourceURL - if agentURL == "" { - agentURL = "https://github.com/ambient-code/vTeam.git" - } - agentBranch := req.AgentSourceBranch - if agentBranch == "" { - agentBranch = "main" - } - agentPath := req.AgentSourcePath - if agentPath == "" { - agentPath = "agents" - } - // Spec-kit configuration: request body > environment variables > hardcoded defaults - specKitRepo := req.SpecKitRepo - if specKitRepo == "" { - if envRepo := strings.TrimSpace(os.Getenv("SPEC_KIT_REPO")); envRepo != "" { - specKitRepo = envRepo - } else { - specKitRepo = "github/spec-kit" - } - } - specKitVersion := req.SpecKitVersion - if specKitVersion == "" { - if envVersion := strings.TrimSpace(os.Getenv("SPEC_KIT_VERSION")); envVersion != "" { - specKitVersion = envVersion - } else { - specKitVersion = "main" - } - } - specKitTemplate := req.SpecKitTemplate - if specKitTemplate == "" { - if envTemplate := strings.TrimSpace(os.Getenv("SPEC_KIT_TEMPLATE")); envTemplate != "" { - specKitTemplate = envTemplate - } else { - specKitTemplate = "spec-kit-template-claude-sh" - } - } - - // Perform seeding operations with platform-managed branch - branchExisted, seedErr := PerformRepoSeeding(c.Request.Context(), wf, wf.BranchName, githubToken, agentURL, agentBranch, agentPath, specKitRepo, specKitVersion, specKitTemplate) - - if seedErr != nil { - log.Printf("Failed to seed RFE workflow %s in project %s: %v", id, project, seedErr) - c.JSON(http.StatusInternalServerError, gin.H{"error": seedErr.Error()}) - return - } - - message := "Repository seeded successfully" - if branchExisted { - message = fmt.Sprintf("Repository seeded successfully. Note: Branch '%s' already existed and will be modified by this RFE.", wf.BranchName) - } - - c.JSON(http.StatusOK, gin.H{ - "status": "completed", - "message": message, - "branchName": wf.BranchName, - "branchExisted": branchExisted, - }) -} - -// CheckProjectRFEWorkflowSeeding checks if the umbrella repo is seeded by querying GitHub API -func CheckProjectRFEWorkflowSeeding(c *gin.Context) { - project := c.Param("projectName") - id := c.Param("id") - - // Get the workflow - gvr := GetRFEWorkflowResource() - reqK8s, reqDyn := GetK8sClientsForRequest(c) - if reqDyn == nil || reqK8s == nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) - return - } - - item, err := reqDyn.Resource(gvr).Namespace(project).Get(c.Request.Context(), id, v1.GetOptions{}) - if err != nil { - if errors.IsNotFound(err) { - c.JSON(http.StatusNotFound, gin.H{"error": "Workflow not found"}) - } else if errors.IsForbidden(err) { - c.JSON(http.StatusForbidden, gin.H{"error": "You don't have permission to access this workflow"}) - } else { - log.Printf("Failed to get workflow %s: %v", id, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve workflow"}) - } - return - } - wf := RfeFromUnstructured(item) - if wf == nil || wf.UmbrellaRepo == nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "No spec repo configured"}) - return - } - - // Get user ID from forwarded identity middleware - userID, _ := c.Get("userID") - userIDStr, ok := userID.(string) - if !ok || userIDStr == "" { - c.JSON(http.StatusUnauthorized, gin.H{"error": "User identity required"}) - return - } - - githubToken, err := GetGitHubToken(c.Request.Context(), reqK8s, reqDyn, project, userIDStr) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // Check if umbrella repo is seeded - use the generated feature branch, not the base branch - branchToCheck := wf.UmbrellaRepo.Branch - if wf.BranchName != "" { - branchToCheck = &wf.BranchName - } - umbrellaSeeded, umbrellaDetails, err := CheckRepoSeeding(c.Request.Context(), wf.UmbrellaRepo.URL, branchToCheck, githubToken) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - // Check if all supporting repos have the feature branch - supportingReposStatus := []map[string]interface{}{} - allSupportingReposSeeded := true - - for _, supportingRepo := range wf.SupportingRepos { - branchExists, err := CheckBranchExists(c.Request.Context(), supportingRepo.URL, wf.BranchName, githubToken) - if err != nil { - log.Printf("Warning: failed to check branch in supporting repo %s: %v", supportingRepo.URL, err) - allSupportingReposSeeded = false - supportingReposStatus = append(supportingReposStatus, map[string]interface{}{ - "repoURL": supportingRepo.URL, - "branchExists": false, - "error": err.Error(), - }) - continue - } - - if !branchExists { - allSupportingReposSeeded = false - } - - supportingReposStatus = append(supportingReposStatus, map[string]interface{}{ - "repoURL": supportingRepo.URL, - "branchExists": branchExists, - }) - } - - // Overall seeding is complete only if umbrella repo is seeded AND all supporting repos have the branch - isFullySeeded := umbrellaSeeded && allSupportingReposSeeded - - c.JSON(http.StatusOK, gin.H{ - "isSeeded": isFullySeeded, - "specRepo": gin.H{ - "isSeeded": umbrellaSeeded, - "details": umbrellaDetails, - }, - "supportingRepos": supportingReposStatus, - }) -} - -// GetProjectRFEWorkflow retrieves a specific RFE workflow by ID -func GetProjectRFEWorkflow(c *gin.Context) { - project := c.Param("projectName") - id := c.Param("id") - // Try CRD with request-scoped client first - gvr := GetRFEWorkflowResource() - _, reqDyn := GetK8sClientsForRequest(c) - var wf *RFEWorkflow - var err error - if reqDyn != nil { - if item, gerr := reqDyn.Resource(gvr).Namespace(project).Get(c.Request.Context(), id, v1.GetOptions{}); gerr == nil { - wf = RfeFromUnstructured(item) - err = nil - } else { - err = gerr - } - } - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Workflow not found"}) - return - } - // Return slim object without artifacts/agentSessions/phaseResults/status/currentPhase - resp := map[string]interface{}{ - "id": wf.ID, - "title": wf.Title, - "description": wf.Description, - "branchName": wf.BranchName, - "project": wf.Project, - "workspacePath": wf.WorkspacePath, - "createdAt": wf.CreatedAt, - "updatedAt": wf.UpdatedAt, - } - if wf.ParentOutcome != nil { - resp["parentOutcome"] = *wf.ParentOutcome - } - if len(wf.JiraLinks) > 0 { - links := make([]map[string]interface{}, 0, len(wf.JiraLinks)) - for _, l := range wf.JiraLinks { - links = append(links, map[string]interface{}{"path": l.Path, "jiraKey": l.JiraKey}) - } - resp["jiraLinks"] = links - } - if wf.UmbrellaRepo != nil { - u := map[string]interface{}{"url": wf.UmbrellaRepo.URL} - if wf.UmbrellaRepo.Branch != nil { - u["branch"] = *wf.UmbrellaRepo.Branch - } - resp["umbrellaRepo"] = u - } - if len(wf.SupportingRepos) > 0 { - repos := make([]map[string]interface{}, 0, len(wf.SupportingRepos)) - for _, r := range wf.SupportingRepos { - rm := map[string]interface{}{"url": r.URL} - if r.Branch != nil { - rm["branch"] = *r.Branch - } - repos = append(repos, rm) - } - resp["supportingRepos"] = repos - } - c.JSON(http.StatusOK, resp) -} - -// GetProjectRFEWorkflowSummary computes derived phase/status and progress based on workspace files and linked sessions -// GET /api/projects/:projectName/rfe-workflows/:id/summary -func GetProjectRFEWorkflowSummary(c *gin.Context) { - project := c.Param("projectName") - id := c.Param("id") - - // Determine workspace and expected files - // workspace content removed - specsItems := []contentListItem{} - - hasSpec := false - hasPlan := false - hasTasks := false - - // helper to scan a list for target filenames - scanFor := func(items []contentListItem) (bool, bool, bool) { - s, p, t := false, false, false - for _, it := range items { - if it.IsDir { - continue - } - switch strings.ToLower(it.Name) { - case "spec.md": - s = true - case "plan.md": - p = true - case "tasks.md": - t = true - } - } - return s, p, t - } - - // First check directly under specs/ - if len(specsItems) > 0 { - s, p, t := scanFor(specsItems) - hasSpec, hasPlan, hasTasks = s, p, t - // If not found, check first subfolder under specs/ - if !hasSpec && !hasPlan && !hasTasks { - for _, it := range specsItems { - if it.IsDir { - subItems := []contentListItem{} - s2, p2, t2 := scanFor(subItems) - hasSpec, hasPlan, hasTasks = s2, p2, t2 - break - } - } - } - } - - // Sessions: find linked sessions and compute running/failed flags - gvr := GetAgenticSessionV1Alpha1Resource() - _, reqDyn := GetK8sClientsForRequest(c) - anyRunning := false - anyFailed := false - if reqDyn != nil { - selector := fmt.Sprintf("rfe-workflow=%s,project=%s", id, project) - if list, err := reqDyn.Resource(gvr).Namespace(project).List(c.Request.Context(), v1.ListOptions{LabelSelector: selector}); err == nil { - for _, item := range list.Items { - status, _ := item.Object["status"].(map[string]interface{}) - phaseStr := strings.ToLower(fmt.Sprintf("%v", status["phase"])) - if phaseStr == "running" || phaseStr == "creating" || phaseStr == "pending" { - anyRunning = true - } - if phaseStr == "failed" || phaseStr == "error" { - anyFailed = true - } - } - } - } - - // Derive phase and status - var phase string - switch { - case !hasSpec && !hasPlan && !hasTasks: - phase = "pre" - case !hasSpec: - phase = "specify" - case !hasPlan: - phase = "plan" - case !hasTasks: - phase = "tasks" - default: - phase = "completed" - } - - status := "not started" - if anyRunning { - status = "running" - } else if hasSpec || hasPlan || hasTasks { - status = "in progress" - } - if hasSpec && hasPlan && hasTasks && !anyRunning { - status = "completed" - } - if anyFailed && status != "running" { - status = "attention" - } - - progress := float64(0) - done := 0 - if hasSpec { - done++ - } - if hasPlan { - done++ - } - if hasTasks { - done++ - } - progress = float64(done) / 3.0 * 100.0 - - c.JSON(http.StatusOK, gin.H{ - "phase": phase, - "status": status, - "progress": progress, - "files": gin.H{ - "spec": hasSpec, - "plan": hasPlan, - "tasks": hasTasks, - }, - }) -} - -// DeleteProjectRFEWorkflow deletes an RFE workflow -func DeleteProjectRFEWorkflow(c *gin.Context) { - id := c.Param("id") - // Delete CR - gvr := GetRFEWorkflowResource() - _, reqDyn := GetK8sClientsForRequest(c) - if reqDyn != nil { - _ = reqDyn.Resource(gvr).Namespace(c.Param("projectName")).Delete(c.Request.Context(), id, v1.DeleteOptions{}) - } - c.JSON(http.StatusOK, gin.H{"message": "Workflow deleted successfully"}) -} - -// ListProjectRFEWorkflowSessions lists sessions linked to a project-scoped RFE workflow by label selector -func ListProjectRFEWorkflowSessions(c *gin.Context) { - project := c.Param("projectName") - id := c.Param("id") - gvr := GetAgenticSessionV1Alpha1Resource() - selector := fmt.Sprintf("rfe-workflow=%s,project=%s", id, project) - _, reqDyn := GetK8sClientsForRequest(c) - if reqDyn == nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing or invalid user token"}) - return - } - list, err := reqDyn.Resource(gvr).Namespace(project).List(c.Request.Context(), v1.ListOptions{LabelSelector: selector}) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list sessions", "details": err.Error()}) - return - } - - // Return full session objects for UI - sessions := make([]map[string]interface{}, 0, len(list.Items)) - for _, item := range list.Items { - sessions = append(sessions, item.Object) - } - c.JSON(http.StatusOK, gin.H{"sessions": sessions}) -} - -// AddProjectRFEWorkflowSession adds/links an existing session to an RFE by applying labels -func AddProjectRFEWorkflowSession(c *gin.Context) { - project := c.Param("projectName") - id := c.Param("id") - var req rfeLinkSessionRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()}) - return - } - if req.ExistingName == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "existingName is required for linking in this version"}) - return - } - gvr := GetAgenticSessionV1Alpha1Resource() - _, reqDyn := GetK8sClientsForRequest(c) - if reqDyn == nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing or invalid user token"}) - return - } - obj, err := reqDyn.Resource(gvr).Namespace(project).Get(c.Request.Context(), req.ExistingName, 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 fetch session", "details": err.Error()}) - return - } - meta, _ := obj.Object["metadata"].(map[string]interface{}) - labels, _ := meta["labels"].(map[string]interface{}) - if labels == nil { - labels = map[string]interface{}{} - meta["labels"] = labels - } - labels["project"] = project - labels["rfe-workflow"] = id - if req.Phase != "" { - labels["rfe-phase"] = req.Phase - } - // Update the resource - updated, err := reqDyn.Resource(gvr).Namespace(project).Update(c.Request.Context(), obj, v1.UpdateOptions{}) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update session labels", "details": err.Error()}) - return - } - _ = updated - c.JSON(http.StatusOK, gin.H{"message": "Session linked to RFE", "session": req.ExistingName}) -} - -// RemoveProjectRFEWorkflowSession removes/unlinks a session from an RFE by clearing linkage labels (non-destructive) -func RemoveProjectRFEWorkflowSession(c *gin.Context) { - project := c.Param("projectName") - _ = project // currently unused but kept for parity/logging if needed - id := c.Param("id") - sessionName := c.Param("sessionName") - gvr := GetAgenticSessionV1Alpha1Resource() - _, reqDyn := GetK8sClientsForRequest(c) - if reqDyn == nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing or invalid user token"}) - return - } - obj, err := reqDyn.Resource(gvr).Namespace(project).Get(c.Request.Context(), 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 fetch session", "details": err.Error()}) - return - } - meta, _ := obj.Object["metadata"].(map[string]interface{}) - labels, _ := meta["labels"].(map[string]interface{}) - if labels != nil { - delete(labels, "rfe-workflow") - delete(labels, "rfe-phase") - } - if _, err := reqDyn.Resource(gvr).Namespace(project).Update(c.Request.Context(), obj, v1.UpdateOptions{}); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update session labels", "details": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "Session unlinked from RFE", "session": sessionName, "rfe": id}) -} - -// GetProjectRFEWorkflowAgents fetches agent definitions from the workflow's umbrella repository -// GET /api/projects/:projectName/rfe-workflows/:id/agents -func GetProjectRFEWorkflowAgents(c *gin.Context) { - project := c.Param("projectName") - id := c.Param("id") - - // Get the workflow - gvr := GetRFEWorkflowResource() - reqK8s, reqDyn := GetK8sClientsForRequest(c) - if reqDyn == nil || reqK8s == nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) - return - } - - item, err := reqDyn.Resource(gvr).Namespace(project).Get(c.Request.Context(), id, v1.GetOptions{}) - if err != nil { - if errors.IsNotFound(err) { - c.JSON(http.StatusNotFound, gin.H{"error": "Workflow not found"}) - } else if errors.IsForbidden(err) { - c.JSON(http.StatusForbidden, gin.H{"error": "You don't have permission to access this workflow"}) - } else { - log.Printf("Failed to get workflow %s: %v", id, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve workflow"}) - } - return - } - wf := RfeFromUnstructured(item) - if wf == nil || wf.UmbrellaRepo == nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "No spec repo configured"}) - return - } - - // Get user ID from forwarded identity middleware - userID, _ := c.Get("userID") - userIDStr, ok := userID.(string) - if !ok || userIDStr == "" { - c.JSON(http.StatusUnauthorized, gin.H{"error": "User identity required"}) - return - } - - githubToken, err := GetGitHubToken(c.Request.Context(), reqK8s, reqDyn, project, userIDStr) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // Parse repo owner/name from umbrella repo URL - repoURL := wf.UmbrellaRepo.URL - owner, repoName, err := parseOwnerRepoFromURL(repoURL) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid repository URL: %v", err)}) - return - } - - // Get ref (branch) - use the generated feature branch, not the base branch - ref := "main" - if wf.BranchName != "" { - ref = wf.BranchName - } else if wf.UmbrellaRepo.Branch != nil { - ref = *wf.UmbrellaRepo.Branch - } - - // Fetch agents from .claude/agents directory - agents, err := fetchAgentsFromRepo(c.Request.Context(), owner, repoName, ref, githubToken) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"agents": agents}) -} - -// parseOwnerRepoFromURL extracts owner and repo name from a GitHub URL -func parseOwnerRepoFromURL(repoURL string) (string, string, error) { - // Remove .git suffix - repoURL = strings.TrimSuffix(repoURL, ".git") - - // Handle https://github.com/owner/repo - if strings.HasPrefix(repoURL, "http://") || strings.HasPrefix(repoURL, "https://") { - parts := strings.Split(strings.TrimPrefix(strings.TrimPrefix(repoURL, "https://"), "http://"), "/") - if len(parts) >= 3 { - return parts[1], parts[2], nil - } - } - - // Handle git@github.com:owner/repo - if strings.Contains(repoURL, "@") { - parts := strings.Split(repoURL, ":") - if len(parts) == 2 { - repoParts := strings.Split(parts[1], "/") - if len(repoParts) == 2 { - return repoParts[0], repoParts[1], nil - } - } - } - - // Handle owner/repo format - parts := strings.Split(repoURL, "/") - if len(parts) == 2 { - return parts[0], parts[1], nil - } - - return "", "", fmt.Errorf("unable to parse repository URL") -} - -// Agent represents an agent definition from .claude/agents directory -type Agent struct { - Persona string `json:"persona"` - Name string `json:"name"` - Role string `json:"role"` - Description string `json:"description"` -} - -// fetchAgentsFromRepo fetches and parses agent definitions from .claude/agents directory -func fetchAgentsFromRepo(ctx context.Context, owner, repo, ref, token string) ([]Agent, error) { - api := "https://api.github.com" - agentsPath := ".claude/agents" - - // Fetch directory listing - treeURL := fmt.Sprintf("%s/repos/%s/%s/contents/%s?ref=%s", api, owner, repo, agentsPath, ref) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, treeURL, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("Authorization", "Bearer "+token) - req.Header.Set("Accept", "application/vnd.github+json") - req.Header.Set("X-GitHub-Api-Version", "2022-11-28") - - client := &http.Client{Timeout: 15 * time.Second} - resp, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("GitHub request failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode == http.StatusNotFound { - // No .claude/agents directory - return empty array - return []Agent{}, nil - } - - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("GitHub API error %d: %s", resp.StatusCode, string(body)) - } - - var treeEntries []map[string]interface{} - if err := json.NewDecoder(resp.Body).Decode(&treeEntries); err != nil { - return nil, fmt.Errorf("failed to parse GitHub response: %w", err) - } - - // Filter for .md files - var agentFiles []string - for _, entry := range treeEntries { - name, _ := entry["name"].(string) - typ, _ := entry["type"].(string) - if typ == "file" && strings.HasSuffix(name, ".md") { - agentFiles = append(agentFiles, name) - } - } - - // Fetch and parse each agent file - agents := make([]Agent, 0, len(agentFiles)) - for _, filename := range agentFiles { - agent, err := fetchAndParseAgentFile(ctx, api, owner, repo, ref, filename, token) - if err != nil { - log.Printf("Warning: failed to parse agent file %s: %v", filename, err) - continue - } - agents = append(agents, agent) - } - - return agents, nil -} - -// fetchAndParseAgentFile fetches a single agent file and parses its metadata -func fetchAndParseAgentFile(ctx context.Context, api, owner, repo, ref, filename, token string) (Agent, error) { - agentPath := fmt.Sprintf(".claude/agents/%s", filename) - url := fmt.Sprintf("%s/repos/%s/%s/contents/%s?ref=%s", api, owner, repo, agentPath, ref) - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return Agent{}, fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("Authorization", "Bearer "+token) - req.Header.Set("Accept", "application/vnd.github+json") - req.Header.Set("X-GitHub-Api-Version", "2022-11-28") - - client := &http.Client{Timeout: 15 * time.Second} - resp, err := client.Do(req) - if err != nil { - return Agent{}, fmt.Errorf("GitHub request failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return Agent{}, fmt.Errorf("GitHub returned status %d", resp.StatusCode) - } - - var fileData map[string]interface{} - if err := json.NewDecoder(resp.Body).Decode(&fileData); err != nil { - return Agent{}, fmt.Errorf("failed to parse GitHub response: %w", err) - } - - // Decode base64 content - content, _ := fileData["content"].(string) - encoding, _ := fileData["encoding"].(string) - - var decodedContent string - if strings.ToLower(encoding) == "base64" { - raw := strings.ReplaceAll(content, "\n", "") - data, err := base64.StdEncoding.DecodeString(raw) - if err != nil { - return Agent{}, fmt.Errorf("failed to decode base64 content: %w", err) - } - decodedContent = string(data) - } else { - decodedContent = content - } - - // Parse persona from filename - persona := strings.TrimSuffix(filename, ".md") - - // Generate default name from filename - nameParts := strings.FieldsFunc(persona, func(r rune) bool { - return r == '-' || r == '_' - }) - for i, part := range nameParts { - if len(part) > 0 { - nameParts[i] = strings.ToUpper(part[:1]) + part[1:] - } - } - name := strings.Join(nameParts, " ") - - role := "" - description := "" - - // Try to extract metadata from YAML frontmatter - // Simple regex-based parsing (consider using a YAML library for production) - lines := strings.Split(decodedContent, "\n") - inFrontmatter := false - for i, line := range lines { - if i == 0 && strings.TrimSpace(line) == "---" { - inFrontmatter = true - continue - } - if inFrontmatter && strings.TrimSpace(line) == "---" { - break - } - if inFrontmatter { - if strings.HasPrefix(line, "name:") { - name = strings.TrimSpace(strings.TrimPrefix(line, "name:")) - } else if strings.HasPrefix(line, "role:") { - role = strings.TrimSpace(strings.TrimPrefix(line, "role:")) - } else if strings.HasPrefix(line, "description:") { - description = strings.TrimSpace(strings.TrimPrefix(line, "description:")) - } - } - } - - // If no description found, use first non-empty line after frontmatter - if description == "" { - afterFrontmatter := false - for _, line := range lines { - if afterFrontmatter { - trimmed := strings.TrimSpace(line) - if trimmed != "" && !strings.HasPrefix(trimmed, "#") { - description = trimmed - if len(description) > 150 { - description = description[:150] - } - break - } - } - if strings.TrimSpace(line) == "---" { - if afterFrontmatter { - break - } - afterFrontmatter = true - } - } - } - - if description == "" { - description = "No description available" - } - - return Agent{ - Persona: persona, - Name: name, - Role: role, - Description: description, - }, nil -} - -// GetWorkflowJira proxies Jira issue fetch for a linked path -// GET /api/projects/:projectName/rfe-workflows/:id/jira?path=... -func GetWorkflowJira(c *gin.Context) { - project := c.Param("projectName") - id := c.Param("id") - reqPath := strings.TrimSpace(c.Query("path")) - if reqPath == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "path is required"}) - return - } - _, reqDyn := GetK8sClientsForRequest(c) - reqK8s, _ := GetK8sClientsForRequest(c) - if reqDyn == nil || reqK8s == nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing or invalid user token"}) - return - } - // Load workflow to find key - gvrWf := GetRFEWorkflowResource() - item, err := reqDyn.Resource(gvrWf).Namespace(project).Get(c.Request.Context(), id, v1.GetOptions{}) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Workflow not found"}) - return - } - wf := RfeFromUnstructured(item) - var key string - for _, jl := range wf.JiraLinks { - if strings.TrimSpace(jl.Path) == reqPath { - key = jl.JiraKey - break - } - } - if key == "" { - c.JSON(http.StatusNotFound, gin.H{"error": "No Jira linked for path"}) - return - } - // Load Jira creds - // Determine secret name - secretName := "ambient-runner-secrets" - if obj, err := reqDyn.Resource(GetProjectSettingsResource()).Namespace(project).Get(c.Request.Context(), "projectsettings", v1.GetOptions{}); err == nil { - if spec, ok := obj.Object["spec"].(map[string]interface{}); ok { - if v, ok := spec["runnerSecretsName"].(string); ok && strings.TrimSpace(v) != "" { - secretName = strings.TrimSpace(v) - } - } - } - sec, err := reqK8s.CoreV1().Secrets(project).Get(c.Request.Context(), secretName, v1.GetOptions{}) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read runner secret", "details": err.Error()}) - return - } - get := func(k string) string { - if b, ok := sec.Data[k]; ok { - return string(b) - } - return "" - } - jiraURL := strings.TrimSpace(get("JIRA_URL")) - jiraToken := strings.TrimSpace(get("JIRA_API_TOKEN")) - if jiraURL == "" || jiraToken == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Missing Jira configuration in runner secret (JIRA_URL, JIRA_API_TOKEN required)"}) - return - } - // Determine auth header (Cloud vs Server/Data Center) - authHeader := "" - if strings.Contains(jiraURL, "atlassian.net") { - // Jira Cloud - assume token is email:api_token format - encoded := base64.StdEncoding.EncodeToString([]byte(jiraToken)) - authHeader = "Basic " + encoded - } else { - // Jira Server/Data Center - authHeader = "Bearer " + jiraToken - } - - jiraBase := strings.TrimRight(jiraURL, "/") - endpoint := fmt.Sprintf("%s/rest/api/2/issue/%s", jiraBase, url.PathEscape(key)) - httpReq, _ := http.NewRequest("GET", endpoint, nil) - httpReq.Header.Set("Authorization", authHeader) - httpClient := &http.Client{Timeout: 30 * time.Second} - httpResp, httpErr := httpClient.Do(httpReq) - if httpErr != nil { - c.JSON(http.StatusBadGateway, gin.H{"error": "Jira request failed", "details": httpErr.Error()}) - return - } - defer httpResp.Body.Close() - respBody, _ := io.ReadAll(httpResp.Body) - c.Data(httpResp.StatusCode, "application/json", respBody) -} diff --git a/components/backend/handlers/secrets.go b/components/backend/handlers/secrets.go index 13d569353..69cdd070a 100644 --- a/components/backend/handlers/secrets.go +++ b/components/backend/handlers/secrets.go @@ -3,7 +3,6 @@ package handlers import ( "log" "net/http" - "strings" "time" "github.com/gin-gonic/gin" @@ -12,9 +11,9 @@ import ( v1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -// Runner secrets management -// Config is stored in ProjectSettings.spec.runnerSecretsName -// The Secret lives in the project namespace and stores key/value pairs for runners +// Two-secret architecture (hardcoded secret names): +// 1. ambient-runner-secrets: ANTHROPIC_API_KEY only (ignored when Vertex enabled) +// 2. ambient-non-vertex-integrations: GITHUB_TOKEN, JIRA_*, custom keys (always injected) // ListNamespaceSecrets handles GET /api/projects/:projectName/secrets -> { items: [{name, createdAt}] } func ListNamespaceSecrets(c *gin.Context) { @@ -51,104 +50,117 @@ func ListNamespaceSecrets(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"items": items}) } -// GetRunnerSecretsConfig handles GET /api/projects/:projectName/runner-secrets/config -func GetRunnerSecretsConfig(c *gin.Context) { +// Runner secrets (ANTHROPIC_API_KEY only) +// Hardcoded secret name: "ambient-runner-secrets" +// Only injected when Vertex is disabled + +// ListRunnerSecrets handles GET /api/projects/:projectName/runner-secrets -> { data: { key: value } } +func ListRunnerSecrets(c *gin.Context) { projectName := c.Param("projectName") - _, reqDyn := GetK8sClientsForRequest(c) - - gvr := GetProjectSettingsResource() - // ProjectSettings is a singleton per namespace named 'projectsettings' - obj, err := reqDyn.Resource(gvr).Namespace(projectName).Get(c.Request.Context(), "projectsettings", v1.GetOptions{}) - if err != nil && !errors.IsNotFound(err) { - log.Printf("Failed to read ProjectSettings for %s: %v", projectName, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read runner secrets config"}) + reqK8s, _ := GetK8sClientsForRequest(c) + if reqK8s == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing token"}) + c.Abort() return } - secretName := "" - if obj != nil { - if spec, ok := obj.Object["spec"].(map[string]interface{}); ok { - if v, ok := spec["runnerSecretsName"].(string); ok { - secretName = v - } + const secretName = "ambient-runner-secrets" + + sec, err := reqK8s.CoreV1().Secrets(projectName).Get(c.Request.Context(), secretName, v1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + c.JSON(http.StatusOK, gin.H{"data": map[string]string{}}) + return } + log.Printf("Failed to get Secret %s/%s: %v", projectName, secretName, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read runner secrets"}) + return } - c.JSON(http.StatusOK, gin.H{"secretName": secretName}) + + out := map[string]string{} + for k, v := range sec.Data { + out[k] = string(v) + } + c.JSON(http.StatusOK, gin.H{"data": out}) } -// UpdateRunnerSecretsConfig handles PUT /api/projects/:projectName/runner-secrets/config { secretName } -func UpdateRunnerSecretsConfig(c *gin.Context) { +// UpdateRunnerSecrets handles PUT /api/projects/:projectName/runner-secrets { data: { key: value } } +func UpdateRunnerSecrets(c *gin.Context) { projectName := c.Param("projectName") - _, reqDyn := GetK8sClientsForRequest(c) + reqK8s, _ := GetK8sClientsForRequest(c) + if reqK8s == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing token"}) + c.Abort() + return + } var req struct { - SecretName string `json:"secretName" binding:"required"` + Data map[string]string `json:"data" binding:"required"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - if strings.TrimSpace(req.SecretName) == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "secretName is required"}) - return - } - // Operator owns ProjectSettings. If it exists, update; otherwise, return not found. - gvr := GetProjectSettingsResource() - obj, err := reqDyn.Resource(gvr).Namespace(projectName).Get(c.Request.Context(), "projectsettings", v1.GetOptions{}) - if errors.IsNotFound(err) { - c.JSON(http.StatusNotFound, gin.H{"error": "ProjectSettings not found. Ensure the namespace is labeled ambient-code.io/managed=true and wait for operator."}) - return - } - if err != nil { - log.Printf("Failed to read ProjectSettings for %s: %v", projectName, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read runner secrets config"}) - return - } - - // Update spec.runnerSecretsName - spec, _ := obj.Object["spec"].(map[string]interface{}) - if spec == nil { - spec = map[string]interface{}{} - obj.Object["spec"] = spec - } - spec["runnerSecretsName"] = req.SecretName + const secretName = "ambient-runner-secrets" - if _, err := reqDyn.Resource(gvr).Namespace(projectName).Update(c.Request.Context(), obj, v1.UpdateOptions{}); err != nil { - log.Printf("Failed to update ProjectSettings for %s: %v", projectName, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update runner secrets config"}) + sec, err := reqK8s.CoreV1().Secrets(projectName).Get(c.Request.Context(), secretName, v1.GetOptions{}) + if errors.IsNotFound(err) { + // Create new Secret + newSec := &corev1.Secret{ + ObjectMeta: v1.ObjectMeta{ + Name: secretName, + Namespace: projectName, + Labels: map[string]string{"app": "ambient-runner-secrets"}, + Annotations: map[string]string{ + "ambient-code.io/runner-secret": "true", + }, + }, + Type: corev1.SecretTypeOpaque, + StringData: req.Data, + } + if _, err := reqK8s.CoreV1().Secrets(projectName).Create(c.Request.Context(), newSec, v1.CreateOptions{}); err != nil { + log.Printf("Failed to create Secret %s/%s: %v", projectName, secretName, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create runner secrets"}) + return + } + } else if err != nil { + log.Printf("Failed to get Secret %s/%s: %v", projectName, secretName, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read runner secrets"}) return + } else { + // Update existing - replace Data + sec.Type = corev1.SecretTypeOpaque + sec.Data = map[string][]byte{} + for k, v := range req.Data { + sec.Data[k] = []byte(v) + } + if _, err := reqK8s.CoreV1().Secrets(projectName).Update(c.Request.Context(), sec, v1.UpdateOptions{}); err != nil { + log.Printf("Failed to update Secret %s/%s: %v", projectName, secretName, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update runner secrets"}) + return + } } - c.JSON(http.StatusOK, gin.H{"secretName": req.SecretName}) + c.JSON(http.StatusOK, gin.H{"message": "runner secrets updated"}) } -// ListRunnerSecrets handles GET /api/projects/:projectName/runner-secrets -> { data: { key: value } } -func ListRunnerSecrets(c *gin.Context) { +// Integration secrets (GITHUB_TOKEN, JIRA_*, custom keys) +// Hardcoded secret name: "ambient-non-vertex-integrations" +// Always injected as env vars, regardless of Vertex setting + +// ListIntegrationSecrets handles GET /api/projects/:projectName/integration-secrets -> { data: { key: value } } +func ListIntegrationSecrets(c *gin.Context) { projectName := c.Param("projectName") - reqK8s, reqDyn := GetK8sClientsForRequest(c) - - // Read config - gvr := GetProjectSettingsResource() - obj, err := reqDyn.Resource(gvr).Namespace(projectName).Get(c.Request.Context(), "projectsettings", v1.GetOptions{}) - if err != nil && !errors.IsNotFound(err) { - log.Printf("Failed to read ProjectSettings for %s: %v", projectName, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read runner secrets config"}) - return - } - secretName := "" - if obj != nil { - if spec, ok := obj.Object["spec"].(map[string]interface{}); ok { - if v, ok := spec["runnerSecretsName"].(string); ok { - secretName = v - } - } - } - if secretName == "" { - c.JSON(http.StatusOK, gin.H{"data": map[string]string{}}) + reqK8s, _ := GetK8sClientsForRequest(c) + if reqK8s == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing token"}) + c.Abort() return } + const secretName = "ambient-non-vertex-integrations" + sec, err := reqK8s.CoreV1().Secrets(projectName).Get(c.Request.Context(), secretName, v1.GetOptions{}) if err != nil { if errors.IsNotFound(err) { @@ -156,7 +168,7 @@ func ListRunnerSecrets(c *gin.Context) { return } log.Printf("Failed to get Secret %s/%s: %v", projectName, secretName, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read runner secrets"}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read integration secrets"}) return } @@ -167,10 +179,15 @@ func ListRunnerSecrets(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"data": out}) } -// UpdateRunnerSecrets handles PUT /api/projects/:projectName/runner-secrets { data: { key: value } } -func UpdateRunnerSecrets(c *gin.Context) { +// UpdateIntegrationSecrets handles PUT /api/projects/:projectName/integration-secrets { data: { key: value } } +func UpdateIntegrationSecrets(c *gin.Context) { projectName := c.Param("projectName") - reqK8s, reqDyn := GetK8sClientsForRequest(c) + reqK8s, _ := GetK8sClientsForRequest(c) + if reqK8s == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing token"}) + c.Abort() + return + } var req struct { Data map[string]string `json:"data" binding:"required"` @@ -180,37 +197,15 @@ func UpdateRunnerSecrets(c *gin.Context) { return } - // Read config for secret name - gvr := GetProjectSettingsResource() - obj, err := reqDyn.Resource(gvr).Namespace(projectName).Get(c.Request.Context(), "projectsettings", v1.GetOptions{}) - if err != nil && !errors.IsNotFound(err) { - log.Printf("Failed to read ProjectSettings for %s: %v", projectName, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read runner secrets config"}) - return - } - secretName := "" - if obj != nil { - if spec, ok := obj.Object["spec"].(map[string]interface{}); ok { - if v, ok := spec["runnerSecretsName"].(string); ok { - secretName = strings.TrimSpace(v) - } - } - } - if secretName == "" { - secretName = "ambient-runner-secrets" - } - - // Do not create/update ProjectSettings here. The operator owns it. + const secretName = "ambient-non-vertex-integrations" - // Try to get existing Secret sec, err := reqK8s.CoreV1().Secrets(projectName).Get(c.Request.Context(), secretName, v1.GetOptions{}) if errors.IsNotFound(err) { - // Create new Secret newSec := &corev1.Secret{ ObjectMeta: v1.ObjectMeta{ Name: secretName, Namespace: projectName, - Labels: map[string]string{"app": "ambient-runner-secrets"}, + Labels: map[string]string{"app": "ambient-integration-secrets"}, Annotations: map[string]string{ "ambient-code.io/runner-secret": "true", }, @@ -220,15 +215,14 @@ func UpdateRunnerSecrets(c *gin.Context) { } if _, err := reqK8s.CoreV1().Secrets(projectName).Create(c.Request.Context(), newSec, v1.CreateOptions{}); err != nil { log.Printf("Failed to create Secret %s/%s: %v", projectName, secretName, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create runner secrets"}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create integration secrets"}) return } } else if err != nil { log.Printf("Failed to get Secret %s/%s: %v", projectName, secretName, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read runner secrets"}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read integration secrets"}) return } else { - // Update existing - replace Data sec.Type = corev1.SecretTypeOpaque sec.Data = map[string][]byte{} for k, v := range req.Data { @@ -236,10 +230,10 @@ func UpdateRunnerSecrets(c *gin.Context) { } if _, err := reqK8s.CoreV1().Secrets(projectName).Update(c.Request.Context(), sec, v1.UpdateOptions{}); err != nil { log.Printf("Failed to update Secret %s/%s: %v", projectName, secretName, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update runner secrets"}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update integration secrets"}) return } } - c.JSON(http.StatusOK, gin.H{"message": "runner secrets updated"}) + c.JSON(http.StatusOK, gin.H{"message": "integration secrets updated"}) } diff --git a/components/backend/handlers/sessions.go b/components/backend/handlers/sessions.go index 44f596da8..a2d66bf62 100644 --- a/components/backend/handlers/sessions.go +++ b/components/backend/handlers/sessions.go @@ -13,6 +13,7 @@ import ( "strings" "time" + "ambient-code-backend/git" "ambient-code-backend/types" "github.com/gin-gonic/gin" @@ -36,17 +37,9 @@ var ( DynamicClient dynamic.Interface GetGitHubToken func(context.Context, *kubernetes.Clientset, dynamic.Interface, string, string) (string, error) DeriveRepoFolderFromURL func(string) string + SendMessageToSession func(string, string, map[string]interface{}) ) -// contentListItem represents a file/directory in the workspace -type contentListItem struct { - Name string `json:"name"` - Path string `json:"path"` - IsDir bool `json:"isDir"` - Size int64 `json:"size"` - ModifiedAt string `json:"modifiedAt"` -} - // parseSpec parses AgenticSessionSpec with v1alpha1 fields func parseSpec(spec map[string]interface{}) types.AgenticSessionSpec { result := types.AgenticSessionSpec{} @@ -183,6 +176,21 @@ func parseSpec(spec map[string]interface{}) types.AgenticSessionSpec { result.MainRepoIndex = &idxInt } + // Parse activeWorkflow + if workflow, ok := spec["activeWorkflow"].(map[string]interface{}); ok { + ws := &types.WorkflowSelection{} + if gitURL, ok := workflow["gitUrl"].(string); ok { + ws.GitURL = gitURL + } + if branch, ok := workflow["branch"].(string); ok { + ws.Branch = branch + } + if path, ok := workflow["path"].(string); ok { + ws.Path = path + } + result.ActiveWorkflow = ws + } + return result } @@ -440,54 +448,6 @@ func CreateSession(c *gin.Context) { } } - // Handle RFE workflow branch management - { - rfeWorkflowID := "" - // Check if RFE workflow ID is in labels - if len(req.Labels) > 0 { - if id, ok := req.Labels["rfe-workflow"]; ok { - rfeWorkflowID = id - } - } - - // If linked to an RFE workflow, fetch it and set the branch - if rfeWorkflowID != "" { - // Get request-scoped dynamic client for fetching RFE workflow - _, reqDyn := GetK8sClientsForRequest(c) - if reqDyn != nil { - rfeGvr := GetRFEWorkflowResource() - if rfeGvr != (schema.GroupVersionResource{}) { - rfeObj, err := reqDyn.Resource(rfeGvr).Namespace(project).Get(c.Request.Context(), rfeWorkflowID, v1.GetOptions{}) - if err == nil { - rfeWf := RfeFromUnstructured(rfeObj) - if rfeWf != nil && rfeWf.BranchName != "" { - // Access spec from session object - spec := session["spec"].(map[string]interface{}) - - // Override branch for all repos to use feature branch - if repos, ok := spec["repos"].([]map[string]interface{}); ok { - for i := range repos { - // Always override input branch with feature branch - if input, ok := repos[i]["input"].(map[string]interface{}); ok { - input["branch"] = rfeWf.BranchName - } - // Always override output branch with feature branch - if output, ok := repos[i]["output"].(map[string]interface{}); ok { - output["branch"] = rfeWf.BranchName - } - } - } - - log.Printf("Set RFE branch %s for session %s", rfeWf.BranchName, name) - } - } else { - log.Printf("Warning: Failed to fetch RFE workflow %s: %v", rfeWorkflowID, err) - } - } - } - } - } - // Add userContext derived from authenticated caller; ignore client-supplied userId { uidVal, _ := c.Get("userID") @@ -1079,518 +1039,690 @@ func UpdateSessionDisplayName(c *gin.Context) { c.JSON(http.StatusOK, session) } -func DeleteSession(c *gin.Context) { +// SelectWorkflow sets the active workflow for a session +// POST /api/projects/:projectName/agentic-sessions/:sessionName/workflow +func SelectWorkflow(c *gin.Context) { project := c.GetString("project") sessionName := c.Param("sessionName") - reqK8s, reqDyn := GetK8sClientsForRequest(c) - _ = reqK8s + _, reqDyn := GetK8sClientsForRequest(c) + + var req types.WorkflowSelection + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + gvr := GetAgenticSessionV1Alpha1Resource() - err := reqDyn.Resource(gvr).Namespace(project).Delete(context.TODO(), sessionName, v1.DeleteOptions{}) + // Retrieve 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 } - log.Printf("Failed to delete agentic session %s in project %s: %v", sessionName, project, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete agentic session"}) + log.Printf("Failed to get agentic session %s in project %s: %v", sessionName, project, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get agentic session"}) return } - c.Status(http.StatusNoContent) + // Update activeWorkflow in spec + spec, ok := item.Object["spec"].(map[string]interface{}) + if !ok { + spec = make(map[string]interface{}) + item.Object["spec"] = spec + } + + // Set activeWorkflow + workflowMap := map[string]interface{}{ + "gitUrl": req.GitURL, + } + if req.Branch != "" { + workflowMap["branch"] = req.Branch + } else { + workflowMap["branch"] = "main" + } + if req.Path != "" { + workflowMap["path"] = req.Path + } + spec["activeWorkflow"] = workflowMap + + // Persist the change + updated, err := reqDyn.Resource(gvr).Namespace(project).Update(context.TODO(), item, v1.UpdateOptions{}) + if err != nil { + log.Printf("Failed to update workflow for agentic session %s in project %s: %v", sessionName, project, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update workflow"}) + return + } + + log.Printf("Workflow updated for session %s: %s@%s", sessionName, req.GitURL, workflowMap["branch"]) + + // Note: The workflow will be available on next user interaction. The frontend should + // send a workflow_change message via the WebSocket to notify the runner immediately. + + // Respond with updated session summary + session := types.AgenticSession{ + APIVersion: updated.GetAPIVersion(), + Kind: updated.GetKind(), + Metadata: updated.Object["metadata"].(map[string]interface{}), + } + if s, ok := updated.Object["spec"].(map[string]interface{}); ok { + session.Spec = parseSpec(s) + } + if st, ok := updated.Object["status"].(map[string]interface{}); ok { + session.Status = parseStatus(st) + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Workflow updated successfully", + "session": session, + }) } -func CloneSession(c *gin.Context) { +// AddRepo adds a new repository to a running session +// POST /api/projects/:projectName/agentic-sessions/:sessionName/repos +func AddRepo(c *gin.Context) { project := c.GetString("project") sessionName := c.Param("sessionName") _, reqDyn := GetK8sClientsForRequest(c) - var req types.CloneSessionRequest + var req struct { + URL string `json:"url" binding:"required"` + Branch string `json:"branch"` + Output *struct { + URL string `json:"url"` + Branch string `json:"branch"` + } `json:"output,omitempty"` + } + if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - gvr := GetAgenticSessionV1Alpha1Resource() - - // Get source session - sourceItem, 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": "Source session not found"}) - return - } - log.Printf("Failed to get source agentic session %s in project %s: %v", sessionName, project, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get source agentic session"}) - return + if req.Branch == "" { + req.Branch = "main" } - // Validate target project exists and is managed by Ambient via OpenShift Project - projGvr := GetOpenShiftProjectResource() - projObj, err := reqDyn.Resource(projGvr).Get(context.TODO(), req.TargetProject, v1.GetOptions{}) + gvr := GetAgenticSessionV1Alpha1Resource() + 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": "Target project not found"}) + c.JSON(http.StatusNotFound, gin.H{"error": "Session not found"}) return } - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to validate target project"}) - return - } - - isAmbient := false - if meta, ok := projObj.Object["metadata"].(map[string]interface{}); ok { - if raw, ok := meta["labels"].(map[string]interface{}); ok { - if v, ok := raw["ambient-code.io/managed"].(string); ok && v == "true" { - isAmbient = true - } - } - } - if !isAmbient { - c.JSON(http.StatusForbidden, gin.H{"error": "Target project is not managed by Ambient"}) + log.Printf("Failed to get session %s in project %s: %v", sessionName, project, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get session"}) return } - // Ensure unique target session name in target namespace; if exists, append "-duplicate" (and numeric suffix) - newName := strings.TrimSpace(req.NewSessionName) - if newName == "" { - newName = sessionName + // Update spec.repos + spec, ok := item.Object["spec"].(map[string]interface{}) + if !ok { + spec = make(map[string]interface{}) + item.Object["spec"] = spec } - finalName := newName - conflicted := false - for i := 0; i < 50; i++ { - _, getErr := reqDyn.Resource(gvr).Namespace(req.TargetProject).Get(context.TODO(), finalName, v1.GetOptions{}) - if errors.IsNotFound(getErr) { - break - } - if getErr != nil && !errors.IsNotFound(getErr) { - // On unexpected error, still attempt to proceed with a duplicate suffix to reduce collision chance - log.Printf("cloneSession: name check encountered error for %s/%s: %v", req.TargetProject, finalName, getErr) - } - conflicted = true - if i == 0 { - finalName = fmt.Sprintf("%s-duplicate", newName) - } else { - finalName = fmt.Sprintf("%s-duplicate-%d", newName, i+1) - } + repos, _ := spec["repos"].([]interface{}) + if repos == nil { + repos = []interface{}{} } - // Create cloned session - clonedSession := map[string]interface{}{ - "apiVersion": "vteam.ambient-code/v1alpha1", - "kind": "AgenticSession", - "metadata": map[string]interface{}{ - "name": finalName, - "namespace": req.TargetProject, - }, - "spec": sourceItem.Object["spec"], - "status": map[string]interface{}{ - "phase": "Pending", + newRepo := map[string]interface{}{ + "input": map[string]interface{}{ + "url": req.URL, + "branch": req.Branch, }, } - - // Update project in spec - clonedSpec := clonedSession["spec"].(map[string]interface{}) - clonedSpec["project"] = req.TargetProject - if conflicted { - if dn, ok := clonedSpec["displayName"].(string); ok && strings.TrimSpace(dn) != "" { - clonedSpec["displayName"] = fmt.Sprintf("%s (Duplicate)", dn) - } else { - clonedSpec["displayName"] = fmt.Sprintf("%s (Duplicate)", finalName) + if req.Output != nil { + newRepo["output"] = map[string]interface{}{ + "url": req.Output.URL, + "branch": req.Output.Branch, } } + repos = append(repos, newRepo) + spec["repos"] = repos - obj := &unstructured.Unstructured{Object: clonedSession} - - created, err := reqDyn.Resource(gvr).Namespace(req.TargetProject).Create(context.TODO(), obj, v1.CreateOptions{}) + // Persist change + _, err = reqDyn.Resource(gvr).Namespace(project).Update(context.TODO(), item, v1.UpdateOptions{}) if err != nil { - log.Printf("Failed to create cloned agentic session in project %s: %v", req.TargetProject, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create cloned agentic session"}) + log.Printf("Failed to update session %s in project %s: %v", sessionName, project, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update session"}) return } - // Parse and return created session - session := types.AgenticSession{ - APIVersion: created.GetAPIVersion(), - Kind: created.GetKind(), - Metadata: created.Object["metadata"].(map[string]interface{}), - } - - if spec, ok := created.Object["spec"].(map[string]interface{}); ok { - session.Spec = parseSpec(spec) - } - - if status, ok := created.Object["status"].(map[string]interface{}); ok { - session.Status = parseStatus(status) + // Notify runner via WebSocket + repoName := DeriveRepoFolderFromURL(req.URL) + if SendMessageToSession != nil { + SendMessageToSession(sessionName, "repo_added", map[string]interface{}{ + "name": repoName, + "url": req.URL, + "branch": req.Branch, + }) } - c.JSON(http.StatusCreated, session) + log.Printf("Added repository %s to session %s in project %s", repoName, sessionName, project) + c.JSON(http.StatusOK, gin.H{"message": "Repository added", "name": repoName}) } -// 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) +// RemoveRepo removes a repository from a running session +// DELETE /api/projects/:projectName/agentic-sessions/:sessionName/repos/:repoName +func RemoveRepo(c *gin.Context) { + project := c.GetString("project") + sessionName := c.Param("sessionName") + repoName := c.Param("repoName") + _, reqDyn := GetK8sClientsForRequest(c) - // Get existing role - existingRole, err := reqK8s.RbacV1().Roles(project).Get(c.Request.Context(), roleName, v1.GetOptions{}) + gvr := GetAgenticSessionV1Alpha1Resource() + item, err := reqDyn.Resource(gvr).Namespace(project).Get(context.TODO(), sessionName, 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 + c.JSON(http.StatusNotFound, gin.H{"error": "Session not found"}) + return } - return fmt.Errorf("get role: %w", err) + log.Printf("Failed to get session %s in project %s: %v", sessionName, project, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get session"}) + return } - // 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 - } - } - } + // Update spec.repos + spec, ok := item.Object["spec"].(map[string]interface{}) + if !ok { + c.JSON(http.StatusBadRequest, gin.H{"error": "Session has no spec"}) + return + } + repos, _ := spec["repos"].([]interface{}) + + filteredRepos := []interface{}{} + found := false + for _, r := range repos { + rm, _ := r.(map[string]interface{}) + input, _ := rm["input"].(map[string]interface{}) + url, _ := input["url"].(string) + if DeriveRepoFolderFromURL(url) != repoName { + filteredRepos = append(filteredRepos, r) + } else { + found = true } } - if hasSelfSubjectAccessReview { - log.Printf("Role %s already has selfsubjectaccessreviews permission", roleName) - return nil + if !found { + c.JSON(http.StatusNotFound, gin.H{"error": "Repository not found in session"}) + return } - // 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"}, - }) + spec["repos"] = filteredRepos - _, err = reqK8s.RbacV1().Roles(project).Update(c.Request.Context(), existingRole, v1.UpdateOptions{}) + // Persist change + _, err = reqDyn.Resource(gvr).Namespace(project).Update(context.TODO(), item, v1.UpdateOptions{}) if err != nil { - return fmt.Errorf("update role: %w", err) + log.Printf("Failed to update session %s in project %s: %v", sessionName, project, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update session"}) + return } - log.Printf("Successfully updated role %s with selfsubjectaccessreviews permission", roleName) - return nil + // Notify runner via WebSocket + if SendMessageToSession != nil { + SendMessageToSession(sessionName, "repo_removed", map[string]interface{}{ + "name": repoName, + }) + } + + log.Printf("Removed repository %s from session %s in project %s", repoName, sessionName, project) + c.JSON(http.StatusOK, gin.H{"message": "Repository removed"}) } -func StartSession(c *gin.Context) { +// GetWorkflowMetadata retrieves commands and agents metadata from the active workflow +// GET /api/projects/:projectName/agentic-sessions/:sessionName/workflow/metadata +func GetWorkflowMetadata(c *gin.Context) { project := c.GetString("project") + if project == "" { + project = c.Param("projectName") + } sessionName := c.Param("sessionName") - reqK8s, reqDyn := GetK8sClientsForRequest(c) - 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 - } - log.Printf("Failed to get agentic session %s in project %s: %v", sessionName, project, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get agentic session"}) + if project == "" { + log.Printf("GetWorkflowMetadata: project is empty, session=%s", sessionName) + c.JSON(http.StatusBadRequest, gin.H{"error": "Project namespace required"}) return } - // 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 + // Get authorization token + token := c.GetHeader("Authorization") + if strings.TrimSpace(token) == "" { + token = c.GetHeader("X-Forwarded-Access-Token") } - // 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 + // Try temp service first (for completed sessions), then regular service + serviceName := fmt.Sprintf("temp-content-%s", sessionName) + reqK8s, _ := GetK8sClientsForRequest(c) 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) + 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", sessionName) } + } else { + serviceName = fmt.Sprintf("ambient-content-%s", sessionName) } - // 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 - } - } - } + // Build URL to content service + endpoint := fmt.Sprintf("http://%s.%s.svc:8080", serviceName, project) + u := fmt.Sprintf("%s/content/workflow-metadata?session=%s", endpoint, sessionName) + + log.Printf("GetWorkflowMetadata: project=%s session=%s endpoint=%s", project, sessionName, endpoint) + + // Create and send request to content pod + req, _ := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, u, nil) + if strings.TrimSpace(token) != "" { + req.Header.Set("Authorization", token) + } + client := &http.Client{Timeout: 4 * time.Second} + resp, err := client.Do(req) + if err != nil { + log.Printf("GetWorkflowMetadata: content service request failed: %v", err) + // Return empty metadata on error + c.JSON(http.StatusOK, gin.H{"commands": []interface{}{}, "agents": []interface{}{}}) + return } + defer resp.Body.Close() - if !isActualContinuation { - log.Printf("StartSession: Not a continuation - current phase is: %s (not in terminal phases)", currentPhase) + b, _ := io.ReadAll(resp.Body) + c.Data(resp.StatusCode, "application/json", b) +} + +// fetchGitHubFileContent fetches a file from GitHub via API +// token is optional - works for public repos without authentication (but has rate limits) +func fetchGitHubFileContent(ctx context.Context, owner, repo, ref, path, token string) ([]byte, error) { + api := "https://api.github.com" + url := fmt.Sprintf("%s/repos/%s/%s/contents/%s?ref=%s", api, owner, repo, path, ref) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err } - // 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) + // Only set Authorization header if token is provided + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + req.Header.Set("Accept", "application/vnd.github.raw") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") - // 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") - } - } + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() - // 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 - } + if resp.StatusCode == http.StatusNotFound { + return nil, fmt.Errorf("file not found") + } - // 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") + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("GitHub API error %d: %s", resp.StatusCode, string(body)) + } - // 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) + return io.ReadAll(resp.Body) +} + +// fetchGitHubDirectoryListing lists files/folders in a GitHub directory +// token is optional - works for public repos without authentication (but has rate limits) +func fetchGitHubDirectoryListing(ctx context.Context, owner, repo, ref, path, token string) ([]map[string]interface{}, error) { + api := "https://api.github.com" + url := fmt.Sprintf("%s/repos/%s/%s/contents/%s?ref=%s", api, owner, repo, path, ref) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + // Only set Authorization header if token is provided + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("GitHub API error %d: %s", resp.StatusCode, string(body)) + } + + var entries []map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&entries); err != nil { + return nil, err + } + + return entries, nil +} + +// OOTBWorkflow represents an out-of-the-box workflow +type OOTBWorkflow struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + GitURL string `json:"gitUrl"` + Branch string `json:"branch"` + Path string `json:"path,omitempty"` + Enabled bool `json:"enabled"` +} + +// ListOOTBWorkflows returns the list of out-of-the-box workflows dynamically discovered from GitHub +// Attempts to use user's GitHub token for better rate limits, falls back to unauthenticated for public repos +// GET /api/workflows/ootb?project= +func ListOOTBWorkflows(c *gin.Context) { + // Try to get user's GitHub token (best effort - not required) + // This gives better rate limits (5000/hr vs 60/hr) and supports private repos + // Project is optional - if provided, we'll try to get the user's token + token := "" + project := c.Query("project") // Optional query parameter + if project != "" { + userID, _ := c.Get("userID") + if reqK8s, reqDyn := GetK8sClientsForRequest(c); reqK8s != nil { + if userIDStr, ok := userID.(string); ok && userIDStr != "" { + if githubToken, err := GetGitHubToken(c.Request.Context(), reqK8s, reqDyn, project, userIDStr); err == nil { + token = githubToken + log.Printf("ListOOTBWorkflows: using user's GitHub token for project %s (better rate limits)", project) } else { - log.Printf("StartSession: Job %s already gone", jobName) + log.Printf("ListOOTBWorkflows: failed to get GitHub token for project %s: %v", project, err) } - } else { - log.Printf("StartSession: Successfully deleted old job %s", jobName) } } - } else { - log.Printf("StartSession: Not setting parent-session-id (first run, no completion time)") + } + if token == "" { + log.Printf("ListOOTBWorkflows: proceeding without GitHub token (public repo, lower rate limits)") } - // Now update status to trigger start (using the fresh object from Update) - if item.Object["status"] == nil { - item.Object["status"] = make(map[string]interface{}) + // Read OOTB repo configuration from environment + ootbRepo := strings.TrimSpace(os.Getenv("OOTB_WORKFLOWS_REPO")) + if ootbRepo == "" { + ootbRepo = "https://github.com/Gkrumbach07/spec-kit-template.git" } - status := item.Object["status"].(map[string]interface{}) - // 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) + ootbBranch := strings.TrimSpace(os.Getenv("OOTB_WORKFLOWS_BRANCH")) + if ootbBranch == "" { + ootbBranch = "main" + } - // Update the status subresource using backend SA (status updates require elevated permissions) - if DynamicClient == nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "backend not initialized"}) - return + ootbWorkflowsPath := strings.TrimSpace(os.Getenv("OOTB_WORKFLOWS_PATH")) + if ootbWorkflowsPath == "" { + ootbWorkflowsPath = "workflows" } - updated, err := DynamicClient.Resource(gvr).Namespace(project).UpdateStatus(context.TODO(), item, v1.UpdateOptions{}) + + // Parse GitHub URL + owner, repoName, err := git.ParseGitHubURL(ootbRepo) 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"}) + log.Printf("ListOOTBWorkflows: invalid repo URL: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid OOTB repo URL"}) return } - // Parse and return updated session - session := types.AgenticSession{ - APIVersion: updated.GetAPIVersion(), - Kind: updated.GetKind(), - Metadata: updated.Object["metadata"].(map[string]interface{}), + // List workflow directories + entries, err := fetchGitHubDirectoryListing(c.Request.Context(), owner, repoName, ootbBranch, ootbWorkflowsPath, token) + if err != nil { + log.Printf("ListOOTBWorkflows: failed to list workflows directory: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to discover OOTB workflows"}) + return } - if spec, ok := updated.Object["spec"].(map[string]interface{}); ok { - session.Spec = parseSpec(spec) - } + // Scan each subdirectory for ambient.json + workflows := []OOTBWorkflow{} + for _, entry := range entries { + entryType, _ := entry["type"].(string) + entryName, _ := entry["name"].(string) - if status, ok := updated.Object["status"].(map[string]interface{}); ok { - session.Status = parseStatus(status) + if entryType != "dir" { + continue + } + + // Try to fetch ambient.json from this workflow directory + ambientPath := fmt.Sprintf("%s/%s/.ambient/ambient.json", ootbWorkflowsPath, entryName) + ambientData, err := fetchGitHubFileContent(c.Request.Context(), owner, repoName, ootbBranch, ambientPath, token) + + var ambientConfig struct { + Name string `json:"name"` + Description string `json:"description"` + } + if err == nil { + // Parse ambient.json if found + if parseErr := json.Unmarshal(ambientData, &ambientConfig); parseErr != nil { + log.Printf("ListOOTBWorkflows: failed to parse ambient.json for %s: %v", entryName, parseErr) + } + } + + // Use ambient.json values or fallback to directory name + workflowName := ambientConfig.Name + if workflowName == "" { + workflowName = strings.ReplaceAll(entryName, "-", " ") + workflowName = strings.Title(workflowName) + } + + workflows = append(workflows, OOTBWorkflow{ + ID: entryName, + Name: workflowName, + Description: ambientConfig.Description, + GitURL: ootbRepo, + Branch: ootbBranch, + Path: fmt.Sprintf("%s/%s", ootbWorkflowsPath, entryName), + Enabled: true, + }) } - c.JSON(http.StatusAccepted, session) + log.Printf("ListOOTBWorkflows: discovered %d workflows from %s", len(workflows), ootbRepo) + c.JSON(http.StatusOK, gin.H{"workflows": workflows}) } -func StopSession(c *gin.Context) { +func DeleteSession(c *gin.Context) { project := c.GetString("project") sessionName := c.Param("sessionName") reqK8s, reqDyn := GetK8sClientsForRequest(c) + _ = reqK8s gvr := GetAgenticSessionV1Alpha1Resource() - // Get current resource - item, err := reqDyn.Resource(gvr).Namespace(project).Get(context.TODO(), sessionName, v1.GetOptions{}) + err := reqDyn.Resource(gvr).Namespace(project).Delete(context.TODO(), sessionName, v1.DeleteOptions{}) if err != nil { if errors.IsNotFound(err) { c.JSON(http.StatusNotFound, gin.H{"error": "Session not found"}) return } - log.Printf("Failed to get agentic session %s in project %s: %v", sessionName, project, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get agentic session"}) + log.Printf("Failed to delete agentic session %s in project %s: %v", sessionName, project, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete agentic session"}) return } - // Check current status - status, ok := item.Object["status"].(map[string]interface{}) - if !ok { - status = make(map[string]interface{}) - item.Object["status"] = status - } - - currentPhase, _ := status["phase"].(string) - if currentPhase == "Completed" || currentPhase == "Failed" || currentPhase == "Stopped" { - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Cannot stop session in %s state", currentPhase)}) - return - } + c.Status(http.StatusNoContent) +} - log.Printf("Attempting to stop agentic session %s in project %s (current phase: %s)", sessionName, project, currentPhase) +func CloneSession(c *gin.Context) { + project := c.GetString("project") + sessionName := c.Param("sessionName") + _, reqDyn := GetK8sClientsForRequest(c) - // Get job name from status - jobName, jobExists := status["jobName"].(string) - 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) + var req types.CloneSessionRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return } - // Delete the job and its pods - log.Printf("Attempting to delete job %s for session %s", jobName, sessionName) + gvr := GetAgenticSessionV1Alpha1Resource() - // First, delete the job itself with foreground propagation - deletePolicy := v1.DeletePropagationForeground - err = reqK8s.BatchV1().Jobs(project).Delete(context.TODO(), jobName, v1.DeleteOptions{ - PropagationPolicy: &deletePolicy, - }) + // Get source session + sourceItem, err := reqDyn.Resource(gvr).Namespace(project).Get(context.TODO(), sessionName, v1.GetOptions{}) 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") + c.JSON(http.StatusNotFound, gin.H{"error": "Source session not found"}) + return } - } else { - log.Printf("Successfully deleted job %s for agentic session %s", jobName, sessionName) + log.Printf("Failed to get source agentic session %s in project %s: %v", sessionName, project, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get source agentic session"}) + return } - // 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) + // Validate target project exists and is managed by Ambient via OpenShift Project + projGvr := GetOpenShiftProjectResource() + projObj, err := reqDyn.Resource(projGvr).Get(context.TODO(), req.TargetProject, v1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "Target project not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to validate target project"}) + return } - // 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") + isAmbient := false + if meta, ok := projObj.Object["metadata"].(map[string]interface{}); ok { + if raw, ok := meta["labels"].(map[string]interface{}); ok { + if v, ok := raw["ambient-code.io/managed"].(string); ok && v == "true" { + isAmbient = true + } + } + } + if !isAmbient { + c.JSON(http.StatusForbidden, gin.H{"error": "Target project is not managed by Ambient"}) + return } - // Update status to Stopped - status["phase"] = "Stopped" - status["message"] = "Session stopped by user" - status["completionTime"] = time.Now().Format(time.RFC3339) - - // 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 - } + // Ensure unique target session name in target namespace; if exists, append "-duplicate" (and numeric suffix) + newName := strings.TrimSpace(req.NewSessionName) + if newName == "" { + newName = sessionName + } + finalName := newName + conflicted := false + for i := 0; i < 50; i++ { + _, getErr := reqDyn.Resource(gvr).Namespace(req.TargetProject).Get(context.TODO(), finalName, v1.GetOptions{}) + if errors.IsNotFound(getErr) { + break + } + if getErr != nil && !errors.IsNotFound(getErr) { + // On unexpected error, still attempt to proceed with a duplicate suffix to reduce collision chance + log.Printf("cloneSession: name check encountered error for %s/%s: %v", req.TargetProject, finalName, getErr) + } + conflicted = true + if i == 0 { + finalName = fmt.Sprintf("%s-duplicate", newName) + } else { + finalName = fmt.Sprintf("%s-duplicate-%d", newName, i+1) } } - // Update the resource using UpdateStatus for status subresource (using backend SA) - if DynamicClient == nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "backend not initialized"}) - return + // Create cloned session + clonedSession := map[string]interface{}{ + "apiVersion": "vteam.ambient-code/v1alpha1", + "kind": "AgenticSession", + "metadata": map[string]interface{}{ + "name": finalName, + "namespace": req.TargetProject, + }, + "spec": sourceItem.Object["spec"], + "status": map[string]interface{}{ + "phase": "Pending", + }, } - updated, err := DynamicClient.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 - log.Printf("Agentic session %s was deleted during stop operation", sessionName) - c.JSON(http.StatusOK, gin.H{"message": "Session no longer exists (already deleted)"}) - return + + // Update project in spec + clonedSpec := clonedSession["spec"].(map[string]interface{}) + clonedSpec["project"] = req.TargetProject + if conflicted { + if dn, ok := clonedSpec["displayName"].(string); ok && strings.TrimSpace(dn) != "" { + clonedSpec["displayName"] = fmt.Sprintf("%s (Duplicate)", dn) + } else { + clonedSpec["displayName"] = fmt.Sprintf("%s (Duplicate)", finalName) } - log.Printf("Failed to update agentic session status %s: %v", sessionName, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agentic session status"}) + } + + obj := &unstructured.Unstructured{Object: clonedSession} + + created, err := reqDyn.Resource(gvr).Namespace(req.TargetProject).Create(context.TODO(), obj, v1.CreateOptions{}) + if err != nil { + log.Printf("Failed to create cloned agentic session in project %s: %v", req.TargetProject, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create cloned agentic session"}) return } - // Parse and return updated session + // Parse and return created session session := types.AgenticSession{ - APIVersion: updated.GetAPIVersion(), - Kind: updated.GetKind(), - Metadata: updated.Object["metadata"].(map[string]interface{}), + APIVersion: created.GetAPIVersion(), + Kind: created.GetKind(), + Metadata: created.Object["metadata"].(map[string]interface{}), } - if spec, ok := updated.Object["spec"].(map[string]interface{}); ok { + if spec, ok := created.Object["spec"].(map[string]interface{}); ok { session.Spec = parseSpec(spec) } - if status, ok := updated.Object["status"].(map[string]interface{}); ok { + if status, ok := created.Object["status"].(map[string]interface{}); ok { session.Status = parseStatus(status) } - log.Printf("Successfully stopped agentic session %s", sessionName) - c.JSON(http.StatusAccepted, session) + c.JSON(http.StatusCreated, session) } -// UpdateSessionStatus writes selected fields to PVC-backed files and updates CR status. -// PUT /api/projects/:projectName/agentic-sessions/:sessionName/status -func UpdateSessionStatus(c *gin.Context) { - project := c.GetString("project") - sessionName := c.Param("sessionName") - _, reqDyn := GetK8sClientsForRequest(c) +// 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) - var statusUpdate map[string]interface{} - if err := c.ShouldBindJSON(&statusUpdate); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return + // 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) gvr := GetAgenticSessionV1Alpha1Resource() // Get current resource @@ -1605,401 +1737,710 @@ func UpdateSessionStatus(c *gin.Context) { return } - // Ensure status map - if item.Object["status"] == nil { - item.Object["status"] = make(map[string]interface{}) + // 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 } - status := item.Object["status"].(map[string]interface{}) - // Accept standard fields and result summary fields from runner - allowed := map[string]struct{}{ - "phase": {}, "completionTime": {}, "cost": {}, "message": {}, - "subtype": {}, "duration_ms": {}, "duration_api_ms": {}, "is_error": {}, - "num_turns": {}, "session_id": {}, "total_cost_usd": {}, "usage": {}, "result": {}, - } - for k := range statusUpdate { - if _, ok := allowed[k]; !ok { - delete(statusUpdate, k) + // 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) } } - // Merge remaining fields into status - for k, v := range statusUpdate { - status[k] = v + // 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 + } + } + } } - // Update only the status subresource using backend SA (status updates require elevated permissions) - if DynamicClient == nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "backend not initialized"}) - return - } - if _, err := DynamicClient.Resource(gvr).Namespace(project).UpdateStatus(context.TODO(), item, v1.UpdateOptions{}); err != nil { - log.Printf("Failed to update agentic session status %s in project %s: %v", sessionName, project, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agentic session status"}) - return + if !isActualContinuation { + log.Printf("StartSession: Not a continuation - current phase is: %s (not in terminal phases)", currentPhase) } - c.JSON(http.StatusOK, gin.H{"message": "agentic session status updated"}) -} - -// 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") + // 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) - reqK8s, _ := GetK8sClientsForRequest(c) - if reqK8s == nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) - return - } + // 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") + } + } - podName := fmt.Sprintf("temp-content-%s", sessionName) + // 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 + } - // 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 + // 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) } } - 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 + } else { + log.Printf("StartSession: Not setting parent-session-id (first run, no completion time)") } - // 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 + // Now update status to trigger start (using the fresh object from Update) + if item.Object["status"] == nil { + item.Object["status"] = make(map[string]interface{}) } - // 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, - }, - }, - }, - }, - }, - } + status := item.Object["status"].(map[string]interface{}) + // 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) - // Create pod using backend SA (pod creation requires elevated permissions) - if K8sClient == nil { + // Update the status subresource using backend SA (status updates require elevated permissions) + if DynamicClient == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "backend not initialized"}) return } - created, err := K8sClient.CoreV1().Pods(project).Create(c.Request.Context(), pod, v1.CreateOptions{}) + updated, err := DynamicClient.Resource(gvr).Namespace(project).UpdateStatus(context.TODO(), item, v1.UpdateOptions{}) 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)}) + 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"}) 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")}, - }, - }, + // Parse and return updated session + session := types.AgenticSession{ + APIVersion: updated.GetAPIVersion(), + Kind: updated.GetKind(), + Metadata: updated.Object["metadata"].(map[string]interface{}), } - // Create service using backend SA - if _, err := K8sClient.CoreV1().Services(project).Create(c.Request.Context(), svc, v1.CreateOptions{}); err != nil && !errors.IsAlreadyExists(err) { - log.Printf("Failed to create temp service: %v", err) + if spec, ok := updated.Object["spec"].(map[string]interface{}); ok { + session.Spec = parseSpec(spec) } - c.JSON(http.StatusOK, gin.H{ - "status": "creating", - "podName": podName, - }) + if status, ok := updated.Object["status"].(map[string]interface{}); ok { + session.Status = parseStatus(status) + } + + c.JSON(http.StatusAccepted, session) } -// 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 +func StopSession(c *gin.Context) { project := c.GetString("project") - if project == "" { - project = c.Param("projectName") - } sessionName := c.Param("sessionName") + reqK8s, reqDyn := GetK8sClientsForRequest(c) + gvr := GetAgenticSessionV1Alpha1Resource() - 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{}) + // 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{"status": "not_found"}) + c.JSON(http.StatusNotFound, gin.H{"error": "Session not found"}) return } - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get pod"}) + log.Printf("Failed to get agentic session %s in project %s: %v", sessionName, project, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get agentic session"}) return } - ready := false - for _, cond := range pod.Status.Conditions { - if cond.Type == corev1.PodReady && cond.Status == corev1.ConditionTrue { - ready = true - break - } + // Check current status + status, ok := item.Object["status"].(map[string]interface{}) + if !ok { + status = make(map[string]interface{}) + item.Object["status"] = status } - 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") + currentPhase, _ := status["phase"].(string) + if currentPhase == "Completed" || currentPhase == "Failed" || currentPhase == "Stopped" { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Cannot stop session in %s state", currentPhase)}) + return } - sessionName := c.Param("sessionName") - reqK8s, _ := GetK8sClientsForRequest(c) - if reqK8s == nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) - return + log.Printf("Attempting to stop agentic session %s in project %s (current phase: %s)", sessionName, project, currentPhase) + + // Get job name from status + jobName, jobExists := status["jobName"].(string) + 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) } - podName := fmt.Sprintf("temp-content-%s", sessionName) - err := reqK8s.CoreV1().Pods(project).Delete(c.Request.Context(), podName, v1.DeleteOptions{}) + // 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("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) { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete pod"}) + 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 + status["phase"] = "Stopped" + status["message"] = "Session stopped by user" + status["completionTime"] = time.Now().Format(time.RFC3339) + + // 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 (using backend SA) + if DynamicClient == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "backend not initialized"}) + return + } + updated, err := DynamicClient.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 + log.Printf("Agentic session %s was deleted during stop operation", sessionName) + c.JSON(http.StatusOK, gin.H{"message": "Session no longer exists (already deleted)"}) + return + } + log.Printf("Failed to update agentic session status %s: %v", sessionName, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agentic session status"}) return } - c.JSON(http.StatusOK, gin.H{"message": "content pod deleted"}) + // Parse and return updated session + session := types.AgenticSession{ + APIVersion: updated.GetAPIVersion(), + Kind: updated.GetKind(), + Metadata: updated.Object["metadata"].(map[string]interface{}), + } + + if spec, ok := updated.Object["spec"].(map[string]interface{}); ok { + session.Spec = parseSpec(spec) + } + + if status, ok := updated.Object["status"].(map[string]interface{}); ok { + session.Status = parseStatus(status) + } + + log.Printf("Successfully stopped agentic session %s", sessionName) + c.JSON(http.StatusAccepted, session) } -// 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 +// UpdateSessionStatus writes selected fields to PVC-backed files and updates CR status. +// PUT /api/projects/:projectName/agentic-sessions/:sessionName/status +func UpdateSessionStatus(c *gin.Context) { project := c.GetString("project") - if project == "" { - project = c.Param("projectName") - } sessionName := c.Param("sessionName") + _, reqDyn := GetK8sClientsForRequest(c) - reqK8s, reqDyn := GetK8sClientsForRequest(c) - if reqK8s == nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + var statusUpdate map[string]interface{} + if err := c.ShouldBindJSON(&statusUpdate); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - // Get session to find job name gvr := GetAgenticSessionV1Alpha1Resource() - session, err := reqDyn.Resource(gvr).Namespace(project).Get(c.Request.Context(), sessionName, v1.GetOptions{}) + + // Get current resource + item, err := reqDyn.Resource(gvr).Namespace(project).Get(context.TODO(), sessionName, v1.GetOptions{}) if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "session not found"}) + if errors.IsNotFound(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "Session not found"}) + return + } + log.Printf("Failed to get agentic session %s in project %s: %v", sessionName, project, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get agentic session"}) return } - status, _ := session.Object["status"].(map[string]interface{}) - jobName, _ := status["jobName"].(string) - if jobName == "" { - jobName = fmt.Sprintf("%s-job", sessionName) + // Ensure status map + if item.Object["status"] == nil { + item.Object["status"] = make(map[string]interface{}) } + status := item.Object["status"].(map[string]interface{}) - 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" + // Accept standard fields and result summary fields from runner + allowed := map[string]struct{}{ + "phase": {}, "completionTime": {}, "cost": {}, "message": {}, + "subtype": {}, "duration_ms": {}, "duration_api_ms": {}, "is_error": {}, + "num_turns": {}, "session_id": {}, "total_cost_usd": {}, "usage": {}, "result": {}, + } + for k := range statusUpdate { + if _, ok := allowed[k]; !ok { + delete(statusUpdate, k) } - 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" - } + // Merge remaining fields into status + for k, v := range statusUpdate { + status[k] = v + } - 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, - }) - } - } + // Update only the status subresource using backend SA (status updates require elevated permissions) + if DynamicClient == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "backend not initialized"}) + return + } + if _, err := DynamicClient.Resource(gvr).Namespace(project).UpdateStatus(context.TODO(), item, v1.UpdateOptions{}); err != nil { + log.Printf("Failed to update agentic session status %s in project %s: %v", sessionName, project, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agentic session status"}) + return } - // 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" - } + c.JSON(http.StatusOK, gin.H{"message": "agentic session status updated"}) +} - containerInfos := []map[string]interface{}{} - for _, cs := range tempPod.Status.ContainerStatuses { - state := "Unknown" - var exitCode *int32 +// 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, + }, + }, + }, + }, + }, + } + + // Create pod using backend SA (pod creation requires elevated permissions) + if K8sClient == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "backend not initialized"}) + return + } + created, err := K8sClient.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")}, + }, + }, + } + + // Create service using backend SA + if _, err := K8sClient.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" @@ -2007,215 +2448,750 @@ func GetSessionK8sResources(c *gin.Context) { 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 + } 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{}) + specRepos, _ := spec["repos"].([]interface{}) + if repoIndex < 0 || repoIndex >= len(specRepos) { + return fmt.Errorf("repo index out of range") + } + 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) + } + } + 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 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) { + // 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" + if rel != "" { + absPath += "/" + rel + } + + // 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") + } + + // 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("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) + } + 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) { + // 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") + } + + // 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("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) != "" { + req.Header.Set("Authorization", token) + } + client := &http.Client{Timeout: 4 * time.Second} + resp, err := client.Do(req) + if err != nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()}) + return + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + c.Data(resp.StatusCode, resp.Header.Get("Content-Type"), b) +} + +// PutSessionWorkspaceFile writes a file via content service. +func PutSessionWorkspaceFile(c *gin.Context) { + // 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") + } + + // 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("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"` + Content string `json:"content"` + Encoding string `json:"encoding"` + }{Path: absPath, Content: string(payload), Encoding: "utf8"} + b, _ := json.Marshal(wreq) + req, _ := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, endpoint+"/content/write", strings.NewReader(string(b))) + if strings.TrimSpace(token) != "" { + req.Header.Set("Authorization", token) + } + req.Header.Set("Content-Type", "application/json") + client := &http.Client{Timeout: 4 * time.Second} + resp, err := client.Do(req) + if err != nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()}) + return + } + defer resp.Body.Close() + rb, _ := io.ReadAll(resp.Body) + c.Data(resp.StatusCode, resp.Header.Get("Content-Type"), rb) +} + +// PushSessionRepo proxies a push request for a given session repo to the per-job content service. +// POST /api/projects/:projectName/agentic-sessions/:sessionName/github/push +// Body: { repoIndex: number, commitMessage?: string, branch?: string } +func PushSessionRepo(c *gin.Context) { + project := c.Param("projectName") + session := c.Param("sessionName") + + var body struct { + RepoIndex int `json:"repoIndex"` + CommitMessage string `json:"commitMessage"` + } + if err := c.BindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON body"}) + return + } + log.Printf("pushSessionRepo: request project=%s session=%s repoIndex=%d commitLen=%d", project, session, body.RepoIndex, len(strings.TrimSpace(body.CommitMessage))) + + // 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("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 := "" + // default branch when not defined on output + resolvedBranch := fmt.Sprintf("sessions/%s", session) + resolvedOutputURL := "" + if _, reqDyn := GetK8sClientsForRequest(c); reqDyn != nil { + gvr := GetAgenticSessionV1Alpha1Resource() + obj, err := reqDyn.Resource(gvr).Namespace(project).Get(c.Request.Context(), session, v1.GetOptions{}) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read session"}) + return + } + spec, _ := obj.Object["spec"].(map[string]interface{}) + repos, _ := spec["repos"].([]interface{}) + if body.RepoIndex < 0 || body.RepoIndex >= len(repos) { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid repo index"}) + return + } + rm, _ := repos[body.RepoIndex].(map[string]interface{}) + // Derive repoPath from input URL folder name + if in, ok := rm["input"].(map[string]interface{}); ok { + if urlv, ok2 := in["url"].(string); ok2 && strings.TrimSpace(urlv) != "" { + folder := DeriveRepoFolderFromURL(strings.TrimSpace(urlv)) + if folder != "" { + resolvedRepoPath = fmt.Sprintf("/sessions/%s/workspace/%s", session, folder) + } } - 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, - }) + if out, ok := rm["output"].(map[string]interface{}); ok { + if urlv, ok2 := out["url"].(string); ok2 && strings.TrimSpace(urlv) != "" { + resolvedOutputURL = strings.TrimSpace(urlv) + } + if bs, ok2 := out["branch"].(string); ok2 && strings.TrimSpace(bs) != "" { + resolvedBranch = strings.TrimSpace(bs) + } else if bv, ok2 := out["branch"].(*string); ok2 && bv != nil && strings.TrimSpace(*bv) != "" { + resolvedBranch = strings.TrimSpace(*bv) + } + } + } else { + c.JSON(http.StatusBadRequest, gin.H{"error": "no dynamic client"}) + return + } + // If input URL missing or unparsable, fall back to numeric index path (last resort) + if strings.TrimSpace(resolvedRepoPath) == "" { + if body.RepoIndex >= 0 { + resolvedRepoPath = fmt.Sprintf("/sessions/%s/workspace/%d", session, body.RepoIndex) + } else { + resolvedRepoPath = fmt.Sprintf("/sessions/%s/workspace", session) + } + } + if strings.TrimSpace(resolvedOutputURL) == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "missing output repo url"}) + return } + log.Printf("pushSessionRepo: resolved repoPath=%q outputUrl=%q branch=%q", resolvedRepoPath, resolvedOutputURL, resolvedBranch) - result["pods"] = podInfos + payload := map[string]interface{}{ + "repoPath": resolvedRepoPath, + "commitMessage": body.CommitMessage, + "branch": resolvedBranch, + "outputRepoUrl": resolvedOutputURL, + } + b, _ := json.Marshal(payload) + req, _ := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, endpoint+"/content/github/push", strings.NewReader(string(b))) + if v := c.GetHeader("Authorization"); v != "" { + req.Header.Set("Authorization", v) + } + if v := c.GetHeader("X-Forwarded-Access-Token"); v != "" { + req.Header.Set("X-Forwarded-Access-Token", v) + } + req.Header.Set("Content-Type", "application/json") - // 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() + // Attach short-lived GitHub token for one-shot authenticated push + if reqK8s, reqDyn := GetK8sClientsForRequest(c); reqK8s != nil { + // Load session to get authoritative userId + gvr := GetAgenticSessionV1Alpha1Resource() + obj, err := reqDyn.Resource(gvr).Namespace(project).Get(c.Request.Context(), session, v1.GetOptions{}) + if err == nil { + spec, _ := obj.Object["spec"].(map[string]interface{}) + userID := "" + if spec != nil { + if uc, ok := spec["userContext"].(map[string]interface{}); ok { + if v, ok := uc["userId"].(string); ok { + userID = strings.TrimSpace(v) + } + } + } + if userID != "" { + if tokenStr, err := GetGitHubToken(c.Request.Context(), reqK8s, reqDyn, project, userID); err == nil && strings.TrimSpace(tokenStr) != "" { + req.Header.Set("X-GitHub-Token", tokenStr) + log.Printf("pushSessionRepo: attached short-lived GitHub token for project=%s session=%s", project, session) + } else if err != nil { + log.Printf("pushSessionRepo: failed to resolve GitHub token: %v", err) + } + } else { + log.Printf("pushSessionRepo: session %s/%s missing userContext.userId; proceeding without token", project, session) + } + } else { + log.Printf("pushSessionRepo: failed to read session for token attach: %v", err) } - } else { - result["pvcExists"] = false } - c.JSON(http.StatusOK, result) + log.Printf("pushSessionRepo: proxy push project=%s session=%s repoIndex=%d repoPath=%s endpoint=%s", project, session, body.RepoIndex, resolvedRepoPath, endpoint+"/content/github/push") + resp, err := http.DefaultClient.Do(req) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + defer resp.Body.Close() + bodyBytes, _ := io.ReadAll(resp.Body) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + log.Printf("pushSessionRepo: content returned status=%d body.snip=%q", resp.StatusCode, func() string { + s := string(bodyBytes) + if len(s) > 1500 { + return s[:1500] + "..." + } + return s + }()) + c.Data(resp.StatusCode, "application/json", bodyBytes) + return + } + if DynamicClient != nil { + log.Printf("pushSessionRepo: setting repo status to 'pushed' for repoIndex=%d", body.RepoIndex) + if err := setRepoStatus(DynamicClient, project, session, body.RepoIndex, "pushed"); err != nil { + log.Printf("pushSessionRepo: setRepoStatus failed project=%s session=%s repoIndex=%d err=%v", project, session, body.RepoIndex, err) + } + } else { + log.Printf("pushSessionRepo: backend SA not available; cannot set repo status project=%s session=%s", project, session) + } + log.Printf("pushSessionRepo: content push succeeded status=%d body.len=%d", resp.StatusCode, len(bodyBytes)) + c.Data(http.StatusOK, "application/json", bodyBytes) } -// 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{}) +// AbandonSessionRepo instructs sidecar to discard local changes for a repo. +func AbandonSessionRepo(c *gin.Context) { + project := c.Param("projectName") + session := c.Param("sessionName") + var body struct { + RepoIndex int `json:"repoIndex"` + RepoPath string `json:"repoPath"` + } + if err := c.BindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON body"}) + return + } + + // 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("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 { + repoPath = fmt.Sprintf("/sessions/%s/workspace/%d", session, body.RepoIndex) + } else { + repoPath = fmt.Sprintf("/sessions/%s/workspace", session) + } + } + payload := map[string]interface{}{ + "repoPath": repoPath, + } + b, _ := json.Marshal(payload) + req, _ := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, endpoint+"/content/github/abandon", strings.NewReader(string(b))) + if v := c.GetHeader("Authorization"); v != "" { + req.Header.Set("Authorization", v) + } + if v := c.GetHeader("X-Forwarded-Access-Token"); v != "" { + req.Header.Set("X-Forwarded-Access-Token", v) + } + req.Header.Set("Content-Type", "application/json") + log.Printf("abandonSessionRepo: proxy abandon project=%s session=%s repoIndex=%d repoPath=%s", project, session, body.RepoIndex, repoPath) + resp, err := http.DefaultClient.Do(req) if err != nil { - return err + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + defer resp.Body.Close() + bodyBytes, _ := io.ReadAll(resp.Body) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + log.Printf("abandonSessionRepo: content returned status=%d body=%s", resp.StatusCode, string(bodyBytes)) + c.Data(resp.StatusCode, "application/json", bodyBytes) + return + } + if DynamicClient != nil { + if err := setRepoStatus(DynamicClient, project, session, body.RepoIndex, "abandoned"); err != nil { + log.Printf("abandonSessionRepo: setRepoStatus failed project=%s session=%s repoIndex=%d err=%v", project, session, body.RepoIndex, err) + } + } else { + log.Printf("abandonSessionRepo: backend SA not available; cannot set repo status project=%s session=%s", project, session) } + c.Data(http.StatusOK, "application/json", bodyBytes) +} - // Get repo name from spec.repos[repoIndex] - spec, _ := item.Object["spec"].(map[string]interface{}) - specRepos, _ := spec["repos"].([]interface{}) - if repoIndex < 0 || repoIndex >= len(specRepos) { - return fmt.Errorf("repo index out of range") +// DiffSessionRepo proxies diff counts for a given session repo to the content sidecar. +// GET /api/projects/:projectName/agentic-sessions/:sessionName/github/diff?repoIndex=0&repoPath=... +func DiffSessionRepo(c *gin.Context) { + project := c.Param("projectName") + session := c.Param("sessionName") + repoIndexStr := strings.TrimSpace(c.Query("repoIndex")) + repoPath := strings.TrimSpace(c.Query("repoPath")) + if repoPath == "" && repoIndexStr != "" { + repoPath = fmt.Sprintf("/sessions/%s/workspace/%s", session, repoIndexStr) } - 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) + if repoPath == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "missing repoPath/repoIndex"}) + return + } + + // 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) } - if repoName == "" { - repoName = fmt.Sprintf("repo-%d", repoIndex) + 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 != "" { + req.Header.Set("Authorization", v) } - - // Ensure status.repos exists - if item.Object["status"] == nil { - item.Object["status"] = make(map[string]interface{}) + if v := c.GetHeader("X-Forwarded-Access-Token"); v != "" { + req.Header.Set("X-Forwarded-Access-Token", v) } - status := item.Object["status"].(map[string]interface{}) - statusRepos, _ := status["repos"].([]interface{}) - if statusRepos == nil { - statusRepos = []interface{}{} + resp, err := http.DefaultClient.Do(req) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "files": gin.H{ + "added": 0, + "removed": 0, + }, + "total_added": 0, + "total_removed": 0, + }) + return } + defer resp.Body.Close() + bodyBytes, _ := io.ReadAll(resp.Body) + c.Data(resp.StatusCode, resp.Header.Get("Content-Type"), bodyBytes) +} - // Find or create status entry for this repo - repoStatus := map[string]interface{}{ - "name": repoName, - "status": newStatus, - "last_updated": time.Now().Format(time.RFC3339), +// GetGitStatus returns git status for a directory in the workspace +// GET /api/projects/:projectName/agentic-sessions/:sessionName/git/status?path=artifacts +func GetGitStatus(c *gin.Context) { + project := c.Param("projectName") + session := c.Param("sessionName") + relativePath := strings.TrimSpace(c.Query("path")) + + if relativePath == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "path parameter required"}) + return } - // 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 - } + // Build absolute path + absPath := fmt.Sprintf("/sessions/%s/workspace/%s", session, relativePath) + + // Get content service endpoint + 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) } - } - if !found { - statusRepos = append(statusRepos, repoStatus) + } else { + serviceName = fmt.Sprintf("ambient-content-%s", session) } - status["repos"] = statusRepos - item.Object["status"] = status + endpoint := fmt.Sprintf("http://%s.%s.svc:8080/content/git-status?path=%s", serviceName, project, url.QueryEscape(absPath)) - 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 + req, _ := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, endpoint, nil) + if v := c.GetHeader("Authorization"); v != "" { + req.Header.Set("Authorization", v) } - if updated != nil { - log.Printf("setRepoStatus: update ok project=%s session=%s repo=%s status=%s", project, sessionName, repoName, newStatus) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "content service unavailable"}) + return } - return nil + defer resp.Body.Close() + + bodyBytes, _ := io.ReadAll(resp.Body) + c.Data(resp.StatusCode, resp.Header.Get("Content-Type"), bodyBytes) } -// ListSessionWorkspace proxies to per-job content service for directory listing. -func ListSessionWorkspace(c *gin.Context) { - // Get project from context (set by middleware) or param - project := c.GetString("project") - if project == "" { - project = c.Param("projectName") +// ConfigureGitRemote initializes git and configures remote for a workspace directory +// Body: { path: string, remoteURL: string, branch: string } +// POST /api/projects/:projectName/agentic-sessions/:sessionName/git/configure-remote +func ConfigureGitRemote(c *gin.Context) { + project := c.Param("projectName") + sessionName := c.Param("sessionName") + _, reqDyn := GetK8sClientsForRequest(c) + + var body struct { + Path string `json:"path" binding:"required"` + RemoteURL string `json:"remoteUrl" binding:"required"` + Branch string `json:"branch"` } - 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"}) + if err := c.BindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) 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" - if rel != "" { - absPath += "/" + rel + if body.Branch == "" { + body.Branch = "main" } - // 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") - } + // Build absolute path + absPath := fmt.Sprintf("/sessions/%s/workspace/%s", sessionName, body.Path) - // Try temp service first (for completed sessions), then regular service - serviceName := fmt.Sprintf("temp-content-%s", session) + // Get content service endpoint + serviceName := fmt.Sprintf("temp-content-%s", sessionName) 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) + serviceName = fmt.Sprintf("ambient-content-%s", sessionName) } } else { - serviceName = fmt.Sprintf("ambient-content-%s", session) + serviceName = fmt.Sprintf("ambient-content-%s", sessionName) } - 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) + endpoint := fmt.Sprintf("http://%s.%s.svc:8080/content/git-configure-remote", serviceName, project) + + reqBody, _ := json.Marshal(map[string]interface{}{ + "path": absPath, + "remoteUrl": body.RemoteURL, + "branch": body.Branch, + }) + + req, _ := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, endpoint, strings.NewReader(string(reqBody))) + req.Header.Set("Content-Type", "application/json") + if v := c.GetHeader("Authorization"); v != "" { + req.Header.Set("Authorization", v) } - client := &http.Client{Timeout: 4 * time.Second} - resp, err := client.Do(req) + + // Get and forward GitHub token for authenticated remote URL + if reqK8s != nil && reqDyn != nil && GetGitHubToken != nil { + if token, err := GetGitHubToken(c.Request.Context(), reqK8s, reqDyn, project, ""); err == nil && token != "" { + req.Header.Set("X-GitHub-Token", token) + log.Printf("Forwarding GitHub token for remote configuration") + } + } + + resp, err := http.DefaultClient.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{}}) + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "content service unavailable"}) 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 + // If successful, persist remote config to session annotations for persistence + if resp.StatusCode == http.StatusOK { + // Persist remote config in annotations (supports multiple directories) + gvr := GetAgenticSessionV1Alpha1Resource() + item, err := reqDyn.Resource(gvr).Namespace(project).Get(c.Request.Context(), sessionName, v1.GetOptions{}) + if err == nil { + metadata := item.Object["metadata"].(map[string]interface{}) + if metadata["annotations"] == nil { + metadata["annotations"] = make(map[string]interface{}) + } + anns := metadata["annotations"].(map[string]interface{}) + + // Derive safe annotation key from path (use :: as separator to avoid conflicts with hyphens in path) + annotationKey := strings.ReplaceAll(body.Path, "/", "::") + anns[fmt.Sprintf("ambient-code.io/remote-%s-url", annotationKey)] = body.RemoteURL + anns[fmt.Sprintf("ambient-code.io/remote-%s-branch", annotationKey)] = body.Branch + + _, err = reqDyn.Resource(gvr).Namespace(project).Update(c.Request.Context(), item, v1.UpdateOptions{}) + if err != nil { + log.Printf("Warning: Failed to persist remote config to annotations: %v", err) + } else { + log.Printf("Persisted remote config for %s to session annotations: %s@%s", body.Path, body.RemoteURL, body.Branch) + } + } } - c.Data(resp.StatusCode, resp.Header.Get("Content-Type"), b) + bodyBytes, _ := io.ReadAll(resp.Body) + c.Data(resp.StatusCode, resp.Header.Get("Content-Type"), bodyBytes) } -// GetSessionWorkspaceFile reads a file via content service. -func GetSessionWorkspaceFile(c *gin.Context) { - // Get project from context (set by middleware) or param - project := c.GetString("project") - if project == "" { - project = c.Param("projectName") - } +// SynchronizeGit commits, pulls, and pushes changes for a workspace directory +// Body: { path: string, message?: string, branch?: string } +// POST /api/projects/:projectName/agentic-sessions/:sessionName/git/synchronize +func SynchronizeGit(c *gin.Context) { + 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"}) + var body struct { + Path string `json:"path" binding:"required"` + Message string `json:"message"` + Branch string `json:"branch"` + } + + if err := c.BindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) 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") + // Auto-generate commit message if not provided + if body.Message == "" { + body.Message = fmt.Sprintf("Session %s - %s", session, time.Now().Format(time.RFC3339)) } - // Try temp service first (for completed sessions), then regular service + // Build absolute path + absPath := fmt.Sprintf("/sessions/%s/workspace/%s", session, body.Path) + + // Get content service endpoint serviceName := fmt.Sprintf("temp-content-%s", session) reqK8s, _ := GetK8sClientsForRequest(c) if reqK8s != nil { @@ -2226,99 +3202,102 @@ func GetSessionWorkspaceFile(c *gin.Context) { serviceName = fmt.Sprintf("ambient-content-%s", session) } - 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) != "" { - req.Header.Set("Authorization", token) + endpoint := fmt.Sprintf("http://%s.%s.svc:8080/content/git-sync", serviceName, project) + + reqBody, _ := json.Marshal(map[string]interface{}{ + "path": absPath, + "message": body.Message, + "branch": body.Branch, + }) + + req, _ := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, endpoint, strings.NewReader(string(reqBody))) + req.Header.Set("Content-Type", "application/json") + if v := c.GetHeader("Authorization"); v != "" { + req.Header.Set("Authorization", v) } - client := &http.Client{Timeout: 4 * time.Second} - resp, err := client.Do(req) + + resp, err := http.DefaultClient.Do(req) if err != nil { - c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()}) + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "content service unavailable"}) return } defer resp.Body.Close() - b, _ := io.ReadAll(resp.Body) - c.Data(resp.StatusCode, resp.Header.Get("Content-Type"), b) + + bodyBytes, _ := io.ReadAll(resp.Body) + c.Data(resp.StatusCode, resp.Header.Get("Content-Type"), bodyBytes) } -// PutSessionWorkspaceFile writes a file via content service. -func PutSessionWorkspaceFile(c *gin.Context) { - // Get project from context (set by middleware) or param - project := c.GetString("project") - if project == "" { - project = c.Param("projectName") - } +// GetGitMergeStatus checks if local and remote can merge cleanly +// GET /api/projects/:projectName/agentic-sessions/:sessionName/git/merge-status?path=&branch= +func GetGitMergeStatus(c *gin.Context) { + project := c.Param("projectName") session := c.Param("sessionName") + relativePath := strings.TrimSpace(c.Query("path")) + branch := strings.TrimSpace(c.Query("branch")) - if project == "" { - log.Printf("PutSessionWorkspaceFile: project is empty, session=%s", session) - c.JSON(http.StatusBadRequest, gin.H{"error": "Project namespace required"}) - return + if relativePath == "" { + relativePath = "artifacts" } - 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") + if branch == "" { + branch = "main" } - // Try temp service first (for completed sessions), then regular service + absPath := fmt.Sprintf("/sessions/%s/workspace/%s", session, relativePath) + 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("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"` - Content string `json:"content"` - Encoding string `json:"encoding"` - }{Path: absPath, Content: string(payload), Encoding: "utf8"} - b, _ := json.Marshal(wreq) - req, _ := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, endpoint+"/content/write", strings.NewReader(string(b))) - if strings.TrimSpace(token) != "" { - req.Header.Set("Authorization", token) + endpoint := fmt.Sprintf("http://%s.%s.svc:8080/content/git-merge-status?path=%s&branch=%s", + serviceName, project, url.QueryEscape(absPath), url.QueryEscape(branch)) + + req, _ := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, endpoint, nil) + if v := c.GetHeader("Authorization"); v != "" { + req.Header.Set("Authorization", v) } - req.Header.Set("Content-Type", "application/json") - client := &http.Client{Timeout: 4 * time.Second} - resp, err := client.Do(req) + + resp, err := http.DefaultClient.Do(req) if err != nil { - c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()}) + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "content service unavailable"}) return } defer resp.Body.Close() - rb, _ := io.ReadAll(resp.Body) - c.Data(resp.StatusCode, resp.Header.Get("Content-Type"), rb) + + bodyBytes, _ := io.ReadAll(resp.Body) + c.Data(resp.StatusCode, resp.Header.Get("Content-Type"), bodyBytes) } -// PushSessionRepo proxies a push request for a given session repo to the per-job content service. -// POST /api/projects/:projectName/agentic-sessions/:sessionName/github/push -// Body: { repoIndex: number, commitMessage?: string, branch?: string } -func PushSessionRepo(c *gin.Context) { +// GitPullSession pulls changes from remote +// POST /api/projects/:projectName/agentic-sessions/:sessionName/git/pull +func GitPullSession(c *gin.Context) { project := c.Param("projectName") session := c.Param("sessionName") var body struct { - RepoIndex int `json:"repoIndex"` - CommitMessage string `json:"commitMessage"` + Path string `json:"path"` + Branch string `json:"branch"` } + if err := c.BindJSON(&body); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON body"}) + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) return } - log.Printf("pushSessionRepo: request project=%s session=%s repoIndex=%d commitLen=%d", project, session, body.RepoIndex, len(strings.TrimSpace(body.CommitMessage))) - // Try temp service first (for completed sessions), then regular service + if body.Path == "" { + body.Path = "artifacts" + } + if body.Branch == "" { + body.Branch = "main" + } + + absPath := fmt.Sprintf("/sessions/%s/workspace/%s", session, body.Path) + serviceName := fmt.Sprintf("temp-content-%s", session) reqK8s, _ := GetK8sClientsForRequest(c) if reqK8s != nil { @@ -2328,156 +3307,117 @@ func PushSessionRepo(c *gin.Context) { } else { serviceName = fmt.Sprintf("ambient-content-%s", session) } - 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 := "" - // default branch when not defined on output - resolvedBranch := fmt.Sprintf("sessions/%s", session) - resolvedOutputURL := "" - if _, reqDyn := GetK8sClientsForRequest(c); reqDyn != nil { - gvr := GetAgenticSessionV1Alpha1Resource() - obj, err := reqDyn.Resource(gvr).Namespace(project).Get(c.Request.Context(), session, v1.GetOptions{}) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read session"}) - return - } - spec, _ := obj.Object["spec"].(map[string]interface{}) - repos, _ := spec["repos"].([]interface{}) - if body.RepoIndex < 0 || body.RepoIndex >= len(repos) { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid repo index"}) - return - } - rm, _ := repos[body.RepoIndex].(map[string]interface{}) - // Derive repoPath from input URL folder name - if in, ok := rm["input"].(map[string]interface{}); ok { - if urlv, ok2 := in["url"].(string); ok2 && strings.TrimSpace(urlv) != "" { - folder := DeriveRepoFolderFromURL(strings.TrimSpace(urlv)) - if folder != "" { - resolvedRepoPath = fmt.Sprintf("/sessions/%s/workspace/%s", session, folder) - } - } - } - if out, ok := rm["output"].(map[string]interface{}); ok { - if urlv, ok2 := out["url"].(string); ok2 && strings.TrimSpace(urlv) != "" { - resolvedOutputURL = strings.TrimSpace(urlv) - } - if bs, ok2 := out["branch"].(string); ok2 && strings.TrimSpace(bs) != "" { - resolvedBranch = strings.TrimSpace(bs) - } else if bv, ok2 := out["branch"].(*string); ok2 && bv != nil && strings.TrimSpace(*bv) != "" { - resolvedBranch = strings.TrimSpace(*bv) - } - } - } else { - c.JSON(http.StatusBadRequest, gin.H{"error": "no dynamic client"}) + endpoint := fmt.Sprintf("http://%s.%s.svc:8080/content/git-pull", serviceName, project) + + reqBody, _ := json.Marshal(map[string]interface{}{ + "path": absPath, + "branch": body.Branch, + }) + + req, _ := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, endpoint, strings.NewReader(string(reqBody))) + req.Header.Set("Content-Type", "application/json") + if v := c.GetHeader("Authorization"); v != "" { + req.Header.Set("Authorization", v) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "content service unavailable"}) return } - // If input URL missing or unparsable, fall back to numeric index path (last resort) - if strings.TrimSpace(resolvedRepoPath) == "" { - if body.RepoIndex >= 0 { - resolvedRepoPath = fmt.Sprintf("/sessions/%s/workspace/%d", session, body.RepoIndex) - } else { - resolvedRepoPath = fmt.Sprintf("/sessions/%s/workspace", session) - } + defer resp.Body.Close() + + bodyBytes, _ := io.ReadAll(resp.Body) + c.Data(resp.StatusCode, resp.Header.Get("Content-Type"), bodyBytes) +} + +// GitPushSession pushes changes to remote branch +// POST /api/projects/:projectName/agentic-sessions/:sessionName/git/push +func GitPushSession(c *gin.Context) { + project := c.Param("projectName") + session := c.Param("sessionName") + + var body struct { + Path string `json:"path"` + Branch string `json:"branch"` + Message string `json:"message"` } - if strings.TrimSpace(resolvedOutputURL) == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "missing output repo url"}) + + if err := c.BindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) return } - log.Printf("pushSessionRepo: resolved repoPath=%q outputUrl=%q branch=%q", resolvedRepoPath, resolvedOutputURL, resolvedBranch) - payload := map[string]interface{}{ - "repoPath": resolvedRepoPath, - "commitMessage": body.CommitMessage, - "branch": resolvedBranch, - "outputRepoUrl": resolvedOutputURL, + if body.Path == "" { + body.Path = "artifacts" } - b, _ := json.Marshal(payload) - req, _ := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, endpoint+"/content/github/push", strings.NewReader(string(b))) - if v := c.GetHeader("Authorization"); v != "" { - req.Header.Set("Authorization", v) + if body.Branch == "" { + body.Branch = "main" } - if v := c.GetHeader("X-Forwarded-Access-Token"); v != "" { - req.Header.Set("X-Forwarded-Access-Token", v) + if body.Message == "" { + body.Message = fmt.Sprintf("Session %s artifacts", session) } - req.Header.Set("Content-Type", "application/json") - // Attach short-lived GitHub token for one-shot authenticated push - if reqK8s, reqDyn := GetK8sClientsForRequest(c); reqK8s != nil { - // Load session to get authoritative userId - gvr := GetAgenticSessionV1Alpha1Resource() - obj, err := reqDyn.Resource(gvr).Namespace(project).Get(c.Request.Context(), session, v1.GetOptions{}) - if err == nil { - spec, _ := obj.Object["spec"].(map[string]interface{}) - userID := "" - if spec != nil { - if uc, ok := spec["userContext"].(map[string]interface{}); ok { - if v, ok := uc["userId"].(string); ok { - userID = strings.TrimSpace(v) - } - } - } - if userID != "" { - if tokenStr, err := GetGitHubToken(c.Request.Context(), reqK8s, reqDyn, project, userID); err == nil && strings.TrimSpace(tokenStr) != "" { - req.Header.Set("X-GitHub-Token", tokenStr) - log.Printf("pushSessionRepo: attached short-lived GitHub token for project=%s session=%s", project, session) - } else if err != nil { - log.Printf("pushSessionRepo: failed to resolve GitHub token: %v", err) - } - } else { - log.Printf("pushSessionRepo: session %s/%s missing userContext.userId; proceeding without token", project, session) - } - } else { - log.Printf("pushSessionRepo: failed to read session for token attach: %v", err) + absPath := fmt.Sprintf("/sessions/%s/workspace/%s", session, body.Path) + + 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("http://%s.%s.svc:8080/content/git-push", serviceName, project) + + reqBody, _ := json.Marshal(map[string]interface{}{ + "path": absPath, + "branch": body.Branch, + "message": body.Message, + }) + + req, _ := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, endpoint, strings.NewReader(string(reqBody))) + req.Header.Set("Content-Type", "application/json") + if v := c.GetHeader("Authorization"); v != "" { + req.Header.Set("Authorization", v) } - log.Printf("pushSessionRepo: proxy push project=%s session=%s repoIndex=%d repoPath=%s endpoint=%s", project, session, body.RepoIndex, resolvedRepoPath, endpoint+"/content/github/push") resp, err := http.DefaultClient.Do(req) if err != nil { - c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "content service unavailable"}) return } defer resp.Body.Close() + bodyBytes, _ := io.ReadAll(resp.Body) - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - log.Printf("pushSessionRepo: content returned status=%d body.snip=%q", resp.StatusCode, func() string { - s := string(bodyBytes) - if len(s) > 1500 { - return s[:1500] + "..." - } - return s - }()) - c.Data(resp.StatusCode, "application/json", bodyBytes) - return - } - if DynamicClient != nil { - log.Printf("pushSessionRepo: setting repo status to 'pushed' for repoIndex=%d", body.RepoIndex) - if err := setRepoStatus(DynamicClient, project, session, body.RepoIndex, "pushed"); err != nil { - log.Printf("pushSessionRepo: setRepoStatus failed project=%s session=%s repoIndex=%d err=%v", project, session, body.RepoIndex, err) - } - } else { - log.Printf("pushSessionRepo: backend SA not available; cannot set repo status project=%s session=%s", project, session) - } - log.Printf("pushSessionRepo: content push succeeded status=%d body.len=%d", resp.StatusCode, len(bodyBytes)) - c.Data(http.StatusOK, "application/json", bodyBytes) + c.Data(resp.StatusCode, resp.Header.Get("Content-Type"), bodyBytes) } -// AbandonSessionRepo instructs sidecar to discard local changes for a repo. -func AbandonSessionRepo(c *gin.Context) { +// GitCreateBranchSession creates a new git branch +// POST /api/projects/:projectName/agentic-sessions/:sessionName/git/create-branch +func GitCreateBranchSession(c *gin.Context) { project := c.Param("projectName") session := c.Param("sessionName") + var body struct { - RepoIndex int `json:"repoIndex"` - RepoPath string `json:"repoPath"` + Path string `json:"path"` + BranchName string `json:"branchName" binding:"required"` } + if err := c.BindJSON(&body); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON body"}) + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) return } - // Try temp service first (for completed sessions), then regular service + if body.Path == "" { + body.Path = "artifacts" + } + + absPath := fmt.Sprintf("/sessions/%s/workspace/%s", session, body.Path) + serviceName := fmt.Sprintf("temp-content-%s", session) reqK8s, _ := GetK8sClientsForRequest(c) if reqK8s != nil { @@ -2487,67 +3427,44 @@ func AbandonSessionRepo(c *gin.Context) { } else { serviceName = fmt.Sprintf("ambient-content-%s", session) } - 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 { - repoPath = fmt.Sprintf("/sessions/%s/workspace/%d", session, body.RepoIndex) - } else { - repoPath = fmt.Sprintf("/sessions/%s/workspace", session) - } - } - payload := map[string]interface{}{ - "repoPath": repoPath, - } - b, _ := json.Marshal(payload) - req, _ := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, endpoint+"/content/github/abandon", strings.NewReader(string(b))) + + endpoint := fmt.Sprintf("http://%s.%s.svc:8080/content/git-create-branch", serviceName, project) + + reqBody, _ := json.Marshal(map[string]interface{}{ + "path": absPath, + "branchName": body.BranchName, + }) + + req, _ := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, endpoint, strings.NewReader(string(reqBody))) + req.Header.Set("Content-Type", "application/json") if v := c.GetHeader("Authorization"); v != "" { req.Header.Set("Authorization", v) } - if v := c.GetHeader("X-Forwarded-Access-Token"); v != "" { - req.Header.Set("X-Forwarded-Access-Token", v) - } - req.Header.Set("Content-Type", "application/json") - log.Printf("abandonSessionRepo: proxy abandon project=%s session=%s repoIndex=%d repoPath=%s", project, session, body.RepoIndex, repoPath) + resp, err := http.DefaultClient.Do(req) if err != nil { - c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "content service unavailable"}) return } defer resp.Body.Close() + bodyBytes, _ := io.ReadAll(resp.Body) - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - log.Printf("abandonSessionRepo: content returned status=%d body=%s", resp.StatusCode, string(bodyBytes)) - c.Data(resp.StatusCode, "application/json", bodyBytes) - return - } - if DynamicClient != nil { - if err := setRepoStatus(DynamicClient, project, session, body.RepoIndex, "abandoned"); err != nil { - log.Printf("abandonSessionRepo: setRepoStatus failed project=%s session=%s repoIndex=%d err=%v", project, session, body.RepoIndex, err) - } - } else { - log.Printf("abandonSessionRepo: backend SA not available; cannot set repo status project=%s session=%s", project, session) - } - c.Data(http.StatusOK, "application/json", bodyBytes) + c.Data(resp.StatusCode, resp.Header.Get("Content-Type"), bodyBytes) } -// DiffSessionRepo proxies diff counts for a given session repo to the content sidecar. -// GET /api/projects/:projectName/agentic-sessions/:sessionName/github/diff?repoIndex=0&repoPath=... -func DiffSessionRepo(c *gin.Context) { +// GitListBranchesSession lists all remote branches +// GET /api/projects/:projectName/agentic-sessions/:sessionName/git/list-branches?path= +func GitListBranchesSession(c *gin.Context) { project := c.Param("projectName") session := c.Param("sessionName") - repoIndexStr := strings.TrimSpace(c.Query("repoIndex")) - repoPath := strings.TrimSpace(c.Query("repoPath")) - if repoPath == "" && repoIndexStr != "" { - repoPath = fmt.Sprintf("/sessions/%s/workspace/%s", session, repoIndexStr) - } - if repoPath == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "missing repoPath/repoIndex"}) - return + relativePath := strings.TrimSpace(c.Query("path")) + + if relativePath == "" { + relativePath = "artifacts" } - // Try temp service first (for completed sessions), then regular service + absPath := fmt.Sprintf("/sessions/%s/workspace/%s", session, relativePath) + serviceName := fmt.Sprintf("temp-content-%s", session) reqK8s, _ := GetK8sClientsForRequest(c) if reqK8s != nil { @@ -2557,29 +3474,22 @@ func DiffSessionRepo(c *gin.Context) { } else { serviceName = fmt.Sprintf("ambient-content-%s", session) } - 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) + + endpoint := fmt.Sprintf("http://%s.%s.svc:8080/content/git-list-branches?path=%s", + serviceName, project, url.QueryEscape(absPath)) + + req, _ := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, endpoint, nil) if v := c.GetHeader("Authorization"); v != "" { req.Header.Set("Authorization", v) } - if v := c.GetHeader("X-Forwarded-Access-Token"); v != "" { - req.Header.Set("X-Forwarded-Access-Token", v) - } + resp, err := http.DefaultClient.Do(req) if err != nil { - c.JSON(http.StatusOK, gin.H{ - "files": gin.H{ - "added": 0, - "removed": 0, - }, - "total_added": 0, - "total_removed": 0, - }) + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "content service unavailable"}) return } defer resp.Body.Close() + bodyBytes, _ := io.ReadAll(resp.Body) c.Data(resp.StatusCode, resp.Header.Get("Content-Type"), bodyBytes) } diff --git a/components/backend/jira/integration.go b/components/backend/jira/integration.go index 74bc32cac..8c77991ee 100644 --- a/components/backend/jira/integration.go +++ b/components/backend/jira/integration.go @@ -1,6 +1,11 @@ -// Package jira provides JIRA integration for publishing RFE workflows. +// Package jira provides JIRA integration (currently disabled - was RFE-specific). +// Kept for potential future use. package jira +/* +// This package was RFE-specific and has been commented out. +// Uncomment and refactor when adding Jira support for sessions or other features. + import ( "bytes" "context" @@ -16,7 +21,6 @@ import ( "ambient-code-backend/git" "ambient-code-backend/handlers" - "ambient-code-backend/types" "github.com/gin-gonic/gin" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -33,558 +37,6 @@ type Handler struct { GetRFEWorkflowResource func() schema.GroupVersionResource } -// RFEFromUnstructured converts an unstructured RFEWorkflow CR into our RFEWorkflow struct -func RFEFromUnstructured(item *unstructured.Unstructured) *types.RFEWorkflow { - if item == nil { - return nil - } - obj := item.Object - spec, _ := obj["spec"].(map[string]interface{}) - - created := "" - if item.GetCreationTimestamp().Time != (time.Time{}) { - created = item.GetCreationTimestamp().Time.UTC().Format(time.RFC3339) - } - - // Extract branchName safely - avoid converting nil to "" string - branchName := "" - if bn, ok := spec["branchName"].(string); ok && strings.TrimSpace(bn) != "" { - branchName = strings.TrimSpace(bn) - } - - wf := &types.RFEWorkflow{ - ID: item.GetName(), - Title: fmt.Sprintf("%v", spec["title"]), - Description: fmt.Sprintf("%v", spec["description"]), - BranchName: branchName, - Project: item.GetNamespace(), - WorkspacePath: fmt.Sprintf("%v", spec["workspacePath"]), - CreatedAt: created, - UpdatedAt: time.Now().UTC().Format(time.RFC3339), - } - - // Parse umbrellaRepo/supportingRepos when present; fallback to repositories - if um, ok := spec["umbrellaRepo"].(map[string]interface{}); ok { - repo := types.GitRepository{} - if u, ok := um["url"].(string); ok { - repo.URL = u - } - if b, ok := um["branch"].(string); ok && strings.TrimSpace(b) != "" { - repo.Branch = handlers.StringPtr(b) - } - wf.UmbrellaRepo = &repo - } - if srs, ok := spec["supportingRepos"].([]interface{}); ok { - wf.SupportingRepos = make([]types.GitRepository, 0, len(srs)) - for _, r := range srs { - if rm, ok := r.(map[string]interface{}); ok { - repo := types.GitRepository{} - if u, ok := rm["url"].(string); ok { - repo.URL = u - } - if b, ok := rm["branch"].(string); ok && strings.TrimSpace(b) != "" { - repo.Branch = handlers.StringPtr(b) - } - wf.SupportingRepos = append(wf.SupportingRepos, repo) - } - } - } else if repos, ok := spec["repositories"].([]interface{}); ok { - // Backward compatibility: map legacy repositories -> umbrellaRepo (first) + supportingRepos (rest) - for i, r := range repos { - if rm, ok := r.(map[string]interface{}); ok { - repo := types.GitRepository{} - if u, ok := rm["url"].(string); ok { - repo.URL = u - } - if b, ok := rm["branch"].(string); ok && strings.TrimSpace(b) != "" { - repo.Branch = handlers.StringPtr(b) - } - if i == 0 { - rcopy := repo - wf.UmbrellaRepo = &rcopy - } else { - wf.SupportingRepos = append(wf.SupportingRepos, repo) - } - } - } - } - - // Parse jiraLinks - if links, ok := spec["jiraLinks"].([]interface{}); ok { - for _, it := range links { - if m, ok := it.(map[string]interface{}); ok { - path := fmt.Sprintf("%v", m["path"]) - jiraKey := fmt.Sprintf("%v", m["jiraKey"]) - if strings.TrimSpace(path) != "" && strings.TrimSpace(jiraKey) != "" { - wf.JiraLinks = append(wf.JiraLinks, types.WorkflowJiraLink{Path: path, JiraKey: jiraKey}) - } - } - } - } - - // Parse parentOutcome - if po, ok := spec["parentOutcome"].(string); ok && strings.TrimSpace(po) != "" { - wf.ParentOutcome = handlers.StringPtr(strings.TrimSpace(po)) - } - - return wf -} - -// ExtractTitleFromContent attempts to extract a title from markdown content -// by looking for the first # heading -func ExtractTitleFromContent(content string) string { - lines := strings.Split(content, "\n") - for _, line := range lines { - line = strings.TrimSpace(line) - if strings.HasPrefix(line, "# ") { - return strings.TrimSpace(strings.TrimPrefix(line, "# ")) - } - } - return "" -} - -// StripExecutionFlow removes the "Execution Flow" section from markdown content -// This section is typically found in spec.md and plan.md artifacts -func StripExecutionFlow(content string) string { - lines := strings.Split(content, "\n") - var result []string - inExecutionFlow := false - - for _, line := range lines { - trimmed := strings.TrimSpace(line) - - // Check if this is the start of the Execution Flow section - if strings.HasPrefix(trimmed, "##") && strings.Contains(strings.ToLower(trimmed), "execution flow") { - inExecutionFlow = true - continue - } - - // Check if we've hit the next section (another ## heading) - if inExecutionFlow && strings.HasPrefix(trimmed, "##") { - inExecutionFlow = false - } - - // Add line if not in Execution Flow section - if !inExecutionFlow { - result = append(result, line) - } - } - - return strings.Join(result, "\n") -} - -// AttachFileToJiraIssue attaches a file to a Jira issue -func AttachFileToJiraIssue(ctx context.Context, jiraBase, issueKey, authHeader string, filename string, content []byte) error { - endpoint := fmt.Sprintf("%s/rest/api/2/issue/%s/attachments", jiraBase, url.PathEscape(issueKey)) - - // Create multipart form body - body := &bytes.Buffer{} - writer := NewMultipartWriter(body) - - part, err := writer.CreateFormFile("file", filename) - if err != nil { - return fmt.Errorf("failed to create form file: %w", err) - } - - if _, err := part.Write(content); err != nil { - return fmt.Errorf("failed to write content: %w", err) - } - - contentType := writer.FormDataContentType() - if err := writer.Close(); err != nil { - return fmt.Errorf("failed to close writer: %w", err) - } - - httpReq, err := http.NewRequestWithContext(ctx, "POST", endpoint, body) - if err != nil { - return fmt.Errorf("failed to create request: %w", err) - } - - httpReq.Header.Set("Authorization", authHeader) - httpReq.Header.Set("Content-Type", contentType) - httpReq.Header.Set("X-Atlassian-Token", "no-check") - - httpClient := &http.Client{Timeout: 60 * time.Second} - resp, err := httpClient.Do(httpReq) - if err != nil { - return fmt.Errorf("request failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - respBody, _ := io.ReadAll(resp.Body) - return fmt.Errorf("jira API error: %s (body: %s)", resp.Status, string(respBody)) - } - - return nil -} - -// MultipartWriter creates a new multipart writer with boundary. -type MultipartWriter struct { - w io.Writer - boundary string - closed bool -} - -func NewMultipartWriter(w io.Writer) *MultipartWriter { - boundary := fmt.Sprintf("----WebKitFormBoundary%d", time.Now().UnixNano()) - return &MultipartWriter{w: w, boundary: boundary} -} - -func (mw *MultipartWriter) FormDataContentType() string { - return fmt.Sprintf("multipart/form-data; boundary=%s", mw.boundary) -} - -func (mw *MultipartWriter) CreateFormFile(fieldname, filename string) (io.Writer, error) { - h := fmt.Sprintf("--%s\r\nContent-Disposition: form-data; name=\"%s\"; filename=\"%s\"\r\nContent-Type: application/octet-stream\r\n\r\n", - mw.boundary, fieldname, filename) - _, err := mw.w.Write([]byte(h)) - return mw.w, err -} - -func (mw *MultipartWriter) Close() error { - if mw.closed { - return nil - } - mw.closed = true - _, err := fmt.Fprintf(mw.w, "\r\n--%s--\r\n", mw.boundary) - return err -} - -// PublishWorkflowFileToJira creates or updates a Jira issue from a GitHub file and updates the RFEWorkflow CR with the linkage. -// POST /api/projects/:projectName/rfe-workflows/:id/jira { path, phase } -// Supports phase-specific logic: specify (Feature + rfe.md attachment), plan (Epic with artifact links), tasks (attach tasks.md) -func (h *Handler) PublishWorkflowFileToJira(c *gin.Context) { - project := c.Param("projectName") - id := c.Param("id") - - var req struct { - Path string `json:"path" binding:"required"` - Phase string `json:"phase"` // Optional: specify, plan, tasks - } - if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Path) == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "path is required"}) - return - } - - log.Printf("DEBUG JIRA: Received phase='%s' for path='%s'", req.Phase, req.Path) - - // Load runner secrets for Jira config - _, reqDyn := h.GetK8sClientsForRequest(c) - reqK8s, _ := h.GetK8sClientsForRequest(c) - if reqK8s == nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing or invalid user token"}) - return - } - - // Determine configured secret name - secretName := "" - if reqDyn != nil { - gvr := h.GetProjectSettingsResource() - if obj, err := reqDyn.Resource(gvr).Namespace(project).Get(c.Request.Context(), "projectsettings", v1.GetOptions{}); err == nil { - if spec, ok := obj.Object["spec"].(map[string]interface{}); ok { - if v, ok := spec["runnerSecretsName"].(string); ok { - secretName = strings.TrimSpace(v) - } - } - } - } - if secretName == "" { - secretName = "ambient-runner-secrets" - } - - sec, err := reqK8s.CoreV1().Secrets(project).Get(c.Request.Context(), secretName, v1.GetOptions{}) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read runner secret", "details": err.Error()}) - return - } - get := func(k string) string { - if b, ok := sec.Data[k]; ok { - return string(b) - } - return "" - } - jiraURL := strings.TrimSpace(get("JIRA_URL")) - jiraProject := strings.TrimSpace(get("JIRA_PROJECT")) - jiraToken := strings.TrimSpace(get("JIRA_API_TOKEN")) - if jiraURL == "" || jiraProject == "" || jiraToken == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Missing Jira configuration in runner secret (JIRA_URL, JIRA_PROJECT, JIRA_API_TOKEN required)"}) - return - } - - // Load workflow - gvrWf := h.GetRFEWorkflowResource() - if reqDyn == nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing or invalid user token"}) - return - } - item, err := reqDyn.Resource(gvrWf).Namespace(project).Get(c.Request.Context(), id, v1.GetOptions{}) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Workflow not found"}) - return - } - wf := RFEFromUnstructured(item) - if wf == nil || wf.UmbrellaRepo == nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Workflow has no spec repo configured"}) - return - } - - // Get user ID and GitHub token - userID, _ := c.Get("userID") - userIDStr, ok := userID.(string) - if !ok || userIDStr == "" { - c.JSON(http.StatusUnauthorized, gin.H{"error": "User identity required"}) - return - } - githubToken, err := git.GetGitHubToken(c.Request.Context(), reqK8s, reqDyn, project, userIDStr) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to get GitHub token", "details": err.Error()}) - return - } - - // Read file from GitHub - owner, repo, err := git.ParseGitHubURL(wf.UmbrellaRepo.URL) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid spec repo URL", "details": err.Error()}) - return - } - // Use the generated feature branch - specs only exist on feature branch - if wf.BranchName == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "RFE workflow has no feature branch. Please seed the repository first."}) - return - } - - branch := wf.BranchName - - content, err := git.ReadGitHubFile(c.Request.Context(), owner, repo, branch, req.Path, githubToken) - if err != nil { - c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to read file from GitHub", "details": err.Error()}) - return - } - - // Extract title from markdown content - title := ExtractTitleFromContent(string(content)) - if title == "" { - title = wf.Title // Fallback to workflow title - } - - // Build GitHub URL for the file - githubURL := fmt.Sprintf("https://github.com/%s/%s/blob/%s/%s", owner, repo, branch, req.Path) - - // Strip Execution Flow section from spec.md and plan.md - var processedContent []byte - if req.Phase == "specify" || req.Phase == "plan" || req.Phase == "tasks" { - stripped := StripExecutionFlow(string(content)) - - // For tasks phase (Epic), add reference to parent Feature if it exists - featureReference := "" - if req.Phase == "tasks" { - for _, jl := range wf.JiraLinks { - if strings.Contains(jl.Path, "plan.md") { - featureReference = fmt.Sprintf("\n\n**Implements Feature:** %s\n\n---", jl.JiraKey) - break - } - } - } - - // Prepend GitHub URL reference at the top - processedContent = []byte(fmt.Sprintf("**Source:** %s%s\n\n---\n\n%s", githubURL, featureReference, stripped)) - } else { - // For other files, just prepend the GitHub URL - processedContent = []byte(fmt.Sprintf("**Source:** %s\n\n---\n\n%s", githubURL, string(content))) - } - - // Check if Jira link already exists for this path - existingKey := "" - for _, jl := range wf.JiraLinks { - if strings.TrimSpace(jl.Path) == strings.TrimSpace(req.Path) { - existingKey = jl.JiraKey - break - } - } - - // Determine auth header (Cloud vs Server) - authHeader := "" - if strings.Contains(jiraURL, "atlassian.net") { - // Jira Cloud - assume token is email:api_token format - encoded := base64.StdEncoding.EncodeToString([]byte(jiraToken)) - authHeader = "Basic " + encoded - } else { - // Jira Server/Data Center - authHeader = "Bearer " + jiraToken - } - - // Determine issue type based on phase - // specify -> Feature Request, plan -> Feature, tasks -> Epic - issueType := "Feature" // default - switch req.Phase { - case "specify": - issueType = "Feature Request" - case "tasks": - issueType = "Epic" - } - - // Create or update Jira issue - jiraBase := strings.TrimRight(jiraURL, "/") - var httpReq *http.Request - - if existingKey == "" { - // CREATE new issue - jiraEndpoint := fmt.Sprintf("%s/rest/api/2/issue", jiraBase) - - fields := map[string]interface{}{ - "project": map[string]string{"key": jiraProject}, - "summary": title, - "description": string(processedContent), - "issuetype": map[string]string{"name": issueType}, - } - - // TODO: decide correct hierarchy for parent/children jira objects - // For Epic (tasks phase), the Feature reference is added to the description instead - parentKey := "" - if req.Phase != "tasks" { - // For non-Epic phases: use parent Outcome if specified - if wf.ParentOutcome != nil && *wf.ParentOutcome != "" { - parentKey = *wf.ParentOutcome - } - } - - if parentKey != "" { - fields["parent"] = map[string]string{"key": parentKey} - } - - reqBody := map[string]interface{}{"fields": fields} - payload, _ := json.Marshal(reqBody) - log.Printf("DEBUG JIRA: Creating issue with type '%s', payload: %s", issueType, string(payload)) - httpReq, _ = http.NewRequestWithContext(c.Request.Context(), "POST", jiraEndpoint, bytes.NewReader(payload)) - } else { - // UPDATE existing issue - jiraEndpoint := fmt.Sprintf("%s/rest/api/2/issue/%s", jiraBase, url.PathEscape(existingKey)) - reqBody := map[string]interface{}{ - "fields": map[string]interface{}{ - "summary": title, - "description": string(processedContent), - }, - } - payload, _ := json.Marshal(reqBody) - httpReq, _ = http.NewRequestWithContext(c.Request.Context(), "PUT", jiraEndpoint, bytes.NewReader(payload)) - } - - httpReq.Header.Set("Content-Type", "application/json") - httpReq.Header.Set("Authorization", authHeader) - - httpClient := &http.Client{Timeout: 30 * time.Second} - httpResp, httpErr := httpClient.Do(httpReq) - if httpErr != nil { - c.JSON(http.StatusBadGateway, gin.H{"error": "Jira request failed", "details": httpErr.Error()}) - return - } - defer httpResp.Body.Close() - - respBody, _ := io.ReadAll(httpResp.Body) - if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { - c.Data(httpResp.StatusCode, "application/json", respBody) - return - } - - // Extract Jira key from response - var outKey string - if existingKey == "" { - var created struct { - Key string `json:"key"` - } - _ = json.Unmarshal(respBody, &created) - if strings.TrimSpace(created.Key) == "" { - c.JSON(http.StatusBadGateway, gin.H{"error": "Jira creation returned no key"}) - return - } - outKey = created.Key - } else { - outKey = existingKey - } - - // Phase-specific attachment handling - switch req.Phase { - case "specify": - // For specify phase (Feature Request): attach rfe.md if it exists - rfeContent, err := git.ReadGitHubFile(c.Request.Context(), owner, repo, branch, "rfe.md", githubToken) - if err == nil && len(rfeContent) > 0 { - if attachErr := AttachFileToJiraIssue(c.Request.Context(), jiraBase, outKey, authHeader, "rfe.md", rfeContent); attachErr != nil { - log.Printf("Warning: failed to attach rfe.md to %s: %v", outKey, attachErr) - } - } - - case "plan": - // For plan phase (Feature): attach supporting documents if they exist - // Extract directory path from plan.md path (e.g., "specs/001-feature/plan.md" -> "specs/001-feature") - pathParts := strings.Split(req.Path, "/") - var dirPath string - if len(pathParts) > 1 { - dirPath = strings.Join(pathParts[:len(pathParts)-1], "/") - } - - // List of supporting documents to attach (if they exist) - supportingDocs := []string{"data-model.md", "quickstart.md", "research.md"} - - for _, docName := range supportingDocs { - docPath := docName - if dirPath != "" { - docPath = dirPath + "/" + docName - } - - // Try to read the file - skip silently if it doesn't exist - docContent, err := git.ReadGitHubFile(c.Request.Context(), owner, repo, branch, docPath, githubToken) - if err == nil && len(docContent) > 0 { - if attachErr := AttachFileToJiraIssue(c.Request.Context(), jiraBase, outKey, authHeader, docName, docContent); attachErr != nil { - log.Printf("Warning: failed to attach %s to %s: %v", docName, outKey, attachErr) - } else { - log.Printf("Successfully attached %s to %s", docName, outKey) - } - } - } - - case "tasks": - // For tasks phase: Epic is created and linked to the Feature (plan.md) - // No additional attachment handling needed - Epic is standalone with parent link - } - - // Update RFEWorkflow CR with Jira link - obj := item.DeepCopy() - spec, _ := obj.Object["spec"].(map[string]interface{}) - if spec == nil { - spec = map[string]interface{}{} - obj.Object["spec"] = spec - } - - var links []interface{} - if existing, ok := spec["jiraLinks"].([]interface{}); ok { - links = existing - } - - // Add or update link - found := false - for _, li := range links { - if m, ok := li.(map[string]interface{}); ok { - if fmt.Sprintf("%v", m["path"]) == req.Path { - m["jiraKey"] = outKey - found = true - break - } - } - } - if !found { - links = append(links, map[string]interface{}{"path": req.Path, "jiraKey": outKey}) - } - spec["jiraLinks"] = links - - if _, err := reqDyn.Resource(gvrWf).Namespace(project).Update(c.Request.Context(), obj, v1.UpdateOptions{}); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update workflow with Jira link", "details": err.Error()}) - return - } - - // Return success - c.JSON(http.StatusOK, gin.H{ - "key": outKey, - "url": fmt.Sprintf("%s/browse/%s", jiraBase, outKey), - }) -} +// Commented out RFE-specific functions +// Add Jira integration functions here when ready for session-based Jira support +*/ diff --git a/components/backend/k8s/resources.go b/components/backend/k8s/resources.go index 1f408fab6..e69ac6c40 100644 --- a/components/backend/k8s/resources.go +++ b/components/backend/k8s/resources.go @@ -21,15 +21,6 @@ func GetProjectSettingsResource() schema.GroupVersionResource { } } -// GetRFEWorkflowResource returns the GroupVersionResource for RFEWorkflow CRD -func GetRFEWorkflowResource() schema.GroupVersionResource { - return schema.GroupVersionResource{ - Group: "vteam.ambient-code", - Version: "v1alpha1", - Resource: "rfeworkflows", - } -} - // GetOpenShiftProjectResource returns the GroupVersionResource for OpenShift Project func GetOpenShiftProjectResource() schema.GroupVersionResource { return schema.GroupVersionResource{ diff --git a/components/backend/main.go b/components/backend/main.go index 1d6168a7d..b2b343c04 100644 --- a/components/backend/main.go +++ b/components/backend/main.go @@ -5,17 +5,13 @@ import ( "log" "os" - "ambient-code-backend/crd" "ambient-code-backend/git" "ambient-code-backend/github" "ambient-code-backend/handlers" - "ambient-code-backend/jira" "ambient-code-backend/k8s" "ambient-code-backend/server" - "ambient-code-backend/types" "ambient-code-backend/websocket" - "github.com/gin-gonic/gin" "github.com/joho/godotenv" ) @@ -36,6 +32,11 @@ func main() { handlers.GitPushRepo = git.PushRepo handlers.GitAbandonRepo = git.AbandonRepo handlers.GitDiffRepo = git.DiffRepo + handlers.GitCheckMergeStatus = git.CheckMergeStatus + handlers.GitPullRepo = git.PullRepo + handlers.GitPushToRepo = git.PushToRepo + handlers.GitCreateBranch = git.CreateBranch + handlers.GitListRemoteBranches = git.ListRemoteBranches log.Printf("Content service using StateBaseDir: %s", server.StateBaseDir) @@ -64,14 +65,16 @@ func main() { } git.GitHubTokenManager = github.Manager - // Initialize CRD package - crd.GetRFEWorkflowResource = k8s.GetRFEWorkflowResource - // Initialize content handlers handlers.StateBaseDir = server.StateBaseDir handlers.GitPushRepo = git.PushRepo handlers.GitAbandonRepo = git.AbandonRepo handlers.GitDiffRepo = git.DiffRepo + handlers.GitCheckMergeStatus = git.CheckMergeStatus + handlers.GitPullRepo = git.PullRepo + handlers.GitPushToRepo = git.PushToRepo + handlers.GitCreateBranch = git.CreateBranch + handlers.GitListRemoteBranches = git.ListRemoteBranches // Initialize GitHub auth handlers handlers.K8sClient = server.K8sClient @@ -88,21 +91,7 @@ func main() { handlers.DynamicClient = server.DynamicClient handlers.GetGitHubToken = git.GetGitHubToken handlers.DeriveRepoFolderFromURL = git.DeriveRepoFolderFromURL - - // Initialize RFE workflow handlers - handlers.GetRFEWorkflowResource = k8s.GetRFEWorkflowResource - handlers.UpsertProjectRFEWorkflowCR = crd.UpsertProjectRFEWorkflowCR - handlers.PerformRepoSeeding = performRepoSeeding - handlers.CheckRepoSeeding = checkRepoSeeding - handlers.CheckBranchExists = checkBranchExists - handlers.RfeFromUnstructured = jira.RFEFromUnstructured - - // Initialize Jira handler - jiraHandler := &jira.Handler{ - GetK8sClientsForRequest: handlers.GetK8sClientsForRequest, - GetProjectSettingsResource: k8s.GetProjectSettingsResource, - GetRFEWorkflowResource: k8s.GetRFEWorkflowResource, - } + handlers.SendMessageToSession = websocket.SendMessageToSession // Initialize repo handlers handlers.GetK8sClientsForRequestRepo = handlers.GetK8sClientsForRequest @@ -115,71 +104,8 @@ func main() { // Initialize websocket package websocket.StateBaseDir = server.StateBaseDir - // Normal server mode - create closure to capture jiraHandler - registerRoutesWithJira := func(r *gin.Engine) { - registerRoutes(r, jiraHandler) - } - if err := server.Run(registerRoutesWithJira); err != nil { + // Normal server mode + if err := server.Run(registerRoutes); err != nil { log.Fatalf("Server error: %v", err) } } - -// Adapter types to implement git package interfaces for RFEWorkflow -type rfeWorkflowAdapter struct { - wf *types.RFEWorkflow -} - -type gitRepositoryAdapter struct { - repo *types.GitRepository -} - -// Wrapper for git.PerformRepoSeeding that adapts *types.RFEWorkflow to git.Workflow interface -func performRepoSeeding(ctx context.Context, wf *types.RFEWorkflow, branchName, githubToken, agentURL, agentBranch, agentPath, specKitRepo, specKitVersion, specKitTemplate string) (bool, error) { - return git.PerformRepoSeeding(ctx, &rfeWorkflowAdapter{wf: wf}, branchName, githubToken, agentURL, agentBranch, agentPath, specKitRepo, specKitVersion, specKitTemplate) -} - -// GetUmbrellaRepo implements git.Workflow interface -func (r *rfeWorkflowAdapter) GetUmbrellaRepo() git.GitRepo { - if r.wf.UmbrellaRepo == nil { - return nil - } - return &gitRepositoryAdapter{repo: r.wf.UmbrellaRepo} -} - -// GetSupportingRepos implements git.Workflow interface -func (r *rfeWorkflowAdapter) GetSupportingRepos() []git.GitRepo { - if len(r.wf.SupportingRepos) == 0 { - return nil - } - repos := make([]git.GitRepo, 0, len(r.wf.SupportingRepos)) - for i := range r.wf.SupportingRepos { - repos = append(repos, &gitRepositoryAdapter{repo: &r.wf.SupportingRepos[i]}) - } - return repos -} - -// GetURL implements git.GitRepo interface -func (g *gitRepositoryAdapter) GetURL() string { - if g.repo == nil { - return "" - } - return g.repo.URL -} - -// GetBranch implements git.GitRepo interface -func (g *gitRepositoryAdapter) GetBranch() *string { - if g.repo == nil { - return nil - } - return g.repo.Branch -} - -// Wrapper for git.CheckRepoSeeding -func checkRepoSeeding(ctx context.Context, repoURL string, branch *string, githubToken string) (bool, map[string]interface{}, error) { - return git.CheckRepoSeeding(ctx, repoURL, branch, githubToken) -} - -// Wrapper for git.CheckBranchExists -func checkBranchExists(ctx context.Context, repoURL, branchName, githubToken string) (bool, error) { - return git.CheckBranchExists(ctx, repoURL, branchName, githubToken) -} diff --git a/components/backend/routes.go b/components/backend/routes.go index 8733bdbe3..b4a28b1ff 100644 --- a/components/backend/routes.go +++ b/components/backend/routes.go @@ -2,7 +2,6 @@ package main import ( "ambient-code-backend/handlers" - "ambient-code-backend/jira" "ambient-code-backend/websocket" "github.com/gin-gonic/gin" @@ -15,12 +14,24 @@ func registerContentRoutes(r *gin.Engine) { r.POST("/content/github/push", handlers.ContentGitPush) r.POST("/content/github/abandon", handlers.ContentGitAbandon) r.GET("/content/github/diff", handlers.ContentGitDiff) + r.GET("/content/git-status", handlers.ContentGitStatus) + r.POST("/content/git-configure-remote", handlers.ContentGitConfigureRemote) + r.POST("/content/git-sync", handlers.ContentGitSync) + r.GET("/content/workflow-metadata", handlers.ContentWorkflowMetadata) + r.GET("/content/git-merge-status", handlers.ContentGitMergeStatus) + r.POST("/content/git-pull", handlers.ContentGitPull) + r.POST("/content/git-push", handlers.ContentGitPushToBranch) + r.POST("/content/git-create-branch", handlers.ContentGitCreateBranch) + r.GET("/content/git-list-branches", handlers.ContentGitListBranches) } -func registerRoutes(r *gin.Engine, jiraHandler *jira.Handler) { +func registerRoutes(r *gin.Engine) { // API routes api := r.Group("/api") { + // Public endpoints (no auth required) + api.GET("/workflows/ootb", handlers.ListOOTBWorkflows) + api.POST("/projects/:projectName/agentic-sessions/:sessionName/github/token", handlers.MintSessionGitHubToken) projectGroup := api.Group("/projects/:projectName", handlers.ValidateProjectContext()) @@ -49,30 +60,27 @@ 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/git/status", handlers.GetGitStatus) + projectGroup.POST("/agentic-sessions/:sessionName/git/configure-remote", handlers.ConfigureGitRemote) + projectGroup.POST("/agentic-sessions/:sessionName/git/synchronize", handlers.SynchronizeGit) + projectGroup.GET("/agentic-sessions/:sessionName/git/merge-status", handlers.GetGitMergeStatus) + projectGroup.POST("/agentic-sessions/:sessionName/git/pull", handlers.GitPullSession) + projectGroup.POST("/agentic-sessions/:sessionName/git/push", handlers.GitPushSession) + projectGroup.POST("/agentic-sessions/:sessionName/git/create-branch", handlers.GitCreateBranchSession) + projectGroup.GET("/agentic-sessions/:sessionName/git/list-branches", handlers.GitListBranchesSession) 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) - projectGroup.GET("/rfe-workflows/:id", handlers.GetProjectRFEWorkflow) - projectGroup.PUT("/rfe-workflows/:id", handlers.UpdateProjectRFEWorkflow) - projectGroup.GET("/rfe-workflows/:id/summary", handlers.GetProjectRFEWorkflowSummary) - projectGroup.DELETE("/rfe-workflows/:id", handlers.DeleteProjectRFEWorkflow) - projectGroup.POST("/rfe-workflows/:id/seed", handlers.SeedProjectRFEWorkflow) - projectGroup.GET("/rfe-workflows/:id/check-seeding", handlers.CheckProjectRFEWorkflowSeeding) - projectGroup.GET("/rfe-workflows/:id/agents", handlers.GetProjectRFEWorkflowAgents) + projectGroup.POST("/agentic-sessions/:sessionName/workflow", handlers.SelectWorkflow) + projectGroup.GET("/agentic-sessions/:sessionName/workflow/metadata", handlers.GetWorkflowMetadata) + projectGroup.POST("/agentic-sessions/:sessionName/repos", handlers.AddRepo) + projectGroup.DELETE("/agentic-sessions/:sessionName/repos/:repoName", handlers.RemoveRepo) 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) - projectGroup.GET("/rfe-workflows/:id/sessions", handlers.ListProjectRFEWorkflowSessions) - projectGroup.POST("/rfe-workflows/:id/sessions/link", handlers.AddProjectRFEWorkflowSession) - projectGroup.DELETE("/rfe-workflows/:id/sessions/:sessionName", handlers.RemoveProjectRFEWorkflowSession) projectGroup.GET("/permissions", handlers.ListProjectPermissions) projectGroup.POST("/permissions", handlers.AddProjectPermission) @@ -83,10 +91,10 @@ func registerRoutes(r *gin.Engine, jiraHandler *jira.Handler) { projectGroup.DELETE("/keys/:keyId", handlers.DeleteProjectKey) projectGroup.GET("/secrets", handlers.ListNamespaceSecrets) - projectGroup.GET("/runner-secrets/config", handlers.GetRunnerSecretsConfig) - projectGroup.PUT("/runner-secrets/config", handlers.UpdateRunnerSecretsConfig) projectGroup.GET("/runner-secrets", handlers.ListRunnerSecrets) projectGroup.PUT("/runner-secrets", handlers.UpdateRunnerSecrets) + projectGroup.GET("/integration-secrets", handlers.ListIntegrationSecrets) + projectGroup.PUT("/integration-secrets", handlers.UpdateIntegrationSecrets) } api.POST("/auth/github/install", handlers.LinkGitHubInstallationGlobal) diff --git a/components/backend/types/rfe.go b/components/backend/types/rfe.go deleted file mode 100644 index 2c2b5b631..000000000 --- a/components/backend/types/rfe.go +++ /dev/null @@ -1,44 +0,0 @@ -package types - -// RFEWorkflow represents RFE (Request For Enhancement) workflow data structures. -type RFEWorkflow struct { - ID string `json:"id"` - Title string `json:"title"` - Description string `json:"description"` - BranchName string `json:"branchName"` - UmbrellaRepo *GitRepository `json:"umbrellaRepo,omitempty"` - SupportingRepos []GitRepository `json:"supportingRepos,omitempty"` - Project string `json:"project,omitempty"` - WorkspacePath string `json:"workspacePath"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` - JiraLinks []WorkflowJiraLink `json:"jiraLinks,omitempty"` - ParentOutcome *string `json:"parentOutcome,omitempty"` -} - -type WorkflowJiraLink struct { - Path string `json:"path"` - JiraKey string `json:"jiraKey"` -} - -type CreateRFEWorkflowRequest struct { - Title string `json:"title" binding:"required"` - Description string `json:"description" binding:"required"` - BranchName string `json:"branchName" binding:"required"` - UmbrellaRepo GitRepository `json:"umbrellaRepo"` - SupportingRepos []GitRepository `json:"supportingRepos,omitempty"` - WorkspacePath string `json:"workspacePath,omitempty"` - ParentOutcome *string `json:"parentOutcome,omitempty"` -} - -type UpdateRFEWorkflowRequest struct { - Title *string `json:"title,omitempty"` - Description *string `json:"description,omitempty"` - UmbrellaRepo *GitRepository `json:"umbrellaRepo,omitempty"` - SupportingRepos []GitRepository `json:"supportingRepos,omitempty"` - ParentOutcome *string `json:"parentOutcome,omitempty"` -} - -type AdvancePhaseRequest struct { - Force bool `json:"force,omitempty"` // Force advance even if current phase isn't complete -} diff --git a/components/backend/types/session.go b/components/backend/types/session.go index d4faaf90a..144140752 100644 --- a/components/backend/types/session.go +++ b/components/backend/types/session.go @@ -23,6 +23,8 @@ type AgenticSessionSpec struct { // Multi-repo support (unified mapping) Repos []SessionRepoMapping `json:"repos,omitempty"` MainRepoIndex *int `json:"mainRepoIndex,omitempty"` + // Active workflow for dynamic workflow switching + ActiveWorkflow *WorkflowSelection `json:"activeWorkflow,omitempty"` } // NamedGitRepo represents named repository types for multi-repo session support. @@ -84,3 +86,24 @@ type CloneSessionRequest struct { TargetProject string `json:"targetProject" binding:"required"` NewSessionName string `json:"newSessionName" binding:"required"` } + +type UpdateAgenticSessionRequest struct { + Prompt *string `json:"prompt,omitempty"` + DisplayName *string `json:"displayName,omitempty"` + Timeout *int `json:"timeout,omitempty"` + LLMSettings *LLMSettings `json:"llmSettings,omitempty"` +} + +type CloneAgenticSessionRequest struct { + TargetProject string `json:"targetProject,omitempty"` + TargetSessionName string `json:"targetSessionName,omitempty"` + DisplayName string `json:"displayName,omitempty"` + Prompt string `json:"prompt,omitempty"` +} + +// WorkflowSelection represents a workflow to load into the session +type WorkflowSelection struct { + GitURL string `json:"gitUrl" binding:"required"` + Branch string `json:"branch,omitempty"` + Path string `json:"path,omitempty"` +} diff --git a/components/frontend/.gitignore b/components/frontend/.gitignore index 3eb22dfc0..c9d206bd2 100644 --- a/components/frontend/.gitignore +++ b/components/frontend/.gitignore @@ -60,4 +60,7 @@ jspm_packages/ # TypeScript *.tsbuildinfo -next-env.d.ts \ No newline at end of file +next-env.d.ts + +# Previous frontend +previous-frontend/ \ No newline at end of file diff --git a/components/frontend/README.md b/components/frontend/README.md index c92748388..a5988e42e 100644 --- a/components/frontend/README.md +++ b/components/frontend/README.md @@ -92,11 +92,15 @@ In production, put an OAuth/ingress proxy in front of the app to set these heade ### Environment variables - `BACKEND_URL` (default: `http://localhost:8080/api`) - Used by server-side API routes to reach the backend. +- `FEEDBACK_URL` (optional) + - URL for the feedback link in the masthead. If not set, the link will not appear. - Optional dev helpers: `OC_USER`, `OC_EMAIL`, `OC_TOKEN`, `ENABLE_OC_WHOAMI=1` You can also put these in a `.env.local` file in this folder: ``` BACKEND_URL=http://localhost:8080/api +# Optional: URL for feedback link in masthead +# FEEDBACK_URL=https://forms.example.com/feedback # Optional dev helpers # OC_USER=your.name # OC_EMAIL=your.name@example.com diff --git a/components/frontend/package-lock.json b/components/frontend/package-lock.json index 8ae804d01..12708c5e6 100644 --- a/components/frontend/package-lock.json +++ b/components/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@hookform/resolvers": "^5.2.1", + "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -18,6 +19,7 @@ "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-toast": "^1.2.15", + "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "@tanstack/react-query-devtools": "^5.90.2", "class-variance-authority": "^0.7.1", @@ -1046,6 +1048,37 @@ "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", "license": "MIT" }, + "node_modules/@radix-ui/react-accordion": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz", + "integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collapsible": "1.1.12", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-arrow": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", @@ -1126,6 +1159,36 @@ } } }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", + "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collection": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", @@ -1657,6 +1720,40 @@ } } }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", + "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", diff --git a/components/frontend/package.json b/components/frontend/package.json index df5f50ae9..f5931698c 100644 --- a/components/frontend/package.json +++ b/components/frontend/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@hookform/resolvers": "^5.2.1", + "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -19,6 +20,7 @@ "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-toast": "^1.2.15", + "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "@tanstack/react-query-devtools": "^5.90.2", "class-variance-authority": "^0.7.1", diff --git a/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/git/configure-remote/route.ts b/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/git/configure-remote/route.ts new file mode 100644 index 000000000..ace995dff --- /dev/null +++ b/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/git/configure-remote/route.ts @@ -0,0 +1,27 @@ +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 body = await request.text(); + + const resp = await fetch( + `${BACKEND_URL}/projects/${encodeURIComponent(name)}/agentic-sessions/${encodeURIComponent(sessionName)}/git/configure-remote`, + { + method: 'POST', + headers, + body, + } + ); + + 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]/git/create-branch/route.ts b/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/git/create-branch/route.ts new file mode 100644 index 000000000..73e84aff4 --- /dev/null +++ b/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/git/create-branch/route.ts @@ -0,0 +1,27 @@ +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 body = await request.text(); + + const resp = await fetch( + `${BACKEND_URL}/projects/${encodeURIComponent(name)}/agentic-sessions/${encodeURIComponent(sessionName)}/git/create-branch`, + { + method: 'POST', + headers, + body, + } + ); + + 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]/git/list-branches/route.ts b/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/git/list-branches/route.ts new file mode 100644 index 000000000..b150cdfa4 --- /dev/null +++ b/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/git/list-branches/route.ts @@ -0,0 +1,20 @@ +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 { searchParams } = new URL(request.url); + const path = searchParams.get('path') || 'artifacts'; + + const headers = await buildForwardHeadersAsync(request); + const resp = await fetch( + `${BACKEND_URL}/projects/${encodeURIComponent(name)}/agentic-sessions/${encodeURIComponent(sessionName)}/git/list-branches?path=${encodeURIComponent(path)}`, + { 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]/git/merge-status/route.ts b/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/git/merge-status/route.ts new file mode 100644 index 000000000..982709b05 --- /dev/null +++ b/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/git/merge-status/route.ts @@ -0,0 +1,21 @@ +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 { searchParams } = new URL(request.url); + const path = searchParams.get('path') || 'artifacts'; + const branch = searchParams.get('branch') || 'main'; + + const headers = await buildForwardHeadersAsync(request); + const resp = await fetch( + `${BACKEND_URL}/projects/${encodeURIComponent(name)}/agentic-sessions/${encodeURIComponent(sessionName)}/git/merge-status?path=${encodeURIComponent(path)}&branch=${encodeURIComponent(branch)}`, + { 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]/git/pull/route.ts b/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/git/pull/route.ts new file mode 100644 index 000000000..b0062882a --- /dev/null +++ b/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/git/pull/route.ts @@ -0,0 +1,27 @@ +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 body = await request.text(); + + const resp = await fetch( + `${BACKEND_URL}/projects/${encodeURIComponent(name)}/agentic-sessions/${encodeURIComponent(sessionName)}/git/pull`, + { + method: 'POST', + headers, + body, + } + ); + + 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]/git/push/route.ts b/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/git/push/route.ts new file mode 100644 index 000000000..fef954985 --- /dev/null +++ b/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/git/push/route.ts @@ -0,0 +1,27 @@ +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 body = await request.text(); + + const resp = await fetch( + `${BACKEND_URL}/projects/${encodeURIComponent(name)}/agentic-sessions/${encodeURIComponent(sessionName)}/git/push`, + { + method: 'POST', + headers, + body, + } + ); + + 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]/git/status/route.ts b/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/git/status/route.ts new file mode 100644 index 000000000..8f8464b8e --- /dev/null +++ b/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/git/status/route.ts @@ -0,0 +1,25 @@ +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 { searchParams } = new URL(request.url); + const path = searchParams.get('path') || ''; + + const headers = await buildForwardHeadersAsync(request); + + const resp = await fetch( + `${BACKEND_URL}/projects/${encodeURIComponent(name)}/agentic-sessions/${encodeURIComponent(sessionName)}/git/status?path=${encodeURIComponent(path)}`, + { method: 'GET', 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]/git/synchronize/route.ts b/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/git/synchronize/route.ts new file mode 100644 index 000000000..793e6d7bc --- /dev/null +++ b/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/git/synchronize/route.ts @@ -0,0 +1,27 @@ +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 body = await request.text(); + + const resp = await fetch( + `${BACKEND_URL}/projects/${encodeURIComponent(name)}/agentic-sessions/${encodeURIComponent(sessionName)}/git/synchronize`, + { + method: 'POST', + headers, + body, + } + ); + + 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]/repos/[repoName]/route.ts b/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/repos/[repoName]/route.ts new file mode 100644 index 000000000..8888ee182 --- /dev/null +++ b/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/repos/[repoName]/route.ts @@ -0,0 +1,25 @@ +import { BACKEND_URL } from '@/lib/config'; +import { buildForwardHeadersAsync } from '@/lib/auth'; + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ name: string; sessionName: string; repoName: string }> }, +) { + const { name, sessionName, repoName } = await params; + const headers = await buildForwardHeadersAsync(request); + + const resp = await fetch( + `${BACKEND_URL}/projects/${encodeURIComponent(name)}/agentic-sessions/${encodeURIComponent(sessionName)}/repos/${encodeURIComponent(repoName)}`, + { + 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]/repos/route.ts b/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/repos/route.ts new file mode 100644 index 000000000..d3898d46d --- /dev/null +++ b/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/repos/route.ts @@ -0,0 +1,27 @@ +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 body = await request.text(); + + const resp = await fetch( + `${BACKEND_URL}/projects/${encodeURIComponent(name)}/agentic-sessions/${encodeURIComponent(sessionName)}/repos`, + { + method: 'POST', + headers, + body, + } + ); + + 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]/workflow/metadata/route.ts b/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/workflow/metadata/route.ts new file mode 100644 index 000000000..a7596cd7d --- /dev/null +++ b/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/workflow/metadata/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)}/workflow/metadata`, + { 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]/workflow/route.ts b/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/workflow/route.ts new file mode 100644 index 000000000..257eb76ae --- /dev/null +++ b/components/frontend/src/app/api/projects/[name]/agentic-sessions/[sessionName]/workflow/route.ts @@ -0,0 +1,27 @@ +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 body = await request.text(); + + const resp = await fetch( + `${BACKEND_URL}/projects/${encodeURIComponent(name)}/agentic-sessions/${encodeURIComponent(sessionName)}/workflow`, + { + method: 'POST', + headers, + body, + } + ); + + 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]/integration-secrets/route.ts b/components/frontend/src/app/api/projects/[name]/integration-secrets/route.ts new file mode 100644 index 000000000..dd773befc --- /dev/null +++ b/components/frontend/src/app/api/projects/[name]/integration-secrets/route.ts @@ -0,0 +1,41 @@ +import { BACKEND_URL } from '@/lib/config'; +import { buildForwardHeadersAsync } from '@/lib/auth'; + +// GET /api/projects/[name]/integration-secrets +export async function GET( + request: Request, + { params }: { params: Promise<{ name: string }> } +) { + try { + const { name } = await params; + const headers = await buildForwardHeadersAsync(request); + const response = await fetch(`${BACKEND_URL}/projects/${encodeURIComponent(name)}/integration-secrets`, { headers }); + const text = await response.text(); + return new Response(text, { status: response.status, headers: { 'Content-Type': 'application/json' } }); + } catch (error) { + console.error('Error getting integration secrets:', error); + return Response.json({ error: 'Failed to get integration secrets' }, { status: 500 }); + } +} + +// PUT /api/projects/[name]/integration-secrets +export async function PUT( + request: Request, + { params }: { params: Promise<{ name: string }> } +) { + try { + const { name } = await params; + const body = await request.text(); + const headers = await buildForwardHeadersAsync(request); + const response = await fetch(`${BACKEND_URL}/projects/${encodeURIComponent(name)}/integration-secrets`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', ...headers }, + body, + }); + const text = await response.text(); + return new Response(text, { status: response.status, headers: { 'Content-Type': 'application/json' } }); + } catch (error) { + console.error('Error updating integration secrets:', error); + return Response.json({ error: 'Failed to update integration secrets' }, { status: 500 }); + } +} diff --git a/components/frontend/src/app/api/projects/[name]/rfe-workflows/[id]/agents/route.ts b/components/frontend/src/app/api/projects/[name]/rfe-workflows/[id]/agents/route.ts deleted file mode 100644 index 9259b4c05..000000000 --- a/components/frontend/src/app/api/projects/[name]/rfe-workflows/[id]/agents/route.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { buildForwardHeadersAsync } from '@/lib/auth'; -import { BACKEND_URL } from '@/lib/config'; - -type RouteContext = { - params: Promise<{ - name: string; - id: string; - }>; -}; - -export async function GET(request: NextRequest, context: RouteContext) { - const { name: projectName, id: workflowId } = await context.params; - - const headers = await buildForwardHeadersAsync(request); - - const resp = await fetch( - `${BACKEND_URL}/projects/${encodeURIComponent(projectName)}/rfe-workflows/${encodeURIComponent(workflowId)}/agents`, - { - method: 'GET', - headers, - } - ); - - if (!resp.ok) { - const text = await resp.text(); - return NextResponse.json( - { error: text || 'Failed to fetch agents' }, - { status: resp.status } - ); - } - - const data = await resp.json(); - return NextResponse.json(data); -} diff --git a/components/frontend/src/app/api/projects/[name]/rfe-workflows/[id]/check-seeding/route.ts b/components/frontend/src/app/api/projects/[name]/rfe-workflows/[id]/check-seeding/route.ts deleted file mode 100644 index 67b61a8af..000000000 --- a/components/frontend/src/app/api/projects/[name]/rfe-workflows/[id]/check-seeding/route.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { buildForwardHeadersAsync } from '@/lib/auth'; -import { BACKEND_URL } from '@/lib/config'; - -type RouteContext = { - params: Promise<{ - name: string; - id: string; - }>; -}; - -export async function GET(request: NextRequest, context: RouteContext) { - const { name: projectName, id: workflowId } = await context.params; - - const headers = await buildForwardHeadersAsync(request); - - const resp = await fetch( - `${BACKEND_URL}/projects/${encodeURIComponent(projectName)}/rfe-workflows/${encodeURIComponent(workflowId)}/check-seeding`, - { - method: 'GET', - headers, - } - ); - - if (!resp.ok) { - const text = await resp.text(); - return NextResponse.json( - { error: text || 'Failed to check seeding status' }, - { status: resp.status } - ); - } - - const data = await resp.json(); - return NextResponse.json(data); -} - diff --git a/components/frontend/src/app/api/projects/[name]/rfe-workflows/[id]/jira/route.ts b/components/frontend/src/app/api/projects/[name]/rfe-workflows/[id]/jira/route.ts deleted file mode 100644 index 562058c30..000000000 --- a/components/frontend/src/app/api/projects/[name]/rfe-workflows/[id]/jira/route.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { BACKEND_URL } from '@/lib/config' -import { buildForwardHeadersAsync } from '@/lib/auth' - -type PublishRequestBody = { - phase?: 'specify' | 'plan' | 'tasks' - path?: string - issueTypeName?: string -} - -function getExpectedPathForPhase(phase: string): string { - if (phase === 'specify') return 'specs/spec.md' - if (phase === 'plan') return 'specs/plan.md' - return 'specs/tasks.md' -} - -export async function POST( - request: Request, - { params }: { params: Promise<{ name: string; id: string }> } -) { - try { - const { name, id } = await params - const headers = await buildForwardHeadersAsync(request) - - const bodyText = await request.text() - const body: PublishRequestBody = bodyText ? JSON.parse(bodyText) : {} - const phase = body.phase || 'specify' - const path = body.path || getExpectedPathForPhase(phase) - - // Resolve repo/ref for this workflow to fetch content from GitHub via backend - // 1) Load workflow to get repositories and canonical branch if present - const wfResp = await fetch(`${BACKEND_URL}/projects/${encodeURIComponent(name)}/rfe-workflows/${encodeURIComponent(id)}`, { headers }) - if (!wfResp.ok) { - const errText = await wfResp.text() - return new Response(errText, { status: wfResp.status, headers: { 'Content-Type': 'application/json' } }) - } - const wf = await wfResp.json() - // Use umbrellaRepo as the main repository - const umbrellaUrl: string | undefined = wf?.umbrellaRepo?.url - const repo: string | undefined = umbrellaUrl?.replace(/^https?:\/\/(?:www\.)?github.com\//i, '').replace(/\.git$/i, '') - // Use the feature branch (branchName), not the base branch - const ref: string | undefined = wf?.branchName - if (!repo) { - return Response.json({ error: 'Workflow umbrellaRepo not configured' }, { status: 400 }) - } - if (!ref) { - return Response.json({ error: 'Workflow has no feature branch. Please seed the repository first.' }, { status: 400 }) - } - - // 2) Fetch file content via backend repo blob proxy - const blobUrl = `${BACKEND_URL}/projects/${encodeURIComponent(name)}/repo/blob?repo=${encodeURIComponent(repo)}&ref=${encodeURIComponent(ref)}&path=${encodeURIComponent(path)}` - const blobResp = await fetch(blobUrl, { headers }) - if (!blobResp.ok) { - const errText = await blobResp.text() - return new Response(errText, { status: blobResp.status, headers: { 'Content-Type': 'application/json' } }) - } - await blobResp.json().catch(async () => ({ content: await blobResp.text() })) - - // 3) Delegate to backend to create Jira and update CR (now that content can be validated server-side if needed) - const backendResp = await fetch(`${BACKEND_URL}/projects/${encodeURIComponent(name)}/rfe-workflows/${encodeURIComponent(id)}/jira`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', ...headers }, - body: JSON.stringify({ path, phase }) - }) - const text = await backendResp.text() - return new Response(text, { status: backendResp.status, headers: { 'Content-Type': 'application/json' } }) - } catch (error) { - console.error('Error publishing to Jira:', error) - return Response.json({ error: 'Failed to publish to Jira' }, { status: 500 }) - } -} - -// GET /api/projects/[name]/rfe/[id]/jira?path=... -export async function GET( - request: Request, - { params }: { params: Promise<{ name: string; id: string }> } -) { - try { - const { name, id } = await params - const headers = await buildForwardHeadersAsync(request) - const url = new URL(request.url) - const pathParam = url.searchParams.get('path') || '' - const backendResp = await fetch(`${BACKEND_URL}/projects/${encodeURIComponent(name)}/rfe-workflows/${encodeURIComponent(id)}/jira?path=${encodeURIComponent(pathParam)}`, { headers }) - const text = await backendResp.text() - return new Response(text, { status: backendResp.status, headers: { 'Content-Type': 'application/json' } }) - } catch (error) { - console.error('Error fetching Jira issue:', error) - return Response.json({ error: 'Failed to fetch Jira issue' }, { status: 500 }) - } -} - - diff --git a/components/frontend/src/app/api/projects/[name]/rfe-workflows/[id]/route.ts b/components/frontend/src/app/api/projects/[name]/rfe-workflows/[id]/route.ts deleted file mode 100644 index 6eaefb18b..000000000 --- a/components/frontend/src/app/api/projects/[name]/rfe-workflows/[id]/route.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { BACKEND_URL } from '@/lib/config' -import { buildForwardHeadersAsync } from '@/lib/auth' - -export async function GET( - request: Request, - { params }: { params: Promise<{ name: string; id: string }> }, -) { - const { name, id } = await params - const headers = await buildForwardHeadersAsync(request) - const resp = await fetch(`${BACKEND_URL}/projects/${encodeURIComponent(name)}/rfe-workflows/${encodeURIComponent(id)}`, { headers }) - const data = await resp.text() - return new Response(data, { status: resp.status, headers: { 'Content-Type': 'application/json' } }) -} - -export async function PUT( - request: Request, - { params }: { params: Promise<{ name: string; id: string }> }, -) { - const { name, id } = await params - const headers = await buildForwardHeadersAsync(request) - const body = await request.text() - const resp = await fetch(`${BACKEND_URL}/projects/${encodeURIComponent(name)}/rfe-workflows/${encodeURIComponent(id)}`, { - method: 'PUT', - headers: { ...headers, 'Content-Type': 'application/json' }, - body - }) - const data = await resp.text() - return new Response(data, { status: resp.status, headers: { 'Content-Type': 'application/json' } }) -} - -export async function DELETE( - request: Request, - { params }: { params: Promise<{ name: string; id: string }> }, -) { - const { name, id } = await params - const headers = await buildForwardHeadersAsync(request) - const resp = await fetch(`${BACKEND_URL}/projects/${encodeURIComponent(name)}/rfe-workflows/${encodeURIComponent(id)}`, { 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]/rfe-workflows/[id]/seed/route.ts b/components/frontend/src/app/api/projects/[name]/rfe-workflows/[id]/seed/route.ts deleted file mode 100644 index 547eebf70..000000000 --- a/components/frontend/src/app/api/projects/[name]/rfe-workflows/[id]/seed/route.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { buildForwardHeadersAsync } from '@/lib/auth'; -import { BACKEND_URL } from '@/lib/config'; - -type RouteContext = { - params: Promise<{ - name: string; - id: string; - }>; -}; - -export async function POST(request: NextRequest, context: RouteContext) { - const { name: projectName, id: workflowId } = await context.params; - - // Forward auth headers properly - const headers = await buildForwardHeadersAsync(request); - - // Forward request body (optional seeding config) - let body = null; - try { - body = await request.json(); - } catch { - // No body provided, use defaults - } - - const resp = await fetch( - `${BACKEND_URL}/projects/${encodeURIComponent(projectName)}/rfe-workflows/${encodeURIComponent(workflowId)}/seed`, - { - method: 'POST', - headers, - body: body ? JSON.stringify(body) : undefined, - } - ); - - if (!resp.ok) { - const text = await resp.text(); - return NextResponse.json( - { error: text || 'Failed to start seeding' }, - { status: resp.status } - ); - } - - const data = await resp.json(); - return NextResponse.json(data); -} - diff --git a/components/frontend/src/app/api/projects/[name]/rfe-workflows/[id]/sessions/[sessionName]/route.ts b/components/frontend/src/app/api/projects/[name]/rfe-workflows/[id]/sessions/[sessionName]/route.ts deleted file mode 100644 index 478709a9b..000000000 --- a/components/frontend/src/app/api/projects/[name]/rfe-workflows/[id]/sessions/[sessionName]/route.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { BACKEND_URL } from '@/lib/config' -import { buildForwardHeadersAsync } from '@/lib/auth' - -export async function DELETE( - request: Request, - { params }: { params: Promise<{ name: string; id: string; sessionName: string }> }, -) { - const { name, id, sessionName } = await params - const headers = await buildForwardHeadersAsync(request) - const resp = await fetch(`${BACKEND_URL}/projects/${encodeURIComponent(name)}/rfe-workflows/${encodeURIComponent(id)}/sessions/${encodeURIComponent(sessionName)}`, { - 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]/rfe-workflows/[id]/sessions/route.ts b/components/frontend/src/app/api/projects/[name]/rfe-workflows/[id]/sessions/route.ts deleted file mode 100644 index bdce11e78..000000000 --- a/components/frontend/src/app/api/projects/[name]/rfe-workflows/[id]/sessions/route.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { BACKEND_URL } from '@/lib/config' -import { buildForwardHeadersAsync } from '@/lib/auth' - -export async function GET( - request: Request, - { params }: { params: Promise<{ name: string; id: string }> }, -) { - const { name, id } = await params - const headers = await buildForwardHeadersAsync(request) - const resp = await fetch(`${BACKEND_URL}/projects/${encodeURIComponent(name)}/rfe-workflows/${encodeURIComponent(id)}/sessions`, { headers }) - const data = await resp.text() - return new Response(data, { status: resp.status, headers: { 'Content-Type': 'application/json' } }) -} - -export async function POST( - request: Request, - { params }: { params: Promise<{ name: string; id: string }> }, -) { - const { name, id } = await params - const headers = await buildForwardHeadersAsync(request) - const body = await request.text() - const resp = await fetch(`${BACKEND_URL}/projects/${encodeURIComponent(name)}/rfe-workflows/${encodeURIComponent(id)}/sessions`, { - method: 'POST', - headers, - body, - }) - 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]/rfe-workflows/[id]/summary/route.ts b/components/frontend/src/app/api/projects/[name]/rfe-workflows/[id]/summary/route.ts deleted file mode 100644 index 925224bf1..000000000 --- a/components/frontend/src/app/api/projects/[name]/rfe-workflows/[id]/summary/route.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { BACKEND_URL } from '@/lib/config' -import { buildForwardHeadersAsync } from '@/lib/auth' - -export async function GET( - request: Request, - { params }: { params: Promise<{ name: string; id: string }> }, -) { - const { name, id } = await params - const headers = await buildForwardHeadersAsync(request) - const resp = await fetch(`${BACKEND_URL}/projects/${encodeURIComponent(name)}/rfe-workflows/${encodeURIComponent(id)}/summary`, { 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]/rfe-workflows/[id]/workspace/[...path]/route.ts b/components/frontend/src/app/api/projects/[name]/rfe-workflows/[id]/workspace/[...path]/route.ts deleted file mode 100644 index 0695a7d2c..000000000 --- a/components/frontend/src/app/api/projects/[name]/rfe-workflows/[id]/workspace/[...path]/route.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { BACKEND_URL } from '@/lib/config' -import { buildForwardHeadersAsync } from '@/lib/auth' - -export async function GET( - request: Request, - { params }: { params: Promise<{ name: string; id: string; path: string[] }> }, -) { - const { name, id, path } = await params - const headers = await buildForwardHeadersAsync(request) - const rel = path.join('/') - const resp = await fetch( - `${BACKEND_URL}/projects/${encodeURIComponent(name)}/rfe-workflows/${encodeURIComponent(id)}/workspace/${encodeURIComponent(rel)}`, - { headers }, - ) - const contentType = resp.headers.get('content-type') || 'application/octet-stream' - const buf = await resp.arrayBuffer() - return new Response(buf, { status: resp.status, headers: { 'Content-Type': contentType } }) -} - - -export async function PUT( - request: Request, - { params }: { params: Promise<{ name: string; id: string; path: string[] }> }, -) { - const { name, id, path } = await params - const headers = await buildForwardHeadersAsync(request) - const rel = path.join('/') - const contentType = request.headers.get('content-type') || 'application/octet-stream' - const body = await request.arrayBuffer() - const resp = await fetch( - `${BACKEND_URL}/projects/${encodeURIComponent(name)}/rfe-workflows/${encodeURIComponent(id)}/workspace/${encodeURIComponent(rel)}`, - { method: 'PUT', headers: { ...headers, 'Content-Type': contentType }, body } - ) - const respBody = await resp.text() - return new Response(respBody, { status: resp.status, headers: { 'Content-Type': resp.headers.get('content-type') || 'application/json' } }) -} - - diff --git a/components/frontend/src/app/api/projects/[name]/rfe-workflows/[id]/workspace/route.ts b/components/frontend/src/app/api/projects/[name]/rfe-workflows/[id]/workspace/route.ts deleted file mode 100644 index dbe2a4a89..000000000 --- a/components/frontend/src/app/api/projects/[name]/rfe-workflows/[id]/workspace/route.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { BACKEND_URL } from '@/lib/config' -import { buildForwardHeadersAsync } from '@/lib/auth' - -export async function GET( - request: Request, - { params }: { params: Promise<{ name: string; id: string }> }, -) { - const { name, id } = await params - const headers = await buildForwardHeadersAsync(request) - const url = new URL(request.url) - const subpath = url.searchParams.get('path') - const qs = subpath ? `?path=${encodeURIComponent(subpath)}` : '' - const resp = await fetch( - `${BACKEND_URL}/projects/${encodeURIComponent(name)}/rfe-workflows/${encodeURIComponent(id)}/workspace${qs}`, - { headers }, - ) - const contentType = resp.headers.get('content-type') || 'application/json' - const body = await resp.text() - return new Response(body, { status: resp.status, headers: { 'Content-Type': contentType } }) -} - - diff --git a/components/frontend/src/app/api/projects/[name]/rfe-workflows/route.ts b/components/frontend/src/app/api/projects/[name]/rfe-workflows/route.ts deleted file mode 100644 index 0ad40fd65..000000000 --- a/components/frontend/src/app/api/projects/[name]/rfe-workflows/route.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { BACKEND_URL } from '@/lib/config' -import { buildForwardHeadersAsync } from '@/lib/auth' - -export async function GET( - request: Request, - { params }: { params: Promise<{ name: string }> }, -) { - const { name } = await params - const headers = await buildForwardHeadersAsync(request) - const resp = await fetch(`${BACKEND_URL}/projects/${encodeURIComponent(name)}/rfe-workflows`, { headers }) - const data = await resp.text() - return new Response(data, { status: resp.status, headers: { 'Content-Type': 'application/json' } }) -} - -export async function POST( - request: Request, - { params }: { params: Promise<{ name: string }> }, -) { - const { name } = await params - const headers = await buildForwardHeadersAsync(request) - const body = await request.text() - const resp = await fetch(`${BACKEND_URL}/projects/${encodeURIComponent(name)}/rfe-workflows`, { - method: 'POST', - headers, - body, - }) - 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/workflows/ootb/route.ts b/components/frontend/src/app/api/workflows/ootb/route.ts new file mode 100644 index 000000000..41c463a95 --- /dev/null +++ b/components/frontend/src/app/api/workflows/ootb/route.ts @@ -0,0 +1,33 @@ +import { BACKEND_URL } from "@/lib/config"; + +export async function GET() { + try { + // No auth required for public OOTB workflows endpoint + const response = await fetch(`${BACKEND_URL}/workflows/ootb`, { + method: 'GET', + headers: { + "Content-Type": "application/json", + }, + }); + + // Forward the response from backend + const data = await response.text(); + + return new Response(data, { + status: response.status, + headers: { + "Content-Type": "application/json", + }, + }); + } catch (error) { + console.error("Failed to fetch OOTB workflows:", error); + return new Response( + JSON.stringify({ error: "Failed to fetch OOTB workflows" }), + { + status: 500, + headers: { "Content-Type": "application/json" } + } + ); + } +} + diff --git a/components/frontend/src/app/globals.css b/components/frontend/src/app/globals.css index b77c4d606..2080a0957 100644 --- a/components/frontend/src/app/globals.css +++ b/components/frontend/src/app/globals.css @@ -52,7 +52,7 @@ --card-foreground: oklch(0.145 0 0); --popover: oklch(1 0 0); --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 0 0); + --primary: oklch(0.5 0.22 264); --primary-foreground: oklch(0.985 0 0); --secondary: oklch(0.97 0 0); --secondary-foreground: oklch(0.205 0 0); @@ -71,7 +71,7 @@ --chart-5: oklch(0.769 0.188 70.08); --sidebar: oklch(0.985 0 0); --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary: oklch(0.5 0.22 264); --sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-accent: oklch(0.97 0 0); --sidebar-accent-foreground: oklch(0.205 0 0); @@ -86,8 +86,8 @@ --card-foreground: oklch(0.985 0 0); --popover: oklch(0.205 0 0); --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.922 0 0); - --primary-foreground: oklch(0.205 0 0); + --primary: oklch(0.6 0.2 264); + --primary-foreground: oklch(0.985 0 0); --secondary: oklch(0.269 0 0); --secondary-foreground: oklch(0.985 0 0); --muted: oklch(0.269 0 0); diff --git a/components/frontend/src/app/integrations/IntegrationsClient.tsx b/components/frontend/src/app/integrations/IntegrationsClient.tsx index 657447dcd..4d81fea81 100644 --- a/components/frontend/src/app/integrations/IntegrationsClient.tsx +++ b/components/frontend/src/app/integrations/IntegrationsClient.tsx @@ -1,73 +1,32 @@ 'use client' import React from 'react' -import { Button } from '@/components/ui/button' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' -import { useGitHubStatus, useDisconnectGitHub } from '@/services/queries' -import { successToast, errorToast } from '@/hooks/use-toast' +import { GitHubConnectionCard } from '@/components/github-connection-card' +import { PageHeader } from '@/components/page-header' type Props = { appSlug?: string } export default function IntegrationsClient({ appSlug }: Props) { - const { data: status, isLoading, refetch } = useGitHubStatus() - const disconnectMutation = useDisconnectGitHub() - - const handleConnect = () => { - if (!appSlug) return - const setupUrl = new URL('/integrations/github/setup', window.location.origin) - const redirectUri = encodeURIComponent(setupUrl.toString()) - const url = `https://github.com/apps/${appSlug}/installations/new?redirect_uri=${redirectUri}` - window.location.href = url - } - - const handleDisconnect = async () => { - disconnectMutation.mutate(undefined, { - onSuccess: () => { - successToast('GitHub disconnected successfully') - refetch() - }, - onError: (error) => { - errorToast(error instanceof Error ? error.message : 'Failed to disconnect GitHub') - }, - }) - } - - const handleManage = () => { - window.open('https://github.com/settings/installations', '_blank') - } - return ( -
-

Integrations

- - - - GitHub - Connect GitHub to enable forks, PRs, and repo browsing - - -
- {status?.installed ? ( -
- Connected{status.githubUserId ? ` as ${status.githubUserId}` : ''} - {status.updatedAt ? ( - · updated {new Date(status.updatedAt).toLocaleString()} - ) : null} -
- ) : ( -
Not connected
- )} -
-
- - {status?.installed ? ( - - ) : ( - - )} +
+ {/* Sticky header */} +
+
+ +
+
+ +
+ {/* Content */} +
+
+
- - +
+
) } diff --git a/components/frontend/src/app/layout.tsx b/components/frontend/src/app/layout.tsx index 7da9cd6ae..d8ff51f21 100644 --- a/components/frontend/src/app/layout.tsx +++ b/components/frontend/src/app/layout.tsx @@ -21,14 +21,15 @@ export default function RootLayout({ children: React.ReactNode; }) { const wsBase = env.BACKEND_URL.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:') + const feedbackUrl = env.FEEDBACK_URL return ( - + - + - +
{children}
diff --git a/components/frontend/src/app/page.tsx b/components/frontend/src/app/page.tsx index 8e1efe49c..ff947ecfc 100644 --- a/components/frontend/src/app/page.tsx +++ b/components/frontend/src/app/page.tsx @@ -16,7 +16,7 @@ export default function HomeRedirect() {
-

Redirecting to RFE Wokspaces...

+

Redirecting to Workspaces...

diff --git a/components/frontend/src/app/projects/[name]/layout.tsx b/components/frontend/src/app/projects/[name]/layout.tsx deleted file mode 100644 index aebc81ab7..000000000 --- a/components/frontend/src/app/projects/[name]/layout.tsx +++ /dev/null @@ -1,57 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { usePathname } from "next/navigation"; -import { cn } from "@/lib/utils"; -import { Button } from "@/components/ui/button"; -import { Home, KeyRound, Settings, Users, Sparkles, ArrowLeft, GitBranch } from "lucide-react"; - -export default function ProjectSectionLayout({ children }: { children: React.ReactNode; params: Promise<{ name: string }> }) { - const pathname = usePathname(); - - const base = pathname?.split("/").slice(0, 3).join("/") || "/projects"; - // base is /projects/[name] - - const items = [ - { href: base, label: "Overview", icon: Home }, - { href: `${base}/rfe`, label: "RFE Workspaces", icon: GitBranch }, - { href: `${base}/sessions`, label: "Sessions", icon: Sparkles }, - { href: `${base}/keys`, label: "Keys", icon: KeyRound }, - { href: `${base}/permissions`, label: "Permissions", icon: Users }, - { href: `${base}/settings`, label: "Settings", icon: Settings }, - ]; - - return ( -
-
- -
{children}
-
-
- ); -} - - diff --git a/components/frontend/src/app/projects/[name]/page.tsx b/components/frontend/src/app/projects/[name]/page.tsx index 10ad504c7..7a1d327d3 100644 --- a/components/frontend/src/app/projects/[name]/page.tsx +++ b/components/frontend/src/app/projects/[name]/page.tsx @@ -1,116 +1,114 @@ 'use client'; -import { useCallback } from 'react'; -import { useParams, useRouter } from 'next/navigation'; -import { formatDistanceToNow } from 'date-fns'; -import { RefreshCw } from 'lucide-react'; +import { useState, useEffect } from 'react'; +import { useParams, useSearchParams } from 'next/navigation'; +import { Star, Settings, Users, RefreshCw } from 'lucide-react'; +import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Label } from '@/components/ui/label'; -import { ProjectSubpageHeader } from '@/components/project-subpage-header'; -import { ErrorMessage } from '@/components/error-message'; +import { PageHeader } from '@/components/page-header'; import { Breadcrumbs } from '@/components/breadcrumbs'; -import { useProject } from '@/services/queries'; +import { SessionsSection } from '@/components/workspace-sections/sessions-section'; +import { SharingSection } from '@/components/workspace-sections/sharing-section'; +import { SettingsSection } from '@/components/workspace-sections/settings-section'; +import { useProject } from '@/services/queries/use-projects'; + +type Section = 'sessions' | 'sharing' | 'settings'; export default function ProjectDetailsPage() { const params = useParams(); - const router = useRouter(); + const searchParams = useSearchParams(); const projectName = params?.name as string; + + // Fetch project data for display name and description + const { data: project, isLoading: projectLoading } = useProject(projectName); + + // Initialize active section from query parameter or default to 'sessions' + const initialSection = (searchParams.get('section') as Section) || 'sessions'; + const [activeSection, setActiveSection] = useState
(initialSection); - // React Query hook replaces all manual state management - const { data: project, isLoading, error, refetch } = useProject(projectName); + // Update active section when query parameter changes + useEffect(() => { + const sectionParam = searchParams.get('section') as Section; + if (sectionParam && ['sessions', 'sharing', 'settings'].includes(sectionParam)) { + setActiveSection(sectionParam); + } + }, [searchParams]); - const handleRefresh = useCallback(() => { - refetch(); - }, [refetch]); + const navItems = [ + { id: 'sessions' as Section, label: 'Sessions', icon: Star }, + { id: 'sharing' as Section, label: 'Sharing', icon: Users }, + { id: 'settings' as Section, label: 'Workspace Settings', icon: Settings }, + ]; // Loading state - if (!projectName || (isLoading && !project)) { + if (!projectName || projectLoading) { return (
- Loading project... + Loading workspace...
); } - // Error state (no project loaded) - if (error && !project) { - return ( -
- - -

{error instanceof Error ? error.message : 'Failed to load project'}

-
- - -
-
-
-
- ); - } - - if (!project) return null; - return ( -
- - {project.displayName || project.name}} - description={<>{projectName}} - actions={ - - } - /> - - {/* Error state (with project loaded) */} - {error && project && ( -
- +
+ {/* Sticky header */} +
+
+ +
- )} +
+ +
+ {/* Content */} +
+ {/* Sidebar Navigation */} + -
-
- {/* Project Info */} - - - Project Information - - -
- -

- {project.description || 'No description provided'} -

-
-
- -

- {project.creationTimestamp && - formatDistanceToNow(new Date(project.creationTimestamp), { - addSuffix: true, - })} -

-
-
-
+ {/* Main Content */} + {activeSection === 'sessions' && } + {activeSection === 'sharing' && } + {activeSection === 'settings' && }
diff --git a/components/frontend/src/app/projects/[name]/permissions/error.tsx b/components/frontend/src/app/projects/[name]/permissions/error.tsx deleted file mode 100644 index 9ad3cc608..000000000 --- a/components/frontend/src/app/projects/[name]/permissions/error.tsx +++ /dev/null @@ -1,37 +0,0 @@ -'use client'; - -import { useEffect } from 'react'; -import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { AlertCircle } from 'lucide-react'; - -export default function PermissionsError({ - error, - reset, -}: { - error: Error & { digest?: string }; - reset: () => void; -}) { - useEffect(() => { - console.error('Permissions page error:', error); - }, [error]); - - return ( -
- - -
- - Failed to load permissions -
- - {error.message || 'An unexpected error occurred while loading permissions.'} - -
- - - -
-
- ); -} diff --git a/components/frontend/src/app/projects/[name]/permissions/loading.tsx b/components/frontend/src/app/projects/[name]/permissions/loading.tsx deleted file mode 100644 index 843fdcf6a..000000000 --- a/components/frontend/src/app/projects/[name]/permissions/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { TableSkeleton } from '@/components/skeletons'; - -export default function PermissionsLoading() { - return ; -} diff --git a/components/frontend/src/app/projects/[name]/permissions/page.tsx b/components/frontend/src/app/projects/[name]/permissions/page.tsx index 08bc483c3..a07590d5f 100644 --- a/components/frontend/src/app/projects/[name]/permissions/page.tsx +++ b/components/frontend/src/app/projects/[name]/permissions/page.tsx @@ -1,404 +1,19 @@ 'use client'; -import { useCallback, useMemo, useState } from 'react'; -import { useParams } from 'next/navigation'; -import { Eye, Edit, Shield, Users, User as UserIcon, Plus, RefreshCw, Loader2, Trash2, Info } from 'lucide-react'; - -import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Badge } from '@/components/ui/badge'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; -import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; -import { ProjectSubpageHeader } from '@/components/project-subpage-header'; -import { ErrorMessage } from '@/components/error-message'; -import { DestructiveConfirmationDialog } from '@/components/confirmation-dialog'; -import { Breadcrumbs } from '@/components/breadcrumbs'; - -import { useProject, useProjectPermissions, useAddProjectPermission, useRemoveProjectPermission } from '@/services/queries'; -import { successToast, errorToast } from '@/hooks/use-toast'; -import type { PermissionRole, SubjectType } from '@/types/project'; - -const ROLE_DEFINITIONS = { - view: { - label: 'View', - description: 'Can see sessions and duplicate to their own project', - permissions: ['sessions:read', 'sessions:duplicate'] as const, - color: 'bg-blue-100 text-blue-800', - icon: Eye, - }, - edit: { - label: 'Edit', - description: 'Can create sessions in the project', - permissions: ['sessions:read', 'sessions:create', 'sessions:duplicate'] as const, - color: 'bg-green-100 text-green-800', - icon: Edit, - }, - admin: { - label: 'Admin', - description: 'Full project management access', - permissions: ['*'] as const, - color: 'bg-purple-100 text-purple-800', - icon: Shield, - }, -} as const; - -type GrantPermissionForm = { - subjectType: SubjectType; - subjectName: string; - role: PermissionRole; -}; +import { useEffect } from 'react'; +import { useParams, useRouter } from 'next/navigation'; export default function PermissionsPage() { const params = useParams(); + const router = useRouter(); const projectName = params?.name as string; - // React Query hooks replace all manual state management - const { data: project } = useProject(projectName); - const { data: permissions = [], isLoading, error, refetch } = useProjectPermissions(projectName); - const addPermissionMutation = useAddProjectPermission(); - const removePermissionMutation = useRemoveProjectPermission(); - - // Local UI state - const [showGrantDialog, setShowGrantDialog] = useState(false); - const [grantForm, setGrantForm] = useState({ - subjectType: 'group', - subjectName: '', - role: 'view', - }); - const [grantError, setGrantError] = useState(null); - const userRole: PermissionRole | undefined = undefined; // TODO: Fetch from /projects/:name/access - - const [showRevokeDialog, setShowRevokeDialog] = useState(false); - const [toRevoke, setToRevoke] = useState<{ subjectType: SubjectType; subjectName: string; role: PermissionRole } | null>(null); - - // Check if user is admin - for now assume admin, or fetch from a separate endpoint - // TODO: Implement proper user role fetching from /projects/:name/access - const isAdmin = userRole === 'admin' || userRole === undefined; // Default to admin for now - - const handleGrant = useCallback(() => { - if (!grantForm.subjectName.trim()) { - setGrantError(`${grantForm.subjectType === 'group' ? 'Group' : 'User'} name is required`); - return; - } - - const key = `${grantForm.subjectType}:${grantForm.subjectName}`.toLowerCase(); - if (permissions.some((i) => `${i.subjectType}:${i.subjectName}`.toLowerCase() === key)) { - setGrantError('This subject already has access to the project'); - return; + // Redirect to main workspace page + useEffect(() => { + if (projectName) { + router.replace(`/projects/${projectName}?section=sharing`); } + }, [projectName, router]); - setGrantError(null); - addPermissionMutation.mutate( - { - projectName, - permission: { - subjectType: grantForm.subjectType, - subjectName: grantForm.subjectName, - role: grantForm.role, - }, - }, - { - onSuccess: () => { - successToast(`Permission granted to ${grantForm.subjectName} successfully`); - setShowGrantDialog(false); - setGrantForm({ subjectType: 'group', subjectName: '', role: 'view' }); - }, - onError: (error) => { - const message = error instanceof Error ? error.message : 'Failed to grant permission'; - setGrantError(message); - errorToast(message); - }, - } - ); - }, [grantForm, permissions, projectName, addPermissionMutation]); - - const handleRevoke = useCallback(() => { - if (!toRevoke) return; - - removePermissionMutation.mutate( - { - projectName, - subjectType: toRevoke.subjectType, - subjectName: toRevoke.subjectName, - }, - { - onSuccess: () => { - successToast(`Permission revoked from ${toRevoke.subjectName} successfully`); - setShowRevokeDialog(false); - setToRevoke(null); - }, - onError: (error) => { - errorToast(error instanceof Error ? error.message : 'Failed to revoke permission'); - }, - } - ); - }, [toRevoke, projectName, removePermissionMutation]); - - const emptyState = useMemo( - () => ( -
- -

No users or groups have access yet

- {isAdmin && ( - - )} -
- ), - [isAdmin] - ); - - if (!projectName || (isLoading && permissions.length === 0)) { - return ( -
-
- - Loading permissions... -
-
- ); - } - - return ( -
- - Permissions} - description={<>Manage user and group access for {project?.displayName || projectName}} - actions={ - <> - - {isAdmin && ( - - )} - - } - /> - - {/* Error state */} - {error && refetch()} />} - - {/* Mutation errors */} - {addPermissionMutation.isError && ( -
- -
- )} - {removePermissionMutation.isError && ( -
- -
- )} - - {!isAdmin && ( - - - -

- You have {userRole || 'view'} access. Only admins can grant or revoke permissions. -

-
-
- )} - - - - - - Permissions - - Users and groups with access to this project and their roles - - - {permissions.length > 0 ? ( - - - - Subject - Type - Role - {isAdmin && Actions} - - - - {permissions.map((p) => { - const roleConfig = ROLE_DEFINITIONS[p.role]; - const RoleIcon = roleConfig.icon; - const isRevokingThis = - removePermissionMutation.isPending && - removePermissionMutation.variables?.subjectName === p.subjectName && - removePermissionMutation.variables?.subjectType === p.subjectType; - - return ( - - {p.subjectName} - -
- {p.subjectType === 'group' ? ( - - ) : ( - - )} - {p.subjectType === 'group' ? 'Group' : 'User'} -
-
- - - - {roleConfig.label} - - - - {isAdmin && ( - - - - )} -
- ); - })} -
-
- ) : ( - emptyState - )} -
-
- - {/* Grant Permission Dialog */} - - - - Grant Permission - Add a user or group to this project with a role - -
-
- - { - if (addPermissionMutation.isPending) return; - setGrantForm((prev) => ({ ...prev, subjectType: value as SubjectType })); - }} - > - - Group - User - - -
-
- - setGrantForm((prev) => ({ ...prev, subjectName: e.target.value }))} - disabled={addPermissionMutation.isPending} - /> -
-
- -
- {Object.entries(ROLE_DEFINITIONS).map(([roleKey, roleConfig]) => { - const RoleIcon = roleConfig.icon; - const id = `role-${roleKey}`; - return ( -
- setGrantForm((prev) => ({ ...prev, role: roleKey as PermissionRole }))} - disabled={addPermissionMutation.isPending} - /> - -
- ); - })} -
-
- {grantError &&
{grantError}
} -
- - - - -
-
- - {/* Revoke Permission Dialog */} - -
- ); + return null; } diff --git a/components/frontend/src/app/projects/[name]/rfe/[id]/edit-repositories-dialog.tsx b/components/frontend/src/app/projects/[name]/rfe/[id]/edit-repositories-dialog.tsx deleted file mode 100644 index 4819179dd..000000000 --- a/components/frontend/src/app/projects/[name]/rfe/[id]/edit-repositories-dialog.tsx +++ /dev/null @@ -1,291 +0,0 @@ -"use client"; - -import React from "react"; -import { useForm, useFieldArray } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import * as z from "zod"; -import { Loader2, Plus, Trash2 } from "lucide-react"; - -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; -import type { RFEWorkflow } from "@/types/agentic-session"; - -const repoSchema = z.object({ - url: z.string().url("Please enter a valid repository URL"), - branch: z.string().min(1), -}); - -const formSchema = z - .object({ - umbrellaRepo: repoSchema, - supportingRepos: z.array(repoSchema), - }) - .refine( - (data) => { - // Check for duplicate repositories - const allUrls: string[] = []; - - if (data.umbrellaRepo?.url) { - allUrls.push(normalizeRepoUrl(data.umbrellaRepo.url)); - } - - const supportingUrls = (data.supportingRepos || []) - .filter((r) => r?.url) - .map((r) => normalizeRepoUrl(r.url)); - - allUrls.push(...supportingUrls); - - const uniqueUrls = new Set(allUrls); - return uniqueUrls.size === allUrls.length; - }, - { - message: - "Duplicate repository URLs are not allowed. Each repository must be unique.", - path: ["supportingRepos"], - } - ); - -type FormValues = z.infer; - -// Normalize repository URL for comparison (remove trailing slash and .git) -function normalizeRepoUrl(url: string): string { - return url.trim().toLowerCase().replace(/\.git$/, "").replace(/\/$/, ""); -} - -type EditRepositoriesDialogProps = { - open: boolean; - onOpenChange: (open: boolean) => void; - workflow: RFEWorkflow; - onSave: (data: { - umbrellaRepo: { url: string; branch?: string }; - supportingRepos: { url: string; branch?: string }[]; - }) => Promise; - isSaving: boolean; -}; - -export function EditRepositoriesDialog({ - open, - onOpenChange, - workflow, - onSave, - isSaving, -}: EditRepositoriesDialogProps) { - const form = useForm({ - resolver: zodResolver(formSchema), - mode: "onBlur", - defaultValues: { - umbrellaRepo: { - url: workflow.umbrellaRepo?.url || "", - branch: workflow.umbrellaRepo?.branch || "main", - }, - supportingRepos: (workflow.supportingRepos || []).map(r => ({ - url: r.url, - branch: r.branch || "main", - })), - }, - }); - - // Reset form when dialog opens with new workflow data - React.useEffect(() => { - if (open) { - form.reset({ - umbrellaRepo: { - url: workflow.umbrellaRepo?.url || "", - branch: workflow.umbrellaRepo?.branch || "main", - }, - supportingRepos: (workflow.supportingRepos || []).map(r => ({ - url: r.url, - branch: r.branch || "main", - })), - }); - } - }, [open, workflow, form]); - - const { fields, append, remove } = useFieldArray({ - control: form.control, - name: "supportingRepos", - }); - - const onSubmit = async (values: FormValues) => { - await onSave({ - umbrellaRepo: { - url: values.umbrellaRepo.url.trim(), - branch: values.umbrellaRepo.branch?.trim() || "main", - }, - supportingRepos: (values.supportingRepos || []) - .filter((r) => r && r.url && r.url.trim() !== "") - .map((r) => ({ url: r.url.trim(), branch: r.branch?.trim() || "main" })), - }); - }; - - return ( - - - - Edit Repositories - - Update the repository URLs and branches. Make sure you have push access - to all repositories. - - - -
- - {/* Spec Repository */} -
-
Spec Repository (Required)
- - ( - - Repository URL - - - - - Main repository for specs and planning documents - - - - )} - /> - - ( - - Base Branch - - - - - Feature branch will be created from this base branch - - - - )} - /> -
- - {/* Supporting Repositories */} -
-
-
- Supporting Repositories (Optional) -
- -
- - {fields.length === 0 && ( -

- No supporting repositories. Click “Add Repository” to add one. -

- )} - - {fields.map((field, index) => ( -
-
-
- Supporting Repository {index + 1} -
- -
- - ( - - Repository URL - - - - - - )} - /> - - ( - - Base Branch - - - - - - )} - /> -
- ))} -
- - - - - -
- -
-
- ); -} diff --git a/components/frontend/src/app/projects/[name]/rfe/[id]/error.tsx b/components/frontend/src/app/projects/[name]/rfe/[id]/error.tsx deleted file mode 100644 index d61cda8aa..000000000 --- a/components/frontend/src/app/projects/[name]/rfe/[id]/error.tsx +++ /dev/null @@ -1,37 +0,0 @@ -'use client'; - -import { useEffect } from 'react'; -import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { AlertCircle } from 'lucide-react'; - -export default function RfeDetailError({ - error, - reset, -}: { - error: Error & { digest?: string }; - reset: () => void; -}) { - useEffect(() => { - console.error('RFE detail error:', error); - }, [error]); - - return ( -
- - -
- - Failed to load RFE workflow -
- - {error.message || 'An unexpected error occurred while loading this RFE workflow.'} - -
- - - -
-
- ); -} diff --git a/components/frontend/src/app/projects/[name]/rfe/[id]/loading.tsx b/components/frontend/src/app/projects/[name]/rfe/[id]/loading.tsx deleted file mode 100644 index 4d2604065..000000000 --- a/components/frontend/src/app/projects/[name]/rfe/[id]/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { DetailPageSkeleton } from '@/components/skeletons'; - -export default function RfeDetailLoading() { - return ; -} diff --git a/components/frontend/src/app/projects/[name]/rfe/[id]/not-found.tsx b/components/frontend/src/app/projects/[name]/rfe/[id]/not-found.tsx deleted file mode 100644 index ee5ed979c..000000000 --- a/components/frontend/src/app/projects/[name]/rfe/[id]/not-found.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { FileQuestion } from 'lucide-react'; - -export default function RfeNotFound() { - return ( -
- - -
- - RFE workflow not found -
- - The RFE workflow you're looking for doesn't exist or has been deleted. - -
- - - -
-
- ); -} diff --git a/components/frontend/src/app/projects/[name]/rfe/[id]/page.tsx b/components/frontend/src/app/projects/[name]/rfe/[id]/page.tsx deleted file mode 100644 index ea97d2107..000000000 --- a/components/frontend/src/app/projects/[name]/rfe/[id]/page.tsx +++ /dev/null @@ -1,332 +0,0 @@ -"use client"; - -import { useEffect, useState, useCallback } from "react"; -import Link from "next/link"; -import { useParams, useRouter } from "next/navigation"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { WorkflowPhase } from "@/types/agentic-session"; -import { ArrowLeft, Loader2 } from "lucide-react"; -import RepoBrowser from "@/components/RepoBrowser"; -import type { GitHubFork } from "@/types"; -import { Breadcrumbs } from "@/components/breadcrumbs"; -import { RfeSessionsTable } from "./rfe-sessions-table"; -import { RfePhaseCards } from "./rfe-phase-cards"; -import { RfeWorkspaceCard } from "./rfe-workspace-card"; -import { RfeHeader } from "./rfe-header"; -import { RfeAgentsCard } from "./rfe-agents-card"; -import { useRfeWorkflow, useRfeWorkflowSessions, useDeleteRfeWorkflow, useRfeWorkflowSeeding, useSeedRfeWorkflow, useUpdateRfeWorkflow, useRepoBlob, useRepoTree, useOpenJiraIssue } from "@/services/queries"; - -export default function ProjectRFEDetailPage() { - const params = useParams(); - const router = useRouter(); - const project = params?.name as string; - const id = params?.id as string; - - // React Query hooks - const { data: workflow, isLoading: loading, refetch: load } = useRfeWorkflow(project, id); - const { data: rfeSessions = [], refetch: loadSessions } = useRfeWorkflowSessions(project, id); - const deleteWorkflowMutation = useDeleteRfeWorkflow(); - const { data: seedingData, isLoading: checkingSeeding, error: seedingQueryError, refetch: refetchSeeding } = useRfeWorkflowSeeding(project, id); - const seedWorkflowMutation = useSeedRfeWorkflow(); - const updateWorkflowMutation = useUpdateRfeWorkflow(); - const { openJiraForPath } = useOpenJiraIssue(project, id); - - // Extract repo info from workflow - const repo = workflow?.umbrellaRepo?.url.replace(/^https?:\/\/(?:www\.)?github.com\//i, '').replace(/\.git$/i, '') || ''; - // Use feature branch if available, otherwise fall back to base branch - const ref = workflow?.branchName || workflow?.umbrellaRepo?.branch || 'main'; - const hasRepoInfo = !!workflow?.umbrellaRepo && !!repo; - - // Fetch rfe.md - const { data: rfeBlob } = useRepoBlob( - project, - { repo, ref, path: 'rfe.md' }, - { enabled: hasRepoInfo } - ); - - // Fetch specs directory tree - const { data: specsTree } = useRepoTree( - project, - { repo, ref, path: 'specs' }, - { enabled: hasRepoInfo } - ); - - // Find first subdirectory in specs tree - const firstSubDir = specsTree?.entries?.find((e: { type: string; name?: string }) => e.type === 'tree')?.name || ''; - const subPath = firstSubDir ? `specs/${firstSubDir}` : ''; - - // Fetch spec files from subdirectory (files are always in subdirs, never in root specs/) - const { data: specBlob } = useRepoBlob( - project, - { repo, ref, path: subPath ? `${subPath}/spec.md` : '' }, - { enabled: hasRepoInfo && !!subPath } - ); - const { data: planBlob } = useRepoBlob( - project, - { repo, ref, path: subPath ? `${subPath}/plan.md` : '' }, - { enabled: hasRepoInfo && !!subPath } - ); - const { data: tasksBlob } = useRepoBlob( - project, - { repo, ref, path: subPath ? `${subPath}/tasks.md` : '' }, - { enabled: hasRepoInfo && !!subPath } - ); - - const [error, setError] = useState(null); - // const [advancing, _setAdvancing] = useState(false); - const [startingPhase, setStartingPhase] = useState(null); - // Workspace (PVC) removed: Git remote is source of truth - const [activeTab, setActiveTab] = useState("overview"); - const [selectedFork] = useState(undefined); - - // const [specBaseRelPath, _setSpecBaseRelPath] = useState("specs"); - const [publishingPhase, setPublishingPhase] = useState(null); - - const [selectedAgents, setSelectedAgents] = useState([]); - - // Process rfe.md blob data - const [rfeDoc, setRfeDoc] = useState<{ exists: boolean; content: string }>({ exists: false, content: "" }); - useEffect(() => { - if (!rfeBlob) return; - - (async () => { - if (rfeBlob.ok) { - const content = await rfeBlob.clone().text(); - setRfeDoc({ exists: true, content }); - } else { - setRfeDoc({ exists: false, content: '' }); - } - })(); - }, [rfeBlob]); - - // Process spec kit blobs from subdirectory - const [specKitDir, setSpecKitDir] = useState<{ - spec: { exists: boolean; content: string; }, - plan: { exists: boolean; content: string; }, - tasks: { exists: boolean; content: string; } - }>({ - spec: { exists: false, content: '' }, - plan: { exists: false, content: '' }, - tasks: { exists: false, content: '' } - }); - - useEffect(() => { - (async () => { - const specData = specBlob?.ok - ? { exists: true, content: await specBlob.clone().text() } - : { exists: false, content: '' }; - - const planData = planBlob?.ok - ? { exists: true, content: await planBlob.clone().text() } - : { exists: false, content: '' }; - - const tasksData = tasksBlob?.ok - ? { exists: true, content: await tasksBlob.clone().text() } - : { exists: false, content: '' }; - - setSpecKitDir({ spec: specData, plan: planData, tasks: tasksData }); - })(); - }, [specBlob, planBlob, tasksBlob]); - - const firstFeaturePath = subPath; - - - const deleteWorkflow = useCallback(async () => { - if (!confirm('Are you sure you want to delete this RFE workflow? This action cannot be undone.')) { - return; - } - return new Promise((resolve, reject) => { - deleteWorkflowMutation.mutate( - { projectName: project, workflowId: id }, - { - onSuccess: () => { - router.push(`/projects/${encodeURIComponent(project)}/rfe`); - resolve(); - }, - onError: (err) => { - setError(err.message || 'Failed to delete workflow'); - reject(err); - }, - } - ); - }); - }, [project, id, deleteWorkflowMutation, router]); - - const seedWorkflow = useCallback(async () => { - return new Promise((resolve, reject) => { - seedWorkflowMutation.mutate( - { projectName: project, workflowId: id }, - { - onSuccess: () => { - resolve(); - }, - onError: (err) => { - // Don't set page-level error - let RfeWorkspaceCard show the inline error - // The error is available via seedWorkflowMutation.error - reject(err); - }, - } - ); - }); - }, [project, id, seedWorkflowMutation]); - - const updateRepositories = useCallback(async (data: { umbrellaRepo: { url: string; branch?: string }; supportingRepos: { url: string; branch?: string }[] }) => { - return new Promise((resolve, reject) => { - updateWorkflowMutation.mutate( - { - projectName: project, - workflowId: id, - data: { - umbrellaRepo: data.umbrellaRepo, - supportingRepos: data.supportingRepos, - }, - }, - { - onSuccess: () => { - // Refetch workflow to get updated data - load(); - // Also refetch seeding status to clear any errors - refetchSeeding(); - // Clear any previous seeding errors - seedWorkflowMutation.reset(); - resolve(); - }, - onError: (err) => { - setError(err.message || 'Failed to update repositories'); - reject(err); - }, - } - ); - }); - }, [project, id, updateWorkflowMutation, load, refetchSeeding]); - - - if (loading) return ( -
- -
- ); - if (error || !workflow) return ( -
- - -

{error || "Not found"}

- - - -
-
-
- ); - - const workflowWorkspace = workflow.workspacePath || `/rfe-workflows/${id}/workspace`; - const upstreamRepo = workflow?.umbrellaRepo?.url || ""; - - // Seeding status from React Query - const isSeeded = seedingData?.isSeeded || false; - // Combine seed mutation error with check-seeding query error - const seedingError = seedWorkflowMutation.error?.message || seedingQueryError?.message; - // Track if we've completed the initial seeding check - const hasCheckedSeeding = seedingData !== undefined || !!seedingQueryError; - const seedingStatus = { - checking: checkingSeeding, - isSeeded, - error: seedingError, - hasChecked: hasCheckedSeeding, - }; - - return ( -
-
- - - - - - - - - - - - Overview - Sessions - {upstreamRepo ? Repository : null} - - - - { await load(); }} - onLoadSessions={async () => { await loadSessions(); }} - onError={setError} - onOpenJira={openJiraForPath} - /> - - - - - - - - - - - - -
-
- ); -} diff --git a/components/frontend/src/app/projects/[name]/rfe/[id]/rfe-agents-card.tsx b/components/frontend/src/app/projects/[name]/rfe/[id]/rfe-agents-card.tsx deleted file mode 100644 index bc46fb0f2..000000000 --- a/components/frontend/src/app/projects/[name]/rfe/[id]/rfe-agents-card.tsx +++ /dev/null @@ -1,105 +0,0 @@ -"use client"; - -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; -import { Checkbox } from "@/components/ui/checkbox"; -import { Bot, Loader2 } from "lucide-react"; -import { AVAILABLE_AGENTS } from "@/lib/agents"; -import { useRfeWorkflowAgents } from "@/services/queries"; - -type RfeAgentsCardProps = { - projectName: string; - workflowId: string; - selectedAgents: string[]; - onAgentsChange: (agents: string[]) => void; -}; - -export function RfeAgentsCard({ - projectName, - workflowId, - selectedAgents, - onAgentsChange, -}: RfeAgentsCardProps) { - // Use React Query hook for agents - const { data: repoAgents = AVAILABLE_AGENTS, isLoading: loadingAgents } = useRfeWorkflowAgents( - projectName, - workflowId - ); - - return ( - - - - - Agents - - - {loadingAgents ? 'Loading agents from repository...' : 'Select agents to participate in workflow sessions'} - - - - {loadingAgents ? ( -
- -
- ) : repoAgents.length === 0 ? ( -
- -

No agents found in repository .claude/agents directory

-

Seed the repository to add agent definitions

-
- ) : ( - <> -
- {repoAgents.map((agent) => { - const isSelected = selectedAgents.includes(agent.persona); - return ( -
- -
- ); - })} -
- {selectedAgents.length > 0 && ( -
-
Selected Agents ({selectedAgents.length})
-
- {selectedAgents.map(persona => { - const agent = repoAgents.find(a => a.persona === persona); - return agent ? ( - - {agent.name} - - ) : null; - })} -
-
- )} - - )} -
-
- ); -} - diff --git a/components/frontend/src/app/projects/[name]/rfe/[id]/rfe-header.tsx b/components/frontend/src/app/projects/[name]/rfe/[id]/rfe-header.tsx deleted file mode 100644 index e65175b89..000000000 --- a/components/frontend/src/app/projects/[name]/rfe/[id]/rfe-header.tsx +++ /dev/null @@ -1,35 +0,0 @@ -"use client"; - -import { Button } from "@/components/ui/button"; -import { Loader2, Trash2 } from "lucide-react"; -import type { RFEWorkflow } from "@/types/agentic-session"; - -type RfeHeaderProps = { - workflow: RFEWorkflow; - deleting: boolean; - onDelete: () => Promise; -}; - -export function RfeHeader({ workflow, deleting, onDelete }: RfeHeaderProps) { - return ( -
-
-

{workflow.title}

-

{workflow.description}

-
- -
- ); -} diff --git a/components/frontend/src/app/projects/[name]/rfe/[id]/rfe-phase-cards.tsx b/components/frontend/src/app/projects/[name]/rfe/[id]/rfe-phase-cards.tsx deleted file mode 100644 index 46d76e142..000000000 --- a/components/frontend/src/app/projects/[name]/rfe/[id]/rfe-phase-cards.tsx +++ /dev/null @@ -1,446 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { CheckCircle2, Loader2, Play, Upload } from "lucide-react"; -import type { AgenticSession, CreateAgenticSessionRequest, RFEWorkflow, WorkflowPhase } from "@/types/agentic-session"; -import { WORKFLOW_PHASE_LABELS, AVAILABLE_AGENTS } from "@/lib/agents"; -import { useCreateSession, usePublishToJira } from "@/services/queries"; - -type RfePhaseCardsProps = { - workflow: RFEWorkflow; - rfeSessions: AgenticSession[]; - rfeDoc: { exists: boolean; content: string }; - specKitDir: { - spec: { exists: boolean; content: string }; - plan: { exists: boolean; content: string }; - tasks: { exists: boolean; content: string }; - }; - firstFeaturePath: string; - projectName: string; - rfeId: string; - workflowWorkspace: string; - isSeeded: boolean; - startingPhase: WorkflowPhase | null; - publishingPhase: WorkflowPhase | null; - selectedAgents: string[]; - onStartPhase: (phase: WorkflowPhase | null) => void; - onPublishPhase: (phase: WorkflowPhase | null) => void; - onLoad: () => Promise; - onLoadSessions: () => Promise; - onError: (error: string) => void; - onOpenJira: (path: string) => void; -}; - -export function RfePhaseCards({ - workflow, - rfeSessions, - rfeDoc, - specKitDir, - firstFeaturePath, - projectName, - rfeId, - workflowWorkspace, - isSeeded, - startingPhase, - publishingPhase, - selectedAgents, - onStartPhase, - onPublishPhase, - onLoad, - onLoadSessions, - onError, - onOpenJira, -}: RfePhaseCardsProps) { - const createSessionMutation = useCreateSession(); - const publishToJiraMutation = usePublishToJira(); - const phaseList = ["ideate", "specify", "plan", "tasks", "implement"] as const; - - // Helper function to generate agent instructions based on selected agents - const getAgentInstructions = () => { - if (selectedAgents.length === 0) return ''; - - const selectedAgentDetails = selectedAgents - .map(persona => AVAILABLE_AGENTS.find(a => a.persona === persona)) - .filter(Boolean); - - if (selectedAgentDetails.length === 0) return ''; - - const agentList = selectedAgentDetails - .map(agent => `- ${agent!.name} (${agent!.role})`) - .join('\n'); - - return `\n\nIMPORTANT - Selected Agents for this workflow: -The following agents have been selected to participate in this workflow. Invoke them by name to get their specialized perspectives: - -${agentList} - -You can invoke agents by using their name in your prompts. For example: "Let's get input from ${selectedAgentDetails[0]!.name} on this approach."`; - }; - - return ( - - - Phase Documents - - -
- {phaseList.map((phase) => { - const expected = (() => { - if (phase === "ideate") return "rfe.md"; - if (phase === "implement") return "implement"; - if (!firstFeaturePath) { - if (phase === "specify") return "spec.md"; - if (phase === "plan") return "plan.md"; - return "tasks.md"; - } - if (phase === "specify") return `${firstFeaturePath}/spec.md`; - if (phase === "plan") return `${firstFeaturePath}/plan.md`; - return `${firstFeaturePath}/tasks.md`; - })(); - - const exists = - phase === "ideate" - ? rfeDoc.exists - : phase === "specify" - ? specKitDir.spec.exists - : phase === "plan" - ? specKitDir.plan.exists - : phase === "tasks" - ? specKitDir.tasks.exists - : false; - - const linkedKey = Array.isArray( - (workflow as unknown as { jiraLinks?: Array<{ path: string; jiraKey: string }> }) - .jiraLinks - ) - ? ( - ( - workflow as unknown as { - jiraLinks?: Array<{ path: string; jiraKey: string }>; - } - ).jiraLinks || [] - ).find((l) => l.path === expected)?.jiraKey - : undefined; - - const sessionForPhase = rfeSessions.find( - (s) => s.metadata.labels?.["rfe-phase"] === phase - ); - const sessionDisplay = - sessionForPhase && typeof sessionForPhase.spec?.displayName === "string" - ? String(sessionForPhase.spec.displayName) - : sessionForPhase?.metadata.name; - - return ( -
-
-
- {WORKFLOW_PHASE_LABELS[phase]} - {expected} -
- {sessionForPhase && ( -
- } - } - > - - - {sessionForPhase?.status?.phase && ( - {sessionForPhase.status.phase} - )} -
- )} -
-
- {exists ? ( -
- - Ready -
- ) : ( - - {phase === "plan" - ? "requires spec.md" - : phase === "tasks" - ? "requires plan.md" - : phase === "implement" - ? "requires tasks.md" - : ""} - - )} - {!exists && - (phase === "ideate" ? ( - sessionForPhase && - (sessionForPhase.status?.phase === "Running" || - sessionForPhase.status?.phase === "Creating") ? ( - } - } - > - - - ) : ( - - ) - ) : ( - - ))} - {exists && phase !== "ideate" && ( - - )} - {exists && linkedKey && phase !== "ideate" && ( -
- {linkedKey} - -
- )} -
-
- ); - })} -
-
-
- ); -} diff --git a/components/frontend/src/app/projects/[name]/rfe/[id]/rfe-sessions-table.tsx b/components/frontend/src/app/projects/[name]/rfe/[id]/rfe-sessions-table.tsx deleted file mode 100644 index ee7417cd7..000000000 --- a/components/frontend/src/app/projects/[name]/rfe/[id]/rfe-sessions-table.tsx +++ /dev/null @@ -1,133 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { Plus } from "lucide-react"; -import { formatDistanceToNow } from "date-fns"; -import { AgenticSession, AgenticSessionPhase, WorkflowPhase } from "@/types/agentic-session"; -import { WORKFLOW_PHASE_LABELS } from "@/lib/agents"; -import { getPhaseColor } from "@/utils/session-helpers"; - -type RfeSessionsTableProps = { - sessions: AgenticSession[]; - projectName: string; - rfeId: string; - workspacePath: string; - workflowId: string; -}; - -export function RfeSessionsTable({ - sessions, - projectName, - rfeId, - workspacePath, - workflowId, -}: RfeSessionsTableProps) { - return ( - - -
-
- Agentic Sessions ({sessions.length}) - Sessions scoped to this RFE -
- - - -
-
- -
- - - - Name - Stage - Status - Model - Created - Cost - - - - {sessions.length === 0 ? ( - - - No agent sessions yet - - - ) : ( - sessions.map((s) => { - const labels = (s.metadata.labels || {}) as Record; - const name = s.metadata.name; - const display = s.spec?.displayName || name; - const rfePhase = - typeof labels["rfe-phase"] === "string" ? String(labels["rfe-phase"]) : ""; - const model = s.spec?.llmSettings?.model; - const created = s.metadata?.creationTimestamp - ? formatDistanceToNow(new Date(s.metadata.creationTimestamp), { addSuffix: true }) - : ""; - const cost = s.status?.total_cost_usd; - return ( - - - } - } - className="text-blue-600 hover:underline hover:text-blue-800 transition-colors block" - > -
{display}
- {display !== name &&
{name}
} - -
- - {WORKFLOW_PHASE_LABELS[rfePhase as WorkflowPhase] || rfePhase || "—"} - - - - {s.status?.phase || "Pending"} - - - - - {model || "—"} - - - - {created || } - - - {cost ? ( - - ${cost.toFixed?.(4) ?? cost} - - ) : ( - - )} - -
- ); - }) - )} -
-
-
-
-
- ); -} diff --git a/components/frontend/src/app/projects/[name]/rfe/[id]/rfe-workspace-card.tsx b/components/frontend/src/app/projects/[name]/rfe/[id]/rfe-workspace-card.tsx deleted file mode 100644 index 04b835a15..000000000 --- a/components/frontend/src/app/projects/[name]/rfe/[id]/rfe-workspace-card.tsx +++ /dev/null @@ -1,192 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { FolderTree, AlertCircle, Loader2, Sprout, CheckCircle2, GitBranch, Edit } from "lucide-react"; -import type { RFEWorkflow } from "@/types/agentic-session"; -import { EditRepositoriesDialog } from "./edit-repositories-dialog"; - -type RfeWorkspaceCardProps = { - workflow: RFEWorkflow; - workflowWorkspace: string; - isSeeded: boolean; - seedingStatus: { checking: boolean; hasChecked?: boolean }; - seedingError: string | null | undefined; - seeding: boolean; - onSeedWorkflow: () => Promise; - onUpdateRepositories: (data: { umbrellaRepo: { url: string; branch?: string }; supportingRepos: { url: string; branch?: string }[] }) => Promise; - updating: boolean; -}; - -export function RfeWorkspaceCard({ - workflow, - workflowWorkspace, - isSeeded, - seedingStatus, - seedingError, - seeding, - onSeedWorkflow, - onUpdateRepositories, - updating, -}: RfeWorkspaceCardProps) { - const [editDialogOpen, setEditDialogOpen] = useState(false); - - return ( - <> - { - await onUpdateRepositories(data); - setEditDialogOpen(false); - }} - isSaving={updating} - /> - - - - - Workspace & Repositories - - Shared workspace with spec repository (specs, planning docs, agent configs) and optional supporting repos - - -
Workspace: {workflowWorkspace}
- - {workflow.branchName && ( - - - Feature Branch - - All modifications will occur on feature branch{' '} - - {workflow.branchName} - - {' '}for all supplied repositories. - - - )} - - {(workflow as { parentOutcome?: string }).parentOutcome && ( -
- Parent Outcome:{' '} - {(workflow as { parentOutcome?: string }).parentOutcome} -
- )} - {(workflow.umbrellaRepo || (workflow.supportingRepos || []).length > 0) && ( -
- {workflow.umbrellaRepo && ( -
-
- Spec Repo: {workflow.umbrellaRepo.url} -
- {workflow.umbrellaRepo.branch && ( -
- Base branch: {workflow.umbrellaRepo.branch} - {workflow.branchName && ( - → Feature branch {workflow.branchName} {isSeeded ? 'set up' : 'will be set up'} - )} -
- )} -
- )} - {(workflow.supportingRepos || []).map( - (r: { url: string; branch?: string; clonePath?: string }, i: number) => ( -
-
- Supporting: {r.url} -
- {r.branch && ( -
- Base branch: {r.branch} - {workflow.branchName && ( - → Feature branch {workflow.branchName} {isSeeded ? 'set up' : 'will be set up'} - )} -
- )} -
- ) - )} -
- )} - - {!isSeeded && !seedingStatus.checking && seedingStatus.hasChecked && workflow.umbrellaRepo && ( - - - Spec Repository Not Seeded - -

- Before you can start working on phases, the spec repository needs to be seeded. - This will: -

-
    -
  • Set up the feature branch{workflow.branchName && ` (${workflow.branchName})`} from the base branch
  • -
  • Add Spec-Kit template files for spec-driven development
  • -
  • Add agent definition files in the .claude directory
  • -
- {seedingError && ( -
- Seeding Failed: {seedingError} -
- )} -
- - -
-
-
- )} - - {seedingStatus.checking && workflow.umbrellaRepo && ( -
- - Checking repository seeding status... -
- )} - - {isSeeded && ( -
-
- - Repository seeded and ready -
- -
- )} -
-
- - ); -} diff --git a/components/frontend/src/app/projects/[name]/rfe/error.tsx b/components/frontend/src/app/projects/[name]/rfe/error.tsx deleted file mode 100644 index 6ad6edac8..000000000 --- a/components/frontend/src/app/projects/[name]/rfe/error.tsx +++ /dev/null @@ -1,37 +0,0 @@ -'use client'; - -import { useEffect } from 'react'; -import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { AlertCircle } from 'lucide-react'; - -export default function RfeWorkflowsError({ - error, - reset, -}: { - error: Error & { digest?: string }; - reset: () => void; -}) { - useEffect(() => { - console.error('RFE workflows page error:', error); - }, [error]); - - return ( -
- - -
- - Failed to load RFE workflows -
- - {error.message || 'An unexpected error occurred while loading RFE workflows.'} - -
- - - -
-
- ); -} diff --git a/components/frontend/src/app/projects/[name]/rfe/loading.tsx b/components/frontend/src/app/projects/[name]/rfe/loading.tsx deleted file mode 100644 index 20ba81e1b..000000000 --- a/components/frontend/src/app/projects/[name]/rfe/loading.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { ListSkeleton } from '@/components/skeletons'; - -export default function RfeWorkflowsLoading() { - return ; -} diff --git a/components/frontend/src/app/projects/[name]/rfe/new/page.tsx b/components/frontend/src/app/projects/[name]/rfe/new/page.tsx deleted file mode 100644 index 9b6ba76b1..000000000 --- a/components/frontend/src/app/projects/[name]/rfe/new/page.tsx +++ /dev/null @@ -1,387 +0,0 @@ -'use client'; - -import React from 'react'; -import { useParams, useRouter } from 'next/navigation'; -import { useForm, useFieldArray } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import * as z from 'zod'; -import Link from 'next/link'; -import { Loader2, GitBranch } from 'lucide-react'; - -import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; -import { Input } from '@/components/ui/input'; -import { Textarea } from '@/components/ui/textarea'; -import { ErrorMessage } from '@/components/error-message'; - -import { useCreateRfeWorkflow } from '@/services/queries'; -import { successToast, errorToast } from '@/hooks/use-toast'; -import { Breadcrumbs } from '@/components/breadcrumbs'; -import type { CreateRFEWorkflowRequest } from '@/types/api'; - -const repoSchema = z.object({ - url: z.string().url('Please enter a valid repository URL'), - branch: z.string().min(1, 'Branch is required').default('main'), -}); - -const formSchema = z.object({ - title: z.string().min(5, 'Title must be at least 5 characters long'), - description: z.string().min(20, 'Description must be at least 20 characters long'), - branchName: z.string().min(1, 'Branch name is required'), - workspacePath: z.string().optional(), - parentOutcome: z.string().optional(), - umbrellaRepo: repoSchema, - supportingRepos: z.array(repoSchema).optional().default([]), -}).refine( - (data) => { - // Check for duplicate repositories - const allUrls: string[] = []; - - // Add umbrella repo URL if present - if (data.umbrellaRepo?.url) { - allUrls.push(normalizeRepoUrl(data.umbrellaRepo.url)); - } - - // Add supporting repo URLs if present - const supportingUrls = (data.supportingRepos || []) - .filter(r => r?.url) - .map(r => normalizeRepoUrl(r.url)); - - allUrls.push(...supportingUrls); - - // Check for duplicates - const uniqueUrls = new Set(allUrls); - return uniqueUrls.size === allUrls.length; - }, - { - message: 'Duplicate repository URLs are not allowed. Each repository must be unique.', - path: ['supportingRepos'], - } -); - -type FormValues = z.input; - -// Normalize repository URL for comparison (remove trailing slash and .git) -function normalizeRepoUrl(url: string): string { - return url.trim().toLowerCase().replace(/\.git$/, '').replace(/\/$/, ''); -} - -// Generate branch name from title (ambient-first-three-words) -function generateBranchName(title: string): string { - const normalized = title.toLowerCase().trim(); - const words = normalized - .split(/[^a-z0-9]+/) - .filter((w) => w.length > 0) - .slice(0, 3); - return words.length > 0 ? `ambient-${words.join('-')}` : ''; -} - -export default function ProjectNewRFEWorkflowPage() { - const router = useRouter(); - const params = useParams(); - const projectName = params?.name as string; - - // React Query mutation replaces manual fetch - const createWorkflowMutation = useCreateRfeWorkflow(); - - const form = useForm({ - resolver: zodResolver(formSchema), - mode: 'onBlur', - defaultValues: { - title: '', - description: '', - branchName: '', - workspacePath: '', - parentOutcome: '', - umbrellaRepo: { url: '', branch: 'main' }, - supportingRepos: [], - }, - }); - - const { fields, append, remove } = useFieldArray({ - control: form.control, - name: 'supportingRepos', - }); - - // Watch the title field and auto-populate branchName - const title = form.watch('title'); - const branchName = form.watch('branchName'); - - // Auto-populate branch name when title changes - // This will only update if user hasn't manually edited the branch name - React.useEffect(() => { - const generatedName = generateBranchName(title); - const currentBranchName = form.getValues('branchName'); - - // Only auto-populate if: - // 1. There's a generated name - // 2. Current branch name is empty or matches the previously auto-generated name - if (generatedName && (!currentBranchName || currentBranchName.startsWith('ambient-'))) { - form.setValue('branchName', generatedName, { shouldValidate: false, shouldDirty: false }); - } - }, [title, form]); - - const onSubmit = async (values: FormValues) => { - const request: CreateRFEWorkflowRequest = { - title: values.title, - description: values.description, - branchName: values.branchName.trim(), - workspacePath: values.workspacePath || undefined, - parentOutcome: values.parentOutcome?.trim() || undefined, - umbrellaRepo: { - url: values.umbrellaRepo.url.trim(), - branch: (values.umbrellaRepo.branch || 'main').trim(), - }, - supportingRepos: (values.supportingRepos || []) - .filter((r) => r && r.url && r.url.trim() !== '') - .map((r) => ({ url: r.url.trim(), branch: r.branch?.trim() || 'main' })), - }; - - createWorkflowMutation.mutate( - { projectName, data: request }, - { - onSuccess: (workflow) => { - successToast(`RFE workspace "${values.title}" created successfully`); - router.push(`/projects/${encodeURIComponent(projectName)}/rfe/${encodeURIComponent(workflow.id)}`); - }, - onError: (error) => { - errorToast(error instanceof Error ? error.message : 'Failed to create RFE workflow'); - }, - } - ); - }; - - return ( -
-
- -
-

Create RFE Workspace

-

Set up a new Request for Enhancement workflow with AI agents

-
- - {/* Error state from mutation */} - {createWorkflowMutation.isError && ( -
- -
- )} - -
- { - e.preventDefault(); - form.handleSubmit(onSubmit)(e); - }} - className="space-y-8" - > - - - - - RFE Details - - Provide basic information about the feature or enhancement - - - ( - - RFE Title - - - - - A concise title that describes the feature or enhancement.{' '} - This title will be used to generate the feature branch name. - - - - )} - /> - ( - - Description - -