From dee64fbc427bffedff46d902be8621ddbc680505 Mon Sep 17 00:00:00 2001 From: Harsh Date: Wed, 11 Mar 2026 23:25:44 +0530 Subject: [PATCH] fix: return project-scoped orchestrators from sessions api Expose orchestrator links per project and keep them out of worker session stats so dashboard consumers stop collapsing multi-project state into one global orchestrator. --- packages/web/src/__tests__/api-routes.test.ts | 74 +++++++++++++++++++ packages/web/src/app/api/sessions/route.ts | 35 ++++++--- packages/web/src/lib/serialize.ts | 44 +++++++---- packages/web/src/lib/types.ts | 6 ++ 4 files changed, 135 insertions(+), 24 deletions(-) diff --git a/packages/web/src/__tests__/api-routes.test.ts b/packages/web/src/__tests__/api-routes.test.ts index 5f23c96f6..f2b4a0569 100644 --- a/packages/web/src/__tests__/api-routes.test.ts +++ b/packages/web/src/__tests__/api-routes.test.ts @@ -61,6 +61,31 @@ const testSessions: Session[] = [ }), ]; +const multiProjectSessions: Session[] = [ + makeSession({ + id: "app-orchestrator", + projectId: "my-app", + metadata: { role: "orchestrator" }, + }), + makeSession({ + id: "backend-3", + projectId: "my-app", + status: "working", + activity: "active", + }), + makeSession({ + id: "docs-orchestrator", + projectId: "docs-app", + metadata: { role: "orchestrator" }, + }), + makeSession({ + id: "docs-2", + projectId: "docs-app", + status: "review_pending", + activity: "idle", + }), +]; + // ── Mock Services ───────────────────────────────────────────────────── const mockSessionManager: SessionManager = { @@ -164,6 +189,14 @@ const mockConfig: OrchestratorConfig = { sessionPrefix: "my-app", scm: { plugin: "github", webhook: {} }, }, + "docs-app": { + name: "Docs App", + repo: "acme/docs-app", + path: "/tmp/docs-app", + defaultBranch: "main", + sessionPrefix: "docs", + scm: { plugin: "github" }, + }, }, notifiers: {}, notificationRouting: { urgent: [], action: [], warning: [], info: [] }, @@ -285,6 +318,47 @@ describe("API Routes", () => { metadataSpy.mockRestore(); vi.useRealTimers(); }); + + it("returns per-project orchestrators and excludes them from worker sessions", async () => { + (mockSessionManager.list as ReturnType).mockResolvedValueOnce( + multiProjectSessions, + ); + + const res = await sessionsGET(makeRequest("http://localhost:3000/api/sessions")); + expect(res.status).toBe(200); + const data = await res.json(); + + expect(data.orchestratorId).toBeNull(); + expect(data.orchestrators).toEqual([ + { id: "docs-orchestrator", projectId: "docs-app", projectName: "Docs App" }, + { id: "app-orchestrator", projectId: "my-app", projectName: "My App" }, + ]); + expect(data.sessions.map((session: { id: string }) => session.id)).toEqual([ + "backend-3", + "docs-2", + ]); + expect(data.stats.totalSessions).toBe(2); + }); + + it("supports project-scoped session queries for orchestrator detail views", async () => { + (mockSessionManager.list as ReturnType).mockImplementationOnce( + async (projectId?: string) => + multiProjectSessions.filter((session) => !projectId || session.projectId === projectId), + ); + + const res = await sessionsGET( + makeRequest("http://localhost:3000/api/sessions?project=docs-app"), + ); + expect(res.status).toBe(200); + const data = await res.json(); + + expect(data.orchestratorId).toBe("docs-orchestrator"); + expect(data.orchestrators).toEqual([ + { id: "docs-orchestrator", projectId: "docs-app", projectName: "Docs App" }, + ]); + expect(data.sessions.map((session: { id: string }) => session.id)).toEqual(["docs-2"]); + expect(mockSessionManager.list).toHaveBeenCalledWith("docs-app"); + }); }); // ── POST /api/spawn ──────────────────────────────────────────────── diff --git a/packages/web/src/app/api/sessions/route.ts b/packages/web/src/app/api/sessions/route.ts index f3f3c37d8..f35b73d8e 100644 --- a/packages/web/src/app/api/sessions/route.ts +++ b/packages/web/src/app/api/sessions/route.ts @@ -1,4 +1,4 @@ -import { ACTIVITY_STATE } from "@composio/ao-core"; +import { ACTIVITY_STATE, isOrchestratorSession } from "@composio/ao-core"; import { NextResponse } from "next/server"; import { getServices, getSCM } from "@/lib/services"; import { @@ -7,9 +7,9 @@ import { enrichSessionPR, enrichSessionsMetadata, computeStats, + listDashboardOrchestrators, } from "@/lib/serialize"; import { resolveGlobalPause } from "@/lib/global-pause"; -import { filterWorkerSessions, findOrchestratorSessionId } from "@/lib/project-utils"; const METADATA_ENRICH_TIMEOUT_MS = 3_000; const PR_ENRICH_TIMEOUT_MS = 4_000; @@ -42,22 +42,34 @@ export async function GET(request: Request) { const activeOnly = searchParams.get("active") === "true"; const { config, registry, sessionManager } = await getServices(); - const coreSessions = await sessionManager.list(); + const requestedProjectId = + projectFilter && projectFilter !== "all" && config.projects[projectFilter] + ? projectFilter + : undefined; + const coreSessions = await sessionManager.list(requestedProjectId); - const orchestratorId = findOrchestratorSessionId(coreSessions, projectFilter, config.projects); + const matchesRequestedProject = (session: { id: string; projectId: string }): boolean => { + if (!projectFilter || projectFilter === "all") return true; + if (session.projectId === projectFilter) return true; + return config.projects[session.projectId]?.sessionPrefix === projectFilter; + }; - let workerSessions = filterWorkerSessions(coreSessions, projectFilter, config.projects); + const visibleSessions = requestedProjectId + ? coreSessions + : coreSessions.filter(matchesRequestedProject); + const orchestrators = listDashboardOrchestrators(visibleSessions, config.projects); + const orchestratorId = orchestrators.length === 1 ? (orchestrators[0]?.id ?? null) : null; + + let workerSessions = visibleSessions.filter((session) => !isOrchestratorSession(session)); - // Convert to dashboard format let dashboardSessions = workerSessions.map(sessionToDashboard); - // Filter to active sessions only if requested (keep workerSessions in sync) if (activeOnly) { const activeIndices = dashboardSessions - .map((s, i) => (s.activity !== ACTIVITY_STATE.EXITED ? i : -1)) - .filter((i) => i !== -1); - workerSessions = activeIndices.map((i) => workerSessions[i]); - dashboardSessions = activeIndices.map((i) => dashboardSessions[i]); + .map((session, index) => (session.activity !== ACTIVITY_STATE.EXITED ? index : -1)) + .filter((index) => index !== -1); + workerSessions = activeIndices.map((index) => workerSessions[index]); + dashboardSessions = activeIndices.map((index) => dashboardSessions[index]); } const metadataSettled = await settlesWithin( @@ -89,6 +101,7 @@ export async function GET(request: Request) { sessions: dashboardSessions, stats: computeStats(dashboardSessions), orchestratorId, + orchestrators, globalPause: resolveGlobalPause(coreSessions), }); } catch (err) { diff --git a/packages/web/src/lib/serialize.ts b/packages/web/src/lib/serialize.ts index d38ae5ff9..d8668cb49 100644 --- a/packages/web/src/lib/serialize.ts +++ b/packages/web/src/lib/serialize.ts @@ -5,17 +5,23 @@ * (string dates, flattened DashboardPR) suitable for JSON serialization. */ -import type { - Session, - Agent, - SCM, - PRInfo, - Tracker, - ProjectConfig, - OrchestratorConfig, - PluginRegistry, +import { + isOrchestratorSession, + type Session, + type Agent, + type SCM, + type PRInfo, + type Tracker, + type ProjectConfig, + type OrchestratorConfig, + type PluginRegistry, } from "@composio/ao-core"; -import type { DashboardSession, DashboardPR, DashboardStats } from "./types.js"; +import type { + DashboardSession, + DashboardPR, + DashboardStats, + DashboardOrchestratorLink, +} from "./types.js"; import { TTLCache, prCache, prCacheKey, type PREnrichmentData } from "./cache"; /** Cache for issue titles (5 min TTL — issue titles rarely change) */ @@ -55,9 +61,7 @@ export function sessionToDashboard(session: Session): DashboardSession { issueLabel: null, // Will be enriched by enrichSessionIssue() issueTitle: null, // Will be enriched by enrichSessionIssueTitle() summary, - summaryIsFallback: agentSummary - ? (session.agentInfo?.summaryIsFallback ?? false) - : false, + summaryIsFallback: agentSummary ? (session.agentInfo?.summaryIsFallback ?? false) : false, createdAt: session.createdAt.toISOString(), lastActivityAt: session.lastActivityAt.toISOString(), pr: session.pr ? basicPRToDashboard(session.pr) : null, @@ -65,6 +69,20 @@ export function sessionToDashboard(session: Session): DashboardSession { }; } +export function listDashboardOrchestrators( + sessions: Session[], + projects: Record, +): DashboardOrchestratorLink[] { + return sessions + .filter((session) => isOrchestratorSession(session)) + .map((session) => ({ + id: session.id, + projectId: session.projectId, + projectName: projects[session.projectId]?.name ?? session.projectId, + })) + .sort((a, b) => a.projectName.localeCompare(b.projectName) || a.id.localeCompare(b.id)); +} + /** * Convert minimal PRInfo to a DashboardPR with default values for enriched fields. * These defaults indicate "data not yet loaded" rather than "failing". diff --git a/packages/web/src/lib/types.ts b/packages/web/src/lib/types.ts index 65bd6cfe1..af9249385 100644 --- a/packages/web/src/lib/types.ts +++ b/packages/web/src/lib/types.ts @@ -130,6 +130,12 @@ export interface DashboardStats { needsReview: number; } +export interface DashboardOrchestratorLink { + id: string; + projectId: string; + projectName: string; +} + /** SSE snapshot event from /api/events */ export interface SSESnapshotEvent { type: "snapshot";