Skip to content

Commit c304766

Browse files
sallyomclaude
andcommitted
feat: real-time repo status polling and shorter session names
Architecture changes: - Frontend now polls runner directly via backend API for real-time branch info - Removed operator polling of runner (was never working) - Session names changed from 'agentic-session-{timestamp}' to 'ambient-{xxxx}' Backend: - Added GetReposStatus handler that proxies runner's /repos/status endpoint - New route: GET /repos/status returns real-time git branch information Frontend: - Added getReposStatus API function and useReposStatus hook - 30-second polling for real-time branch updates - Updated page.tsx to use real-time status for File Explorer and Context Modal - Branch badges now update within 30 seconds of git changes Operator: - Simplified repo reconciliation (no more polling runner for branch info) - Shortened session name format to ambient-{last-4-digits} Benefits: - Branch badges update in ~30 seconds (previously never worked) - No truncation of repo names (shorter branch names) - Less operator overhead - More responsive UI 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
1 parent b4f46e9 commit c304766

File tree

6 files changed

+116
-77
lines changed

6 files changed

+116
-77
lines changed

components/backend/handlers/sessions.go

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -555,7 +555,7 @@ func CreateSession(c *gin.Context) {
555555

556556
// Generate unique name
557557
timestamp := time.Now().Unix()
558-
name := fmt.Sprintf("agentic-session-%d", timestamp)
558+
name := fmt.Sprintf("ambient-%d", timestamp%10000)
559559

560560
// Create the custom resource
561561
// Metadata
@@ -3069,6 +3069,58 @@ func DiffSessionRepo(c *gin.Context) {
30693069
c.Data(resp.StatusCode, resp.Header.Get("Content-Type"), bodyBytes)
30703070
}
30713071

3072+
// GetReposStatus returns current status of all repositories (branches, current branch, etc.)
3073+
// GET /api/projects/:projectName/agentic-sessions/:sessionName/repos/status
3074+
func GetReposStatus(c *gin.Context) {
3075+
project := c.Param("projectName")
3076+
session := c.Param("sessionName")
3077+
3078+
k8sClt, _ := GetK8sClientsForRequest(c)
3079+
if k8sClt == nil {
3080+
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing token"})
3081+
c.Abort()
3082+
return
3083+
}
3084+
3085+
// Call runner's /repos/status endpoint directly
3086+
runnerURL := fmt.Sprintf("http://session-%s.%s.svc.cluster.local:8000/repos/status", session, project)
3087+
3088+
req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, runnerURL, nil)
3089+
if err != nil {
3090+
log.Printf("GetReposStatus: failed to create HTTP request: %v", err)
3091+
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create request"})
3092+
return
3093+
}
3094+
if v := c.GetHeader("Authorization"); v != "" {
3095+
req.Header.Set("Authorization", v)
3096+
}
3097+
3098+
client := &http.Client{Timeout: 5 * time.Second}
3099+
resp, err := client.Do(req)
3100+
if err != nil {
3101+
log.Printf("GetReposStatus: runner not reachable: %v", err)
3102+
// Return empty repos list instead of error for better UX
3103+
c.JSON(http.StatusOK, gin.H{"repos": []interface{}{}})
3104+
return
3105+
}
3106+
defer resp.Body.Close()
3107+
3108+
bodyBytes, err := io.ReadAll(resp.Body)
3109+
if err != nil {
3110+
log.Printf("GetReposStatus: failed to read response body: %v", err)
3111+
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read response from runner"})
3112+
return
3113+
}
3114+
3115+
if resp.StatusCode != http.StatusOK {
3116+
log.Printf("GetReposStatus: runner returned status %d", resp.StatusCode)
3117+
c.JSON(http.StatusOK, gin.H{"repos": []interface{}{}})
3118+
return
3119+
}
3120+
3121+
c.Data(http.StatusOK, "application/json", bodyBytes)
3122+
}
3123+
30723124
// GetGitStatus returns git status for a directory in the workspace
30733125
// GET /api/projects/:projectName/agentic-sessions/:sessionName/git/status?path=artifacts
30743126
func GetGitStatus(c *gin.Context) {

components/backend/routes.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ func registerRoutes(r *gin.Engine) {
6666
projectGroup.GET("/agentic-sessions/:sessionName/workflow/metadata", handlers.GetWorkflowMetadata)
6767
projectGroup.POST("/agentic-sessions/:sessionName/repos", handlers.AddRepo)
6868
projectGroup.DELETE("/agentic-sessions/:sessionName/repos/:repoName", handlers.RemoveRepo)
69+
projectGroup.GET("/agentic-sessions/:sessionName/repos/status", handlers.GetReposStatus)
6970
projectGroup.PUT("/agentic-sessions/:sessionName/displayname", handlers.UpdateSessionDisplayName)
7071

7172
// OAuth integration - requires user auth like all other session endpoints

components/frontend/src/app/projects/[name]/sessions/[sessionName]/page.tsx

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ import {
8181
useStopSession,
8282
useDeleteSession,
8383
useContinueSession,
84+
useReposStatus,
8485
} from "@/services/queries";
8586
import {
8687
useWorkspaceList,
@@ -190,6 +191,13 @@ export default function ProjectSessionDetailPage({
190191
// Extract phase for sidebar state management
191192
const phase = session?.status?.phase || "Pending";
192193

194+
// Fetch repos status directly from runner (real-time branch info)
195+
const { data: reposStatus } = useReposStatus(
196+
projectName,
197+
sessionName,
198+
phase === "Running" // Only poll when session is running
199+
);
200+
193201
// AG-UI streaming hook - replaces useSessionMessages and useSendChatMessage
194202
// Note: autoConnect is intentionally false to avoid SSR hydration mismatch
195203
// Connection is triggered manually in useEffect after client hydration
@@ -605,9 +613,8 @@ export default function ProjectSessionDetailPage({
605613
{ type: "file-uploads", name: "File Uploads", path: "file-uploads" },
606614
];
607615

608-
// Use reconciledRepos when available (contains current active branch info),
609-
// otherwise fall back to spec.repos
610-
const reposToDisplay = session?.status?.reconciledRepos || session?.spec?.repos || [];
616+
// Use real-time repos status from runner when available, otherwise fall back to CR status
617+
const reposToDisplay = reposStatus?.repos || session?.status?.reconciledRepos || session?.spec?.repos || [];
611618

612619
// Deduplicate repos by name - only show one entry per repo directory
613620
const seenRepos = new Set<string>();
@@ -642,7 +649,7 @@ export default function ProjectSessionDetailPage({
642649
}
643650

644651
return options;
645-
}, [session, workflowManagement.activeWorkflow]);
652+
}, [session, workflowManagement.activeWorkflow, reposStatus]);
646653

647654
// Workflow change handler
648655
const handleWorkflowChange = (value: string) => {
@@ -1544,7 +1551,7 @@ export default function ProjectSessionDetailPage({
15441551
/>
15451552

15461553
<RepositoriesAccordion
1547-
repositories={session?.status?.reconciledRepos || session?.spec?.repos || []}
1554+
repositories={reposStatus?.repos || session?.status?.reconciledRepos || session?.spec?.repos || []}
15481555
uploadedFiles={fileUploadsList.map((f) => ({
15491556
name: f.name,
15501557
path: f.path,
@@ -1644,10 +1651,13 @@ export default function ProjectSessionDetailPage({
16441651
</SelectTrigger>
16451652
<SelectContent>
16461653
{directoryOptions.map((opt) => {
1647-
// Find branch info for repo directories
1654+
// Find branch info for repo directories from real-time status
16481655
let branchName: string | undefined;
16491656
if (opt.type === "repo") {
1650-
const repo = session?.status?.reconciledRepos?.find(
1657+
// Try real-time repos status first, then fall back to CR status
1658+
const repo = reposStatus?.repos?.find(
1659+
(r) => r.name === opt.path
1660+
) || session?.status?.reconciledRepos?.find(
16511661
(r: ReconciledRepo) => {
16521662
const repoName = r.name || r.url?.split("/").pop()?.replace(".git", "");
16531663
return opt.path === repoName;

components/frontend/src/services/api/sessions.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,3 +221,28 @@ export async function getMcpStatus(
221221
`/projects/${projectName}/agentic-sessions/${sessionName}/mcp/status`
222222
);
223223
}
224+
225+
export type RepoStatus = {
226+
url: string;
227+
name: string;
228+
branches: string[];
229+
currentActiveBranch: string;
230+
defaultBranch: string;
231+
};
232+
233+
export type ReposStatusResponse = {
234+
repos: RepoStatus[];
235+
};
236+
237+
/**
238+
* Get current status of all repositories (branches, current branch, etc.)
239+
* Fetches directly from runner for real-time updates
240+
*/
241+
export async function getReposStatus(
242+
projectName: string,
243+
sessionName: string
244+
): Promise<ReposStatusResponse> {
245+
return apiClient.get<ReposStatusResponse>(
246+
`/projects/${projectName}/agentic-sessions/${sessionName}/repos/status`
247+
);
248+
}

components/frontend/src/services/queries/use-sessions.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ export const sessionKeys = {
2727
[...sessionKeys.detail(projectName, sessionName), 'messages'] as const,
2828
export: (projectName: string, sessionName: string) =>
2929
[...sessionKeys.detail(projectName, sessionName), 'export'] as const,
30+
reposStatus: (projectName: string, sessionName: string) =>
31+
[...sessionKeys.detail(projectName, sessionName), 'repos-status'] as const,
3032
};
3133

3234
/**
@@ -322,3 +324,17 @@ export function useSessionExport(projectName: string, sessionName: string, enabl
322324
staleTime: 60000, // Cache for 1 minute
323325
});
324326
}
327+
328+
/**
329+
* Hook to fetch repository status (branches, current branch) from runner
330+
* Polls every 30 seconds for real-time updates
331+
*/
332+
export function useReposStatus(projectName: string, sessionName: string, enabled: boolean = true) {
333+
return useQuery({
334+
queryKey: sessionKeys.reposStatus(projectName, sessionName),
335+
queryFn: () => sessionsApi.getReposStatus(projectName, sessionName),
336+
enabled: enabled && !!projectName && !!sessionName,
337+
refetchInterval: 30000, // Poll every 30 seconds
338+
staleTime: 25000, // Consider stale after 25 seconds
339+
});
340+
}

components/operator/internal/handlers/sessions.go

Lines changed: 4 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1606,51 +1606,17 @@ func reconcileSpecReposWithPatch(sessionNamespace, sessionName string, spec map[
16061606
}
16071607
}
16081608

1609-
// Poll runner for actual repository status (branches, currentActiveBranch, etc.)
1610-
// Note: This may return empty if runner is not ready or repos are still being cloned
1611-
actualRepos := pollRunnerReposStatus(runnerBaseURL)
1612-
1613-
// Update status to reflect the reconciled state with actual filesystem data
1609+
// Build simple reconciled status (frontend now polls runner directly for real-time branch info)
16141610
reconciled := make([]interface{}, 0, len(specRepos))
16151611
for _, repo := range specRepos {
16161612
repoName := deriveRepoNameFromURL(repo["url"])
1617-
1618-
// Find matching actual repo status from runner
1619-
var actualRepo map[string]interface{}
1620-
for _, actual := range actualRepos {
1621-
if actualName, ok := actual["name"].(string); ok && actualName == repoName {
1622-
actualRepo = actual
1623-
break
1624-
}
1625-
}
1626-
1627-
// Build reconciled entry with actual data if available
16281613
reconciledEntry := map[string]interface{}{
16291614
"url": repo["url"],
16301615
"name": repoName,
16311616
"branch": repo["branch"], // Intended branch from spec (deprecated)
16321617
"clonedAt": time.Now().UTC().Format(time.RFC3339),
1618+
"status": "Ready", // Simplified - frontend polls runner for detailed status
16331619
}
1634-
1635-
// Set status based on whether we have actual repo data
1636-
if actualRepo != nil {
1637-
reconciledEntry["status"] = "Ready"
1638-
// Add actual branch information from runner
1639-
if branches, ok := actualRepo["branches"].([]interface{}); ok {
1640-
reconciledEntry["branches"] = branches
1641-
}
1642-
if currentBranch, ok := actualRepo["currentActiveBranch"].(string); ok {
1643-
reconciledEntry["currentActiveBranch"] = currentBranch
1644-
}
1645-
if defaultBranch, ok := actualRepo["defaultBranch"].(string); ok {
1646-
reconciledEntry["defaultBranch"] = defaultBranch
1647-
}
1648-
} else {
1649-
// Repo not found in runner status - may still be cloning
1650-
reconciledEntry["status"] = "Cloning"
1651-
log.Printf("[Reconcile] Repo '%s' not found in runner status, marking as Cloning", repoName)
1652-
}
1653-
16541620
reconciled = append(reconciled, reconciledEntry)
16551621
}
16561622
statusPatch.SetField("reconciledRepos", reconciled)
@@ -2335,39 +2301,8 @@ func deriveRepoNameFromURL(repoURL string) string {
23352301
return "repo"
23362302
}
23372303

2338-
// pollRunnerReposStatus queries the runner's /repos/status endpoint to get actual filesystem state.
2339-
// Returns array of repo status objects with branches, currentActiveBranch, defaultBranch.
2340-
func pollRunnerReposStatus(runnerBaseURL string) []map[string]interface{} {
2341-
req, err := http.NewRequest("GET", runnerBaseURL+"/repos/status", nil)
2342-
if err != nil {
2343-
log.Printf("[PollRepos] Failed to create request: %v", err)
2344-
return []map[string]interface{}{}
2345-
}
2346-
2347-
client := &http.Client{Timeout: 5 * time.Second}
2348-
resp, err := client.Do(req)
2349-
if err != nil {
2350-
log.Printf("[PollRepos] Failed to poll runner: %v", err)
2351-
return []map[string]interface{}{}
2352-
}
2353-
defer resp.Body.Close()
2354-
2355-
if resp.StatusCode != http.StatusOK {
2356-
log.Printf("[PollRepos] Runner returned status %d", resp.StatusCode)
2357-
return []map[string]interface{}{}
2358-
}
2359-
2360-
var result struct {
2361-
Repos []map[string]interface{} `json:"repos"`
2362-
}
2363-
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
2364-
log.Printf("[PollRepos] Failed to decode response: %v", err)
2365-
return []map[string]interface{}{}
2366-
}
2367-
2368-
log.Printf("[PollRepos] Successfully polled %d repos from runner", len(result.Repos))
2369-
return result.Repos
2370-
}
2304+
// pollRunnerReposStatus removed - frontend now polls runner directly via backend API
2305+
// for real-time branch information. Operator no longer needs to maintain this in CR status.
23712306

23722307
// regenerateRunnerToken provisions a fresh ServiceAccount, Role, RoleBinding, and token Secret for a session.
23732308
// This is called when restarting sessions to ensure fresh tokens.

0 commit comments

Comments
 (0)