Merge pull request #433 from ComposioHQ/fix/project-scoped-orchestrators

fix: scope orchestrators per project across cli and web
This commit is contained in:
Harsh Batheja 2026-03-12 08:14:39 +05:30 committed by GitHub
commit dbdbaa54ea
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 784 additions and 165 deletions

View File

@ -158,6 +158,25 @@ function buildSessionsFromDir(
});
}
function makeSession(overrides: Partial<Session> & { id: string; projectId: string }): Session {
return {
id: overrides.id,
projectId: overrides.projectId,
status: "working",
activity: null,
branch: null,
issueId: null,
pr: null,
workspacePath: null,
runtimeHandle: { id: overrides.id, runtimeName: "tmux", data: {} },
agentInfo: null,
createdAt: new Date(),
lastActivityAt: new Date(),
metadata: {},
...overrides,
} satisfies Session;
}
vi.mock("../../src/lib/create-session-manager.js", () => ({
getSessionManager: async (): Promise<SessionManager> => mockSessionManager as SessionManager,
}));
@ -730,4 +749,85 @@ describe("status command", () => {
expect(parsed[0].prNumber).toBeNull();
expect(mockDetectPR).not.toHaveBeenCalled();
});
it("shows one orchestrator per project without counting them as worker sessions", async () => {
mockConfigRef.current = {
...(mockConfigRef.current as Record<string, unknown>),
projects: {
"my-app": {
name: "My App",
repo: "org/my-app",
path: join(tmpDir, "main-repo"),
defaultBranch: "main",
sessionPrefix: "app",
scm: { plugin: "github" },
},
docs: {
name: "Docs",
repo: "org/docs",
path: join(tmpDir, "docs-repo"),
defaultBranch: "main",
sessionPrefix: "docs",
scm: { plugin: "github" },
},
},
} as Record<string, unknown>;
mockSessionManager.list.mockResolvedValue([
makeSession({
id: "app-orchestrator",
projectId: "my-app",
metadata: { role: "orchestrator", summary: "Manage app agents" },
}),
makeSession({ id: "app-1", projectId: "my-app", branch: "feat/app", activity: "active" }),
makeSession({
id: "docs-orchestrator",
projectId: "docs",
metadata: { role: "orchestrator" },
}),
]);
mockGit.mockResolvedValue(null);
mockIntrospect.mockResolvedValue(null);
await program.parseAsync(["node", "test", "status"]);
const output = consoleSpy.mock.calls.map((c) => c[0]).join("\n");
expect(output).toContain("Orchestrator:");
expect(output).toContain("app-orchestrator");
expect(output).toContain("docs-orchestrator");
expect(output).toContain("1 active session across 2 projects · 2 orchestrators");
});
it("includes orchestrators in JSON output with explicit roles", async () => {
mockSessionManager.list.mockResolvedValue([
makeSession({
id: "app-orchestrator",
projectId: "my-app",
metadata: { role: "orchestrator" },
}),
makeSession({
id: "app-1",
projectId: "my-app",
branch: "feat/json-worker",
activity: "ready",
}),
]);
mockGit.mockResolvedValue(null);
await program.parseAsync(["node", "test", "status", "--json"]);
const jsonCalls = consoleSpy.mock.calls.map((c) => c[0]).join("");
const parsed = JSON.parse(jsonCalls);
expect(parsed).toHaveLength(2);
expect(
parsed.find((entry: { name: string }) => entry.name === "app-orchestrator"),
).toMatchObject({
role: "orchestrator",
project: "my-app",
});
expect(parsed.find((entry: { name: string }) => entry.name === "app-1")).toMatchObject({
role: "worker",
project: "my-app",
});
});
});

View File

