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:
parent
60a9c9fbb6
commit
dee64fbc42
|
|
@ -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 ────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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".
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Reference in New Issue