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.
This commit is contained in:
Harsh 2026-03-11 23:25:44 +05:30
parent 60a9c9fbb6
commit dee64fbc42
4 changed files with 135 additions and 24 deletions

View File

@ -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<typeof vi.fn>).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<typeof vi.fn>).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 ────────────────────────────────────────────────

View File

@ -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) {

View File

@ -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<string, ProjectConfig>,
): 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".

View File

@ -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";