@ -10,6 +10,7 @@ import {
type ActivityState,
type Tracker,
type ProjectConfig,
isOrchestratorSession,
loadConfig,
} from "@composio/ao-core";
import { git, getTmuxSessions, getTmuxActivity } from "../lib/shell.js";
@ -27,6 +28,7 @@ import { getSessionManager } from "../lib/create-session-manager.js";
interface SessionInfo {
name: string;
role: "worker" | "orchestrator";
branch: string | null;
status: string | null;
summary: string | null;
@ -42,10 +44,6 @@ interface SessionInfo {
activity: ActivityState | null;
}
function isOrchestratorSession(session: Session): boolean {
return session.metadata["role"] === "orchestrator" || session.id.endsWith("-orchestrator");
}
async function gatherSessionInfo(
session: Session,
agent: Agent,
@ -122,6 +120,7 @@ async function gatherSessionInfo(
return {
name: session.id,
role: isOrchestratorSession(session) ? "orchestrator" : "worker",
branch,
status,
summary,
@ -193,6 +192,18 @@ function printSessionRow(info: SessionInfo): void {
}
}
function printOrchestratorRow(info: SessionInfo): void {
const lastActivity =
info.lastActivity === "-" ? chalk.dim("unknown") : chalk.dim(info.lastActivity);
console.log(
` ${chalk.magenta("Orchestrator:")} ${chalk.green(info.name)} ${chalk.dim("(")}${lastActivity}${chalk.dim(")")}`,
);
const displaySummary = info.claudeSummary || info.summary;
if (displaySummary) {
console.log(` ${chalk.dim(displaySummary.slice(0, 60))}`);
}
}
export function registerStatus(program: Command): void {
program
.command("status")
@ -234,8 +245,9 @@ export function registerStatus(program: Command): void {
// Show projects that have no sessions too (if not filtered)
const projectIds = opts.project ? [opts.project] : Object.keys(config.projects);
let totalSessions = 0;
const jsonOutput: SessionInfo[] = [];
let totalWorkers = 0;
let totalOrchestrators = 0;
for (const projectId of projectIds) {
const projectConfig = config.projects[projectId];
@ -262,27 +274,43 @@ export function registerStatus(program: Command): void {
continue;
}
totalSessions += projectSessions.length;
if (!opts.json) {
printTableHeader();
}
// Gather all session info in parallel
const infoPromises = projectSessions.map((s) => gatherSessionInfo(s, agent, scm, config));
const sessionInfos = await Promise.all(infoPromises);
const orchestrators = sessionInfos.filter((info) => info.role === "orchestrator");
const workers = sessionInfos.filter((info) => info.role === "worker");
totalWorkers += workers.length;
totalOrchestrators += orchestrators.length;
for (const info of sessionInfos) {
if (opts.json) {
jsonOutput.push(info);
} else {
printSessionRow(info);
}
}
if (!opts.json) {
console.log();
if (opts.json) {
continue;
}
if (orchestrators.length > 0) {
for (const info of orchestrators) {
printOrchestratorRow(info);
}
}
if (workers.length === 0) {
console.log(chalk.dim(" (no active sessions)"));
console.log();
continue;
}
printTableHeader();
for (const info of workers) {
printSessionRow(info);
}
console.log();
}
if (opts.json) {
@ -290,7 +318,10 @@ export function registerStatus(program: Command): void {
} else {
console.log(
chalk.dim(
` ${totalSessions} active session${totalSessions !== 1 ? "s" : ""} across ${projectIds.length} project${projectIds.length !== 1 ? "s" : ""}`,
` ${totalWorkers} active session${totalWorkers !== 1 ? "s" : ""} across ${projectIds.length} project${projectIds.length !== 1 ? "s" : ""}` +
(totalOrchestrators > 0
? ` · ${totalOrchestrators} orchestrator${totalOrchestrators !== 1 ? "s" : ""}`
: ""),
),
);

View File

@ -126,6 +126,29 @@ describe("recoverSession", () => {
expect(metadata?.["recoveredAt"]).toBeUndefined();
});
it("preserves project ownership when legacy metadata omits the project field", async () => {
rootDir = join(tmpdir(), `ao-recovery-${randomUUID()}`);
mkdirSync(rootDir, { recursive: true });
mkdirSync(join(rootDir, "project"), { recursive: true });
writeFileSync(join(rootDir, "agent-orchestrator.yaml"), "projects: {}\n", "utf-8");
const config = makeConfig(rootDir);
const registry = makeRegistry();
const assessment = makeAssessment({
rawMetadata: {
branch: "feature/recover",
worktree: join(rootDir, "project"),
status: "needs_input",
},
});
const context = makeContext(rootDir);
const result = await recoverSession(assessment, config, registry, context);
expect(result.success).toBe(true);
expect(result.session?.projectId).toBe("app");
});
it("returns the max-attempt reason when recovery escalates", async () => {
rootDir = join(tmpdir(), `ao-recovery-${randomUUID()}`);
mkdirSync(rootDir, { recursive: true });

View File

@ -1184,6 +1184,20 @@ describe("list", () => {
expect(sessions[0].id).toBe("app-1");
});
it("preserves owning project ID for legacy metadata missing the project field", async () => {
writeMetadata(sessionsDir, "app-1", {
worktree: "/tmp",
branch: "a",
status: "working",
});
const sm = createSessionManager({ config, registry: mockRegistry });
const sessions = await sm.list("my-app");
expect(sessions).toHaveLength(1);
expect(sessions[0].projectId).toBe("my-app");
});
it("clears enrichment timeout when enrichment completes quickly", async () => {
vi.useFakeTimers();
const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout");
@ -1450,6 +1464,20 @@ describe("get", () => {
expect(await sm.get("nonexistent")).toBeNull();
});
it("assigns owning project ID when loading legacy metadata without project", async () => {
writeMetadata(sessionsDir, "app-1", {
worktree: "/tmp",
branch: "main",
status: "working",
});
const sm = createSessionManager({ config, registry: mockRegistry });
const session = await sm.get("app-1");
expect(session).not.toBeNull();
expect(session?.projectId).toBe("my-app");
});
it("auto-discovers and persists OpenCode session mapping when missing", async () => {
const deleteLogPath = join(tmpDir, "opencode-get-remap.log");
const mockBin = installMockOpencode(

View File

@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { isOrchestratorSession } from "../types.js";
describe("isOrchestratorSession", () => {
it("detects orchestrators by explicit role metadata", () => {
expect(
isOrchestratorSession({
id: "app-control",
metadata: { role: "orchestrator" },
}),
).toBe(true);
});
it("falls back to orchestrator naming for legacy sessions", () => {
expect(isOrchestratorSession({ id: "app-orchestrator", metadata: {} })).toBe(true);
});
it("does not classify worker sessions as orchestrators", () => {
expect(isOrchestratorSession({ id: "app-7", metadata: { role: "worker" } })).toBe(false);
});
});

View File

@ -32,6 +32,7 @@ import {
type Session,
type EventPriority,
type ProjectConfig as _ProjectConfig,
isOrchestratorSession,
} from "./types.js";
import { updateMetadata } from "./metadata.js";
import { getSessionsDir } from "./paths.js";
@ -225,10 +226,6 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
let polling = false; // re-entrancy guard
let allCompleteEmitted = false; // guard against repeated all_complete
function isOrchestratorSession(session: Session): boolean {
return session.metadata["role"] === "orchestrator" || session.id.endsWith("-orchestrator");
}
function setProjectPause(project: _ProjectConfig, sourceSessionId: string, until: Date): void {
const sessionsDir = getSessionsDir(config.configPath, project.path);
const orchestratorId = `${project.sessionPrefix}-orchestrator`;

View File

@ -72,6 +72,7 @@ export async function recoverSession(
};
const session = sessionFromMetadata(sessionId, updatedMetadata, {
projectId: assessment.projectId,
status: preservedStatus,
runtimeHandle: assessment.runtimeHandle,
lastActivityAt: new Date(),

View File

@ -41,6 +41,7 @@ import {
type PluginRegistry,
type RuntimeHandle,
type Issue,
isOrchestratorSession,
PR_STATE,
} from "./types.js";
import {
@ -228,10 +229,12 @@ function sleep(ms: number): Promise<void> {
function metadataToSession(
sessionId: SessionId,
meta: Record<string, string>,
projectId: string,
createdAt?: Date,
modifiedAt?: Date,
): Session {
return sessionFromMetadata(sessionId, meta, {
projectId,
createdAt,
lastActivityAt: modifiedAt ?? new Date(),
});
@ -556,6 +559,10 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
maybeAdd(id, readArchivedMetadataRaw(sessionsDir, id));
}
if (criteria.sessionId) {
maybeAdd(criteria.sessionId, readArchivedMetadataRaw(sessionsDir, criteria.sessionId));
}
return [...new Set(ids)];
}
@ -1201,7 +1208,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
const existingRaw = readMetadataRaw(sessionsDir, sessionId);
const existingOrchestrator = existingRaw?.["runtimeHandle"]
? metadataToSession(sessionId, existingRaw)
? metadataToSession(sessionId, existingRaw, orchestratorConfig.projectId)
: null;
if (existingOrchestrator?.runtimeHandle) {
const existingAlive = await plugins.runtime
@ -1210,7 +1217,11 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
if (existingAlive && orchestratorSessionStrategy === "reuse") {
const persistedRaw = readMetadataRaw(sessionsDir, sessionId);
if (persistedRaw?.["runtimeHandle"]) {
const persisted = metadataToSession(sessionId, persistedRaw);
const persisted = metadataToSession(
sessionId,
persistedRaw,
orchestratorConfig.projectId,
);
persisted.metadata["orchestratorSessionReused"] = "true";
return persisted;
}
@ -1239,7 +1250,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
// Check if the session now exists and is alive.
const concurrentRaw = readMetadataRaw(sessionsDir, sessionId);
const concurrentSession = concurrentRaw?.["runtimeHandle"]
? metadataToSession(sessionId, concurrentRaw)
? metadataToSession(sessionId, concurrentRaw, orchestratorConfig.projectId)
: null;
if (concurrentSession?.runtimeHandle) {
const concurrentAlive = await plugins.runtime
@ -1416,7 +1427,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
// If stat fails, timestamps will fall back to current time
}
const session = metadataToSession(sessionName, raw, createdAt, modifiedAt);
const session = metadataToSession(sessionName, raw, sessionProjectId, createdAt, modifiedAt);
const selectedAgentName = raw["agent"];
const effectiveAgentName = selectedAgentName ?? project.agent ?? config.defaults.agent;
const plugins = resolvePlugins(project, effectiveAgentName);
@ -1455,7 +1466,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
async function get(sessionId: SessionId): Promise<Session | null> {
// Try to find the session in any project's sessions directory
for (const project of Object.values(config.projects)) {
for (const [projectId, project] of Object.entries(config.projects)) {
const sessionsDir = getProjectSessionsDir(project);
const raw = readMetadataRaw(sessionsDir, sessionId);
if (!raw) continue;
@ -1478,7 +1489,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
modifiedAt,
});
const session = metadataToSession(sessionId, repaired.raw, createdAt, modifiedAt);
const session = metadataToSession(sessionId, repaired.raw, projectId, createdAt, modifiedAt);
const selectedAgentName = repaired.raw["agent"];
const effectiveAgentName = selectedAgentName ?? project.agent ?? config.defaults.agent;
@ -1606,7 +1617,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
// Never clean up orchestrator sessions — they manage the lifecycle.
// Check explicit role metadata first, fall back to naming convention
// for pre-existing sessions spawned before the role field was added.
if (session.metadata["role"] === "orchestrator" || session.id.endsWith("-orchestrator")) {
if (isOrchestratorSession(session)) {
pushSkipped(session.projectId, session.id);
continue;
}
@ -1678,7 +1689,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
const archived = readArchivedMetadataRaw(sessionsDir, archivedId);
if (!archived) continue;
if (archived["role"] === "orchestrator" || archivedId.endsWith("-orchestrator")) {
if (isOrchestratorSession({ id: archivedId, metadata: archived })) {
pushSkipped(projectKey, archivedId);
continue;
}
@ -2107,7 +2118,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
// metadataToSession sets activity: null, so without enrichment a crashed
// session (status "working", agent exited) would not be detected as terminal
// and isRestorable would reject it.
const session = metadataToSession(sessionId, raw);
const session = metadataToSession(sessionId, raw, projectId);
const plugins = resolvePlugins(project, raw["agent"]);
await enrichSessionWithRuntimeState(session, plugins, true);

View File

@ -172,6 +172,13 @@ export interface Session {
metadata: Record<string, string>;
}
export function isOrchestratorSession(session: {
id: SessionId;
metadata?: Record<string, string>;
}): boolean {
return session.metadata?.["role"] === "orchestrator" || session.id.endsWith("-orchestrator");
}
/** Config for creating a new session */
export interface SessionSpawnConfig {
projectId: string;

View File

@ -3,6 +3,7 @@ import { parsePrFromUrl } from "./pr.js";
import { safeJsonParse, validateStatus } from "./validation.js";
interface SessionFromMetadataOptions {
projectId?: string;
status?: SessionStatus;
activity?: Session["activity"];
runtimeHandle?: RuntimeHandle | null;
@ -18,7 +19,7 @@ export function sessionFromMetadata(
): Session {
return {
id: sessionId,
projectId: meta["project"] ?? "",
projectId: meta["project"] ?? options.projectId ?? "",
status: options.status ?? validateStatus(meta["status"]),
activity: options.activity ?? null,
branch: meta["branch"] || null,

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,122 @@ 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");
});
it("keeps global pause sourced from all projects even for project-scoped requests", async () => {
const pausedUntil = new Date(Date.now() + 60_000).toISOString();
const pausedSessions = [
makeSession({
id: "docs-orchestrator",
projectId: "docs-app",
metadata: {
role: "orchestrator",
globalPauseUntil: pausedUntil,
globalPauseReason: "Rate limit hit",
globalPauseSource: "docs-orchestrator",
},
}),
makeSession({ id: "docs-1", projectId: "docs-app", status: "working", activity: "active" }),
makeSession({
id: "backend-3",
projectId: "my-app",
status: "working",
activity: "active",
}),
];
(mockSessionManager.list as ReturnType<typeof vi.fn>).mockImplementation(
async (projectId?: string) =>
projectId
? pausedSessions.filter((session) => session.projectId === projectId)
: pausedSessions,
);
const res = await sessionsGET(
makeRequest("http://localhost:3000/api/sessions?project=my-app"),
);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.globalPause).toMatchObject({
pausedUntil,
reason: "Rate limit hit",
sourceSessionId: "docs-orchestrator",
});
expect(mockSessionManager.list).toHaveBeenNthCalledWith(1, "my-app");
expect(mockSessionManager.list).toHaveBeenNthCalledWith(2);
});
it("finds active global pause even when a metadata-role orchestrator appears first", async () => {
const pausedUntil = new Date(Date.now() + 60_000).toISOString();
const sessions = [
makeSession({
id: "control-session",
projectId: "docs-app",
metadata: { role: "orchestrator" },
}),
makeSession({
id: "docs-orchestrator",
projectId: "docs-app",
metadata: {
role: "orchestrator",
globalPauseUntil: pausedUntil,
globalPauseReason: "Rate limit hit",
globalPauseSource: "docs-orchestrator",
},
}),
];
(mockSessionManager.list as ReturnType<typeof vi.fn>).mockResolvedValueOnce(sessions);
const res = await sessionsGET(makeRequest("http://localhost:3000/api/sessions"));
expect(res.status).toBe(200);
const data = await res.json();
expect(data.globalPause).toMatchObject({
pausedUntil,
reason: "Rate limit hit",
sourceSessionId: "docs-orchestrator",
});
});
});
// ── POST /api/spawn ────────────────────────────────────────────────
@ -651,6 +800,32 @@ describe("API Routes", () => {
expect(sessionIds.every((id: string) => !id.endsWith("-orchestrator"))).toBe(true);
});
it("excludes metadata-role orchestrators from snapshots", async () => {
const sessionsWithMetadataRoleOrchestrator = [
...testSessions,
makeSession({
id: "control-session",
projectId: "my-app",
status: "working",
activity: "active",
metadata: { role: "orchestrator" },
}),
];
(mockSessionManager.list as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
sessionsWithMetadataRoleOrchestrator,
);
const res = await eventsGET(makeRequest("http://localhost:3000/api/events"));
const reader = res.body!.getReader();
const { value } = await reader.read();
reader.cancel();
const text = new TextDecoder().decode(value);
const event = JSON.parse(text.replace("data: ", "").trim());
const sessionIds = event.sessions.map((s: { id: string }) => s.id);
expect(sessionIds).not.toContain("control-session");
});
it("filters sessions by project query param", async () => {
const multiProjectSessions = [
...testSessions,

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,10 @@ import {
enrichSessionPR,
enrichSessionsMetadata,
computeStats,
listDashboardOrchestrators,
} from "@/lib/serialize";
import { resolveGlobalPause } from "@/lib/global-pause";
import { filterWorkerSessions, findOrchestratorSessionId } from "@/lib/project-utils";
import { filterProjectSessions } from "@/lib/project-utils";
const METADATA_ENRICH_TIMEOUT_MS = 3_000;
const PR_ENRICH_TIMEOUT_MS = 4_000;
@ -42,22 +43,26 @@ 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 allSessions = requestedProjectId ? await sessionManager.list() : coreSessions;
const visibleSessions = filterProjectSessions(coreSessions, projectFilter, config.projects);
const orchestrators = listDashboardOrchestrators(visibleSessions, config.projects);
const orchestratorId = orchestrators.length === 1 ? (orchestrators[0]?.id ?? null) : null;
const orchestratorId = findOrchestratorSessionId(coreSessions, projectFilter, config.projects);
let workerSessions = visibleSessions.filter((session) => !isOrchestratorSession(session));
let workerSessions = filterWorkerSessions(coreSessions, projectFilter, config.projects);
// 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,7 +94,8 @@ export async function GET(request: Request) {
sessions: dashboardSessions,
stats: computeStats(dashboardSessions),
orchestratorId,
globalPause: resolveGlobalPause(coreSessions),
orchestrators,
globalPause: resolveGlobalPause(allSessions),
});
} catch (err) {
return NextResponse.json(

View File

@ -9,10 +9,11 @@ import {
resolveProject,
enrichSessionPR,
enrichSessionsMetadata,
listDashboardOrchestrators,
} from "@/lib/serialize";
import { prCache, prCacheKey } from "@/lib/cache";
import { getPrimaryProjectId, getProjectName, getAllProjects } from "@/lib/project-name";
import { filterWorkerSessions, findOrchestratorSessionId } from "@/lib/project-utils";
import { filterProjectSessions, filterWorkerSessions } from "@/lib/project-utils";
import { resolveGlobalPause, type GlobalPauseState } from "@/lib/global-pause";
function getSelectedProjectName(projectFilter: string | undefined): string {
@ -36,27 +37,33 @@ export async function generateMetadata(props: {
export default async function Home(props: { searchParams: Promise<{ project?: string }> }) {
const searchParams = await props.searchParams;
let sessions: DashboardSession[] = [];
let orchestratorId: string | null;
let globalPause: GlobalPauseState | null;
// Allow ?project=all to show all sessions (for multi-project setups)
const projectFilter = searchParams.project ?? getPrimaryProjectId();
const pageData: {
sessions: DashboardSession[];
globalPause: GlobalPauseState | null;
orchestrators: Array<{ id: string; projectId: string; projectName: string }>;
} = {
sessions: [],
globalPause: null,
orchestrators: [],
};
try {
const { config, registry, sessionManager } = await getServices();
const allSessions = await sessionManager.list();
orchestratorId = findOrchestratorSessionId(allSessions, projectFilter, config.projects);
pageData.globalPause = resolveGlobalPause(allSessions);
globalPause = resolveGlobalPause(allSessions);
const visibleSessions = filterProjectSessions(allSessions, projectFilter, config.projects);
pageData.orchestrators = listDashboardOrchestrators(visibleSessions, config.projects);
const coreSessions = filterWorkerSessions(allSessions, projectFilter, config.projects);
sessions = coreSessions.map(sessionToDashboard);
pageData.sessions = coreSessions.map(sessionToDashboard);
const metaTimeout = new Promise<void>((resolve) => setTimeout(resolve, 3_000));
await Promise.race([
enrichSessionsMetadata(coreSessions, sessions, config, registry),
enrichSessionsMetadata(coreSessions, pageData.sessions, config, registry),
metaTimeout,
]);
@ -68,25 +75,29 @@ export default async function Home(props: { searchParams: Promise<{ project?: st
const cached = prCache.get(cacheKey);
if (cached) {
if (sessions[i].pr) {
sessions[i].pr.state = cached.state;
sessions[i].pr.title = cached.title;
sessions[i].pr.additions = cached.additions;
sessions[i].pr.deletions = cached.deletions;
sessions[i].pr.ciStatus = cached.ciStatus as "none" | "pending" | "passing" | "failing";
sessions[i].pr.reviewDecision = cached.reviewDecision as
if (pageData.sessions[i].pr) {
pageData.sessions[i].pr.state = cached.state;
pageData.sessions[i].pr.title = cached.title;
pageData.sessions[i].pr.additions = cached.additions;
pageData.sessions[i].pr.deletions = cached.deletions;
pageData.sessions[i].pr.ciStatus = cached.ciStatus as
| "none"
| "pending"
| "passing"
| "failing";
pageData.sessions[i].pr.reviewDecision = cached.reviewDecision as
| "none"
| "pending"
| "approved"
| "changes_requested";
sessions[i].pr.ciChecks = cached.ciChecks.map((c) => ({
pageData.sessions[i].pr.ciChecks = cached.ciChecks.map((c) => ({
name: c.name,
status: c.status as "pending" | "running" | "passed" | "failed" | "skipped",
url: c.url,
}));
sessions[i].pr.mergeability = cached.mergeability;
sessions[i].pr.unresolvedThreads = cached.unresolvedThreads;
sessions[i].pr.unresolvedComments = cached.unresolvedComments;
pageData.sessions[i].pr.mergeability = cached.mergeability;
pageData.sessions[i].pr.unresolvedThreads = cached.unresolvedThreads;
pageData.sessions[i].pr.unresolvedComments = cached.unresolvedComments;
}
if (
@ -101,14 +112,14 @@ export default async function Home(props: { searchParams: Promise<{ project?: st
const project = resolveProject(core, config.projects);
const scm = getSCM(registry, project);
if (!scm) return Promise.resolve();
return enrichSessionPR(sessions[i], scm, core.pr);
return enrichSessionPR(pageData.sessions[i], scm, core.pr);
});
const enrichTimeout = new Promise<void>((resolve) => setTimeout(resolve, 4_000));
await Promise.race([Promise.allSettled(enrichPromises), enrichTimeout]);
} catch {
sessions = [];
orchestratorId = null;
globalPause = null;
pageData.sessions = [];
pageData.globalPause = null;
pageData.orchestrators = [];
}
const projectName = getSelectedProjectName(projectFilter);
@ -117,12 +128,12 @@ export default async function Home(props: { searchParams: Promise<{ project?: st
return (
<Dashboard
initialSessions={sessions}
orchestratorId={orchestratorId}
initialSessions={pageData.sessions}
projectId={selectedProjectId}
projectName={projectName}
projects={projects}
initialGlobalPause={globalPause}
initialGlobalPause={pageData.globalPause}
orchestrators={pageData.orchestrators}
/>
);
}

View File

@ -2,6 +2,7 @@
import { useEffect, useState, useCallback } from "react";
import { useParams } from "next/navigation";
import { isOrchestratorSession } from "@composio/ao-core/types";
import { SessionDetail } from "@/components/SessionDetail";
import { type DashboardSession, getAttentionLevel, type AttentionLevel } from "@/lib/types";
import { activityIcon } from "@/lib/activity-icons";
@ -14,7 +15,7 @@ function truncate(s: string, max: number): string {
function buildSessionTitle(session: DashboardSession): string {
const id = session.id;
const emoji = session.activity ? (activityIcon[session.activity] ?? "") : "";
const isOrchestrator = id.endsWith("-orchestrator");
const isOrchestrator = isOrchestratorSession(session);
let detail: string;
@ -43,12 +44,13 @@ interface ZoneCounts {
export default function SessionPage() {
const params = useParams();
const id = params.id as string;
const isOrchestrator = id.endsWith("-orchestrator");
const [session, setSession] = useState<DashboardSession | null>(null);
const [zoneCounts, setZoneCounts] = useState<ZoneCounts | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const sessionProjectId = session?.projectId ?? null;
const sessionIsOrchestrator = session ? isOrchestratorSession(session) : false;
// Update document title based on session data
useEffect(() => {
@ -81,23 +83,30 @@ export default function SessionPage() {
}, [id]);
const fetchZoneCounts = useCallback(async () => {
if (!isOrchestrator) return;
if (!sessionIsOrchestrator || !sessionProjectId) return;
try {
const res = await fetch("/api/sessions");
const res = await fetch(`/api/sessions?project=${encodeURIComponent(sessionProjectId)}`);
if (!res.ok) return;
const body = (await res.json()) as { sessions: DashboardSession[] };
const sessions = body.sessions ?? [];
const counts: ZoneCounts = { merge: 0, respond: 0, review: 0, pending: 0, working: 0, done: 0 };
const counts: ZoneCounts = {
merge: 0,
respond: 0,
review: 0,
pending: 0,
working: 0,
done: 0,
};
for (const s of sessions) {
if (!s.id.endsWith("-orchestrator")) {
if (!isOrchestratorSession(s)) {
counts[getAttentionLevel(s) as AttentionLevel]++;
}
}
setZoneCounts(counts);
} catch {
// non-critical status strip just won't show
// non-critical - status strip just won't show
}
}, [isOrchestrator]);
}, [sessionIsOrchestrator, sessionProjectId]);
// Initial fetch — session first, zone counts after (avoids blocking on slow /api/sessions)
useEffect(() => {
@ -127,7 +136,9 @@ export default function SessionPage() {
if (error || !session) {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-[var(--color-bg-base)]">
<div className="text-[13px] text-[var(--color-status-error)]">{error ?? "Session not found"}</div>
<div className="text-[13px] text-[var(--color-status-error)]">
{error ?? "Session not found"}
</div>
<a href="/" className="text-[12px] text-[var(--color-accent)] hover:underline">
Back to dashboard
</a>
@ -138,7 +149,7 @@ export default function SessionPage() {
return (
<SessionDetail
session={session}
isOrchestrator={isOrchestrator}
isOrchestrator={sessionIsOrchestrator}
orchestratorZones={zoneCounts ?? undefined}
/>
);

View File

@ -7,6 +7,7 @@ import {
type DashboardPR,
type AttentionLevel,
type GlobalPauseState,
type DashboardOrchestratorLink,
getAttentionLevel,
isPRRateLimited,
} from "@/lib/types";
@ -20,22 +21,22 @@ import type { ProjectInfo } from "@/lib/project-name";
interface DashboardProps {
initialSessions: DashboardSession[];
orchestratorId?: string | null;
projectId?: string;
projectName?: string;
projects?: ProjectInfo[];
initialGlobalPause?: GlobalPauseState | null;
orchestrators?: DashboardOrchestratorLink[];
}
const KANBAN_LEVELS = ["working", "pending", "review", "respond", "merge"] as const;
export function Dashboard({
initialSessions,
orchestratorId,
projectId,
projectName,
projects = [],
initialGlobalPause = null,
orchestrators = [],
}: DashboardProps) {
const { sessions, globalPause } = useSessionEvents(
initialSessions,
@ -45,6 +46,8 @@ export function Dashboard({
const [rateLimitDismissed, setRateLimitDismissed] = useState(false);
const [globalPauseDismissed, setGlobalPauseDismissed] = useState(false);
const showSidebar = projects.length > 1;
const allProjectsView = showSidebar && projectId === undefined;
const grouped = useMemo(() => {
const zones: Record<AttentionLevel, DashboardSession[]> = {
merge: [],
@ -62,11 +65,43 @@ export function Dashboard({
const openPRs = useMemo(() => {
return sessions
.filter((s): s is DashboardSession & { pr: DashboardPR } => s.pr?.state === "open")
.map((s) => s.pr)
.filter(
(session): session is DashboardSession & { pr: DashboardPR } =>
session.pr?.state === "open",
)
.map((session) => session.pr)
.sort((a, b) => mergeScore(a) - mergeScore(b));
}, [sessions]);
const projectOverviews = useMemo(() => {
if (!allProjectsView) return [];
return projects.map((project) => {
const projectSessions = sessions.filter((session) => session.projectId === project.id);
const counts: Record<AttentionLevel, number> = {
merge: 0,
respond: 0,
review: 0,
pending: 0,
working: 0,
done: 0,
};
for (const session of projectSessions) {
counts[getAttentionLevel(session)]++;
}
return {
project,
orchestrator:
orchestrators.find((orchestrator) => orchestrator.projectId === project.id) ?? null,
sessionCount: projectSessions.length,
openPRCount: projectSessions.filter((session) => session.pr?.state === "open").length,
counts,
};
});
}, [allProjectsView, orchestrators, projects, sessions]);
const handleSend = async (sessionId: string, message: string) => {
const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/send`, {
method: "POST",
@ -105,24 +140,27 @@ export function Dashboard({
}
};
const hasKanbanSessions = KANBAN_LEVELS.some((l) => grouped[l].length > 0);
const hasKanbanSessions = KANBAN_LEVELS.some((level) => grouped[level].length > 0);
const anyRateLimited = useMemo(
() => sessions.some((s) => s.pr && isPRRateLimited(s.pr)),
() => sessions.some((session) => session.pr && isPRRateLimited(session.pr)),
[sessions],
);
const liveStats = useMemo<DashboardStats>(
() => ({
totalSessions: sessions.length,
workingSessions: sessions.filter((s) => s.activity !== null && s.activity !== "exited")
.length,
openPRs: sessions.filter((s) => s.pr?.state === "open").length,
workingSessions: sessions.filter(
(session) => session.activity !== null && session.activity !== "exited",
).length,
openPRs: sessions.filter((session) => session.pr?.state === "open").length,
needsReview: sessions.filter(
(s) => s.pr && !s.pr.isDraft && s.pr.reviewDecision === "pending",
(session) => session.pr && !session.pr.isDraft && session.pr.reviewDecision === "pending",
).length,
}),
[sessions],
);
const resumeAtLabel = useMemo(() => {
if (!globalPause) return null;
return new Date(globalPause.pausedUntil).toLocaleString();
@ -137,7 +175,6 @@ export function Dashboard({
{showSidebar && <ProjectSidebar projects={projects} activeProjectId={projectId} />}
<div className="flex-1 overflow-y-auto px-8 py-7">
<DynamicFavicon sessions={sessions} projectName={projectName} />
{/* Header */}
<div className="mb-8 flex items-center justify-between border-b border-[var(--color-border-subtle)] pb-6">
<div className="flex items-center gap-6">
<h1 className="text-[17px] font-semibold tracking-[-0.02em] text-[var(--color-text-primary)]">
@ -145,27 +182,9 @@ export function Dashboard({
</h1>
<StatusLine stats={liveStats} />
</div>
{orchestratorId && (
<a
href={`/sessions/${encodeURIComponent(orchestratorId)}`}
className="orchestrator-btn flex items-center gap-2 rounded-[7px] px-4 py-2 text-[12px] font-semibold hover:no-underline"
>
<span className="h-1.5 w-1.5 rounded-full bg-[var(--color-accent)] opacity-80" />
orchestrator
<svg
className="h-3 w-3 opacity-70"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6M15 3h6v6M10 14L21 3" />
</svg>
</a>
)}
{!allProjectsView && <OrchestratorControl orchestrators={orchestrators} />}
</div>
{/* Global pause banner */}
{globalPause && !globalPauseDismissed && (
<div className="mb-6 flex items-center gap-2.5 rounded border border-[rgba(239,68,68,0.25)] bg-[rgba(239,68,68,0.05)] px-3.5 py-2.5 text-[11px] text-[var(--color-status-error)]">
<svg
@ -205,7 +224,6 @@ export function Dashboard({
</div>
)}
{/* Rate limit notice */}
{anyRateLimited && !rateLimitDismissed && (
<div className="mb-6 flex items-center gap-2.5 rounded border border-[rgba(245,158,11,0.25)] bg-[rgba(245,158,11,0.05)] px-3.5 py-2.5 text-[11px] text-[var(--color-status-attention)]">
<svg
@ -240,8 +258,9 @@ export function Dashboard({
</div>
)}
{/* Kanban columns for active zones */}
{hasKanbanSessions && (
{allProjectsView && <ProjectOverviewGrid overviews={projectOverviews} />}
{!allProjectsView && hasKanbanSessions && (
<div className="mb-8 flex gap-4 overflow-x-auto pb-2">
{KANBAN_LEVELS.map((level) =>
grouped[level].length > 0 ? (
@ -261,8 +280,7 @@ export function Dashboard({
</div>
)}
{/* Done — full-width grid below Kanban */}
{grouped.done.length > 0 && (
{!allProjectsView && grouped.done.length > 0 && (
<div className="mb-8">
<AttentionZone
level="done"
@ -276,7 +294,6 @@ export function Dashboard({
</div>
)}
{/* PR Table */}
{openPRs.length > 0 && (
<div className="mx-auto max-w-[900px]">
<h2 className="mb-3 px-1 text-[10px] font-bold uppercase tracking-[0.10em] text-[var(--color-text-tertiary)]">
@ -320,6 +337,164 @@ export function Dashboard({
);
}
function OrchestratorControl({ orchestrators }: { orchestrators: DashboardOrchestratorLink[] }) {
if (orchestrators.length === 0) return null;
if (orchestrators.length === 1) {
const orchestrator = orchestrators[0];
return (
<a
href={`/sessions/${encodeURIComponent(orchestrator.id)}`}
className="orchestrator-btn flex items-center gap-2 rounded-[7px] px-4 py-2 text-[12px] font-semibold hover:no-underline"
>
<span className="h-1.5 w-1.5 rounded-full bg-[var(--color-accent)] opacity-80" />
orchestrator
<svg
className="h-3 w-3 opacity-70"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6M15 3h6v6M10 14L21 3" />
</svg>
</a>
);
}
return (
<details className="group relative">
<summary className="orchestrator-btn flex cursor-pointer list-none items-center gap-2 rounded-[7px] px-4 py-2 text-[12px] font-semibold hover:no-underline">
<span className="h-1.5 w-1.5 rounded-full bg-[var(--color-accent)] opacity-80" />
{orchestrators.length} orchestrators
<svg
className="h-3 w-3 opacity-70 transition-transform group-open:rotate-90"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path d="m9 18 6-6-6-6" />
</svg>
</summary>
<div className="absolute right-0 top-[calc(100%+0.5rem)] z-10 min-w-[220px] overflow-hidden rounded-[10px] border border-[var(--color-border-default)] bg-[var(--color-bg-elevated)] shadow-[0_18px_40px_rgba(0,0,0,0.18)]">
{orchestrators.map((orchestrator, index) => (
<a
key={orchestrator.id}
href={`/sessions/${encodeURIComponent(orchestrator.id)}`}
className={`flex items-center justify-between gap-3 px-4 py-3 text-[12px] hover:bg-[var(--color-bg-hover)] hover:no-underline ${
index > 0 ? "border-t border-[var(--color-border-subtle)]" : ""
}`}
>
<span className="flex min-w-0 items-center gap-2 text-[var(--color-text-primary)]">
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--color-accent)] opacity-80" />
<span className="truncate">{orchestrator.projectName}</span>
</span>
<svg
className="h-3 w-3 shrink-0 opacity-60"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6M15 3h6v6M10 14L21 3" />
</svg>
</a>
))}
</div>
</details>
);
}
function ProjectOverviewGrid({
overviews,
}: {
overviews: Array<{
project: ProjectInfo;
orchestrator: DashboardOrchestratorLink | null;
sessionCount: number;
openPRCount: number;
counts: Record<AttentionLevel, number>;
}>;
}) {
return (
<div className="mb-8 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{overviews.map(({ project, orchestrator, sessionCount, openPRCount, counts }) => (
<section
key={project.id}
className="rounded-[10px] border border-[var(--color-border-default)] bg-[var(--color-bg-surface)] p-4"
>
<div className="mb-4 flex items-start justify-between gap-3">
<div>
<h2 className="text-[14px] font-semibold text-[var(--color-text-primary)]">
{project.name}
</h2>
<div className="mt-1 text-[11px] text-[var(--color-text-muted)]">
{sessionCount} active session{sessionCount !== 1 ? "s" : ""}
{openPRCount > 0 ? ` · ${openPRCount} open PR${openPRCount !== 1 ? "s" : ""}` : ""}
</div>
</div>
<a
href={`/?project=${encodeURIComponent(project.id)}`}
className="rounded-[7px] border border-[var(--color-border-default)] px-3 py-1.5 text-[11px] font-medium text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-hover)] hover:no-underline"
>
Open project
</a>
</div>
<div className="mb-4 flex flex-wrap gap-2">
<ProjectMetric label="Merge" value={counts.merge} tone="var(--color-status-ready)" />
<ProjectMetric
label="Respond"
value={counts.respond}
tone="var(--color-status-error)"
/>
<ProjectMetric label="Review" value={counts.review} tone="var(--color-accent-orange)" />
<ProjectMetric
label="Pending"
value={counts.pending}
tone="var(--color-status-attention)"
/>
<ProjectMetric
label="Working"
value={counts.working}
tone="var(--color-status-working)"
/>
</div>
<div className="flex items-center justify-between border-t border-[var(--color-border-subtle)] pt-3">
<div className="text-[11px] text-[var(--color-text-muted)]">
{orchestrator ? "Per-project orchestrator available" : "No running orchestrator"}
</div>
{orchestrator ? (
<a
href={`/sessions/${encodeURIComponent(orchestrator.id)}`}
className="orchestrator-btn flex items-center gap-2 rounded-[7px] px-3 py-1.5 text-[11px] font-semibold hover:no-underline"
>
<span className="h-1.5 w-1.5 rounded-full bg-[var(--color-accent)] opacity-80" />
orchestrator
</a>
) : null}
</div>
</section>
))}
</div>
);
}
function ProjectMetric({ label, value, tone }: { label: string; value: number; tone: string }) {
return (
<div className="min-w-[78px] rounded-[8px] border border-[var(--color-border-subtle)] px-2.5 py-2">
<div className="text-[10px] uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
{label}
</div>
<div className="mt-1 text-[18px] font-semibold tabular-nums" style={{ color: tone }}>
{value}
</div>
</div>
);
}
function StatusLine({ stats }: { stats: DashboardStats }) {
if (stats.totalSessions === 0) {
return <span className="text-[13px] text-[var(--color-text-muted)]">no sessions</span>;
@ -338,16 +513,18 @@ function StatusLine({ stats }: { stats: DashboardStats }) {
return (
<div className="flex items-baseline gap-0.5">
{parts.map((p, i) => (
<span key={p.label} className="flex items-baseline">
{i > 0 && <span className="mx-3 text-[11px] text-[var(--color-border-strong)]">·</span>}
{parts.map((part, index) => (
<span key={part.label} className="flex items-baseline">
{index > 0 && (
<span className="mx-3 text-[11px] text-[var(--color-border-strong)]">·</span>
)}
<span
className="text-[20px] font-bold tabular-nums tracking-tight"
style={{ color: p.color ?? "var(--color-text-primary)" }}
style={{ color: part.color ?? "var(--color-text-primary)" }}
>
{p.value}
{part.value}
</span>
<span className="ml-1.5 text-[11px] text-[var(--color-text-muted)]">{p.label}</span>
<span className="ml-1.5 text-[11px] text-[var(--color-text-muted)]">{part.label}</span>
</span>
))}
</div>

View File

@ -2,6 +2,7 @@ import {
GLOBAL_PAUSE_REASON_KEY,
GLOBAL_PAUSE_SOURCE_KEY,
GLOBAL_PAUSE_UNTIL_KEY,
isOrchestratorSession,
parsePauseUntil,
} from "@composio/ao-core";
@ -14,14 +15,17 @@ export interface GlobalPauseState {
export function resolveGlobalPause(
sessions: Array<{ id: string; metadata: Record<string, string> }>,
): GlobalPauseState | null {
const orchestrator = sessions.find((session) => session.id.endsWith("-orchestrator"));
const pausedUntilRaw = orchestrator?.metadata[GLOBAL_PAUSE_UNTIL_KEY];
const parsed = parsePauseUntil(pausedUntilRaw);
if (!parsed || parsed.getTime() <= Date.now()) return null;
for (const session of sessions) {
if (!isOrchestratorSession(session)) continue;
const parsed = parsePauseUntil(session.metadata[GLOBAL_PAUSE_UNTIL_KEY]);
if (!parsed || parsed.getTime() <= Date.now()) continue;
return {
pausedUntil: parsed.toISOString(),
reason: orchestrator?.metadata[GLOBAL_PAUSE_REASON_KEY] ?? "Model rate limit reached",
sourceSessionId: orchestrator?.metadata[GLOBAL_PAUSE_SOURCE_KEY] ?? null,
};
return {
pausedUntil: parsed.toISOString(),
reason: session.metadata[GLOBAL_PAUSE_REASON_KEY] ?? "Model rate limit reached",
sourceSessionId: session.metadata[GLOBAL_PAUSE_SOURCE_KEY] ?? null,
};
}
return null;
}

View File

@ -1,5 +1,7 @@
import { isOrchestratorSession } from "@composio/ao-core";
type ProjectWithPrefix = { sessionPrefix?: string };
type SessionLike = { id: string; projectId: string };
type SessionLike = { id: string; projectId: string; metadata?: Record<string, string> };
/**
* Check if a session belongs to a specific project.
@ -17,27 +19,16 @@ function matchesProject(
if (session.projectId === projectId) return true;
const project = projects[projectId];
if (project?.sessionPrefix && session.id.startsWith(project.sessionPrefix)) return true;
return false;
return projects[session.projectId]?.sessionPrefix === projectId;
}
function isOrchestratorSession(session: { id: string }): boolean {
return session.id.endsWith("-orchestrator");
}
export function findOrchestratorSessionId<T extends SessionLike>(
export function filterProjectSessions<T extends SessionLike>(
sessions: T[],
projectFilter: string | null | undefined,
projects: Record<string, ProjectWithPrefix>,
): string | null {
if (projectFilter && projectFilter !== "all") {
const session = sessions.find(
(s) => isOrchestratorSession(s) && matchesProject(s, projectFilter, projects),
);
return session?.id ?? null;
}
const session = sessions.find((s) => isOrchestratorSession(s));
return session?.id ?? null;
): T[] {
if (!projectFilter || projectFilter === "all") return sessions;
return sessions.filter((session) => matchesProject(session, projectFilter, projects));
}
export function filterWorkerSessions<T extends SessionLike>(
@ -46,6 +37,5 @@ export function filterWorkerSessions<T extends SessionLike>(
projects: Record<string, ProjectWithPrefix>,
): T[] {
const workers = sessions.filter((s) => !isOrchestratorSession(s));
if (!projectFilter || projectFilter === "all") return workers;
return workers.filter((s) => matchesProject(s, projectFilter, projects));
return filterProjectSessions(workers, projectFilter, projects);
}

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

@ -30,6 +30,7 @@ import {
type Session,
type DecomposerConfig,
DEFAULT_DECOMPOSER_CONFIG,
isOrchestratorSession,
TERMINAL_STATUSES,
} from "@composio/ao-core";
@ -219,7 +220,7 @@ export async function pollBacklog(): Promise<void> {
await relabelReopenedIssues(config, registry);
const workerSessions = allSessions.filter(
(s) => !s.id.endsWith("-orchestrator") && !TERMINAL_STATUSES.has(s.status),
(session) => !isOrchestratorSession(session) && !TERMINAL_STATUSES.has(session.status),
);
const activeIssueIds = new Set(
workerSessions.filter((s) => s.issueId).map((s) => s.issueId!.toLowerCase()),

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