diff --git a/packages/cli/__tests__/commands/status.test.ts b/packages/cli/__tests__/commands/status.test.ts index 10c5e3cac..3139922f4 100644 --- a/packages/cli/__tests__/commands/status.test.ts +++ b/packages/cli/__tests__/commands/status.test.ts @@ -158,6 +158,25 @@ function buildSessionsFromDir( }); } +function makeSession(overrides: Partial & { 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 => 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), + 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; + + 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", + }); + }); }); diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts index c25a5344a..785009ae1 100644 --- a/packages/cli/src/commands/status.ts +++ b/packages/cli/src/commands/status.ts @@ -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" : ""}` + : ""), ), ); diff --git a/packages/core/src/__tests__/recovery-actions.test.ts b/packages/core/src/__tests__/recovery-actions.test.ts index 1f9cfc392..546262dd6 100644 --- a/packages/core/src/__tests__/recovery-actions.test.ts +++ b/packages/core/src/__tests__/recovery-actions.test.ts @@ -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 }); diff --git a/packages/core/src/__tests__/session-manager.test.ts b/packages/core/src/__tests__/session-manager.test.ts index 1412d2c54..a2aa995ba 100644 --- a/packages/core/src/__tests__/session-manager.test.ts +++ b/packages/core/src/__tests__/session-manager.test.ts @@ -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( diff --git a/packages/core/src/__tests__/types.test.ts b/packages/core/src/__tests__/types.test.ts new file mode 100644 index 000000000..6c2fffaf8 --- /dev/null +++ b/packages/core/src/__tests__/types.test.ts @@ -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); + }); +}); diff --git a/packages/core/src/lifecycle-manager.ts b/packages/core/src/lifecycle-manager.ts index f05547f14..0ca4438c9 100644 --- a/packages/core/src/lifecycle-manager.ts +++ b/packages/core/src/lifecycle-manager.ts @@ -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`; diff --git a/packages/core/src/recovery/actions.ts b/packages/core/src/recovery/actions.ts index 11d4bb762..1df1b5681 100644 --- a/packages/core/src/recovery/actions.ts +++ b/packages/core/src/recovery/actions.ts @@ -72,6 +72,7 @@ export async function recoverSession( }; const session = sessionFromMetadata(sessionId, updatedMetadata, { + projectId: assessment.projectId, status: preservedStatus, runtimeHandle: assessment.runtimeHandle, lastActivityAt: new Date(), diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index 84ebf4af0..b2b7b0e54 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -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 { function metadataToSession( sessionId: SessionId, meta: Record, + 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 { // 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); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 9d2bf2746..bc6829f10 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -172,6 +172,13 @@ export interface Session { metadata: Record; } +export function isOrchestratorSession(session: { + id: SessionId; + metadata?: Record; +}): boolean { + return session.metadata?.["role"] === "orchestrator" || session.id.endsWith("-orchestrator"); +} + /** Config for creating a new session */ export interface SessionSpawnConfig { projectId: string; diff --git a/packages/core/src/utils/session-from-metadata.ts b/packages/core/src/utils/session-from-metadata.ts index 50996fda8..fd5fc1dfb 100644 --- a/packages/core/src/utils/session-from-metadata.ts +++ b/packages/core/src/utils/session-from-metadata.ts @@ -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, diff --git a/packages/web/src/__tests__/api-routes.test.ts b/packages/web/src/__tests__/api-routes.test.ts index 5f23c96f6..798caba2f 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,122 @@ 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"); + }); + + 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).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).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).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, diff --git a/packages/web/src/app/api/sessions/route.ts b/packages/web/src/app/api/sessions/route.ts index f3f3c37d8..c7a069009 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,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( diff --git a/packages/web/src/app/page.tsx b/packages/web/src/app/page.tsx index 3ac9d0173..24281b4e5 100644 --- a/packages/web/src/app/page.tsx +++ b/packages/web/src/app/page.tsx @@ -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((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((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 ( ); } diff --git a/packages/web/src/app/sessions/[id]/page.tsx b/packages/web/src/app/sessions/[id]/page.tsx index e82dcb55d..fb855b564 100644 --- a/packages/web/src/app/sessions/[id]/page.tsx +++ b/packages/web/src/app/sessions/[id]/page.tsx @@ -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(null); const [zoneCounts, setZoneCounts] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(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 (
-
{error ?? "Session not found"}
+
+ {error ?? "Session not found"} +
← Back to dashboard @@ -138,7 +149,7 @@ export default function SessionPage() { return ( ); diff --git a/packages/web/src/components/Dashboard.tsx b/packages/web/src/components/Dashboard.tsx index 772a0da06..391f0aa54 100644 --- a/packages/web/src/components/Dashboard.tsx +++ b/packages/web/src/components/Dashboard.tsx @@ -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 = { 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 = { + 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( () => ({ 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 && }
- {/* Header */}

@@ -145,27 +182,9 @@ export function Dashboard({

- {orchestratorId && ( - - - orchestrator - - - - - )} + {!allProjectsView && }
- {/* Global pause banner */} {globalPause && !globalPauseDismissed && (
)} - {/* Rate limit notice */} {anyRateLimited && !rateLimitDismissed && (
)} - {/* Kanban columns for active zones */} - {hasKanbanSessions && ( + {allProjectsView && } + + {!allProjectsView && hasKanbanSessions && (
{KANBAN_LEVELS.map((level) => grouped[level].length > 0 ? ( @@ -261,8 +280,7 @@ export function Dashboard({
)} - {/* Done — full-width grid below Kanban */} - {grouped.done.length > 0 && ( + {!allProjectsView && grouped.done.length > 0 && (
)} - {/* PR Table */} {openPRs.length > 0 && (

@@ -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 ( + + + orchestrator + + + + + ); + } + + return ( +
+ + + {orchestrators.length} orchestrators + + + + + +
+ ); +} + +function ProjectOverviewGrid({ + overviews, +}: { + overviews: Array<{ + project: ProjectInfo; + orchestrator: DashboardOrchestratorLink | null; + sessionCount: number; + openPRCount: number; + counts: Record; + }>; +}) { + return ( +
+ {overviews.map(({ project, orchestrator, sessionCount, openPRCount, counts }) => ( +
+
+
+

+ {project.name} +

+
+ {sessionCount} active session{sessionCount !== 1 ? "s" : ""} + {openPRCount > 0 ? ` · ${openPRCount} open PR${openPRCount !== 1 ? "s" : ""}` : ""} +
+
+ + Open project + +
+ +
+ + + + + +
+ +
+
+ {orchestrator ? "Per-project orchestrator available" : "No running orchestrator"} +
+ {orchestrator ? ( + + + orchestrator + + ) : null} +
+
+ ))} +
+ ); +} + +function ProjectMetric({ label, value, tone }: { label: string; value: number; tone: string }) { + return ( +
+
+ {label} +
+
+ {value} +
+
+ ); +} + function StatusLine({ stats }: { stats: DashboardStats }) { if (stats.totalSessions === 0) { return no sessions; @@ -338,16 +513,18 @@ function StatusLine({ stats }: { stats: DashboardStats }) { return (
- {parts.map((p, i) => ( - - {i > 0 && ·} + {parts.map((part, index) => ( + + {index > 0 && ( + · + )} - {p.value} + {part.value} - {p.label} + {part.label} ))}
diff --git a/packages/web/src/lib/global-pause.ts b/packages/web/src/lib/global-pause.ts index f75903014..2811aada6 100644 --- a/packages/web/src/lib/global-pause.ts +++ b/packages/web/src/lib/global-pause.ts @@ -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 }>, ): 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; } diff --git a/packages/web/src/lib/project-utils.ts b/packages/web/src/lib/project-utils.ts index 548ec441b..e58ee7962 100644 --- a/packages/web/src/lib/project-utils.ts +++ b/packages/web/src/lib/project-utils.ts @@ -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 }; /** * 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( +export function filterProjectSessions( sessions: T[], projectFilter: string | null | undefined, projects: Record, -): 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( @@ -46,6 +37,5 @@ export function filterWorkerSessions( projects: Record, ): 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); } 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/services.ts b/packages/web/src/lib/services.ts index 00c364d5f..5d1a8f780 100644 --- a/packages/web/src/lib/services.ts +++ b/packages/web/src/lib/services.ts @@ -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 { 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()), 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";