diff --git a/docs/specs/project-based-dashboard-architecture.md b/docs/specs/project-based-dashboard-architecture.md new file mode 100644 index 000000000..39a968580 --- /dev/null +++ b/docs/specs/project-based-dashboard-architecture.md @@ -0,0 +1,458 @@ +# Project-Based Dashboard Architecture + +**Status:** Draft +**Author:** Agent Orchestrator +**Date:** 2026-03-09 +**Target Merge:** `opencode-lifyecycle` + +--- + +## Overview + +This spec defines the architecture changes required to scope the Agent Orchestrator dashboard by project. Currently, the dashboard displays all sessions across all configured projects, which creates cognitive overhead for multi-project setups. The target architecture adds project filtering at every layer (API, SSE events, frontend state) while maintaining full backward compatibility for single-project deployments. + +**Key Change:** Add optional `project` query parameter to session list/event endpoints, filtering all responses to a single project scope. Frontend will read `projectName` from config and pass it as the filter. + +--- + +## Current State + +### Data Flow + +``` +page.tsx (SSR) + ↓ +getServices() → sessionManager.list() // NO project filter + ↓ +enrichSessionsMetadata() + ↓ +Dashboard.tsx + ↓ (initial render) +useSessionEvents() ← EventSource("/api/events") // NO project filter + ↓ +Real-time updates +``` + +### Key Files + +| File | Role | Project Awareness | +| -------------------------------------------- | ----------------------------------------- | ----------------------------------------- | +| `packages/web/src/app/page.tsx` | SSR entry point, fetches initial sessions | Reads `projectName` for display only | +| `packages/web/src/app/api/sessions/route.ts` | GET `/api/sessions` — lists all sessions | **No filtering** | +| `packages/web/src/app/api/events/route.ts` | GET `/api/events` — SSE stream | **No filtering** | +| `packages/web/src/lib/services.ts` | Core services singleton | N/A | +| `packages/web/src/lib/serialize.ts` | Session → DashboardSession mapping | `resolveProject()` maps session → project | +| `packages/web/src/lib/types.ts` | Dashboard types | `DashboardSession.projectId` exists | +| `packages/web/src/components/Dashboard.tsx` | Main dashboard component | Displays all sessions | +| `packages/web/src/hooks/useSessionEvents.ts` | SSE event handler | Receives all sessions | + +### Current API Response Shape + +**GET /api/sessions** + +```typescript +{ + sessions: DashboardSession[]; // ALL sessions, unfiltered + stats: DashboardStats; + orchestratorId: string | null; +} +``` + +**GET /api/events (SSE)** + +```typescript +{ + type: "snapshot"; + sessions: Array<{ + id: string; + status: SessionStatus; + activity: ActivityState | null; + attentionLevel: AttentionLevel; + lastActivityAt: string; + }>; // ALL sessions, unfiltered +} +``` + +--- + +## Problems with Current Non-Project-Scoped Behavior + +1. **Cognitive Overload** — Multi-project users see sessions from unrelated projects mixed together, making it hard to focus on one project's work. + +2. **Stats Misleading** — `DashboardStats` aggregates across all projects. "3 needs review" might be spread across 3 different projects, not actionable. + +3. **Orchestrator Ambiguity** — Orchestrator session is found by suffix (`-orchestrator`). With multiple projects, multiple orchestrators may exist but only one is surfaced. + +4. **SSE Inefficiency** — Client receives updates for ALL sessions, including irrelevant projects, wasting bandwidth and causing unnecessary re-renders. + +5. **URL Non-Shareability** — Cannot share a dashboard URL scoped to a specific project. `http://localhost:3000/` shows everything. + +6. **Future Multi-Tenant Blocker** — If AO ever supports multi-tenant hosting, unscoped APIs would leak data between tenants. + +--- + +## Target Project-Based Architecture + +### Design Principles + +1. **Opt-in** — No `project` param = all sessions (backward compatible) +2. **Single Source of Truth** — `projectName` comes from config, not URL state +3. **Filter at Source** — API and SSE filter before returning data, not client-side +4. **Zero Config for Single-Project** — Existing users see no change +5. **Type-Safe** — Project param typed in API contracts + +### Target Data Flow + +``` +page.tsx (SSR) + ↓ projectName from getProjectName() + ↓ +GET /api/sessions?project= // ← NEW: project filter + ↓ +sessionManager.list().filter(s => s.projectId === project || matchesPrefix) + ↓ +Dashboard.tsx (receives only project sessions) + ↓ +useSessionEvents(projectName) ← EventSource("/api/events?project=") // ← NEW + ↓ +Real-time updates (project-scoped) +``` + +### URL Scheme + +| URL | Behavior | +| ------------------ | ----------------------------------------------- | +| `/` | Scoped to first/primary project (from config) | +| `/?project=all` | Show all sessions (explicit multi-project view) | +| `/?project=my-app` | Scoped to `my-app` project | + +**Default behavior:** When no `project` query param, use `projectName` from config (first project's name or `ao` fallback). This ensures single-project users see their project automatically. + +--- + +## API Contract Changes + +### 1. GET /api/sessions + +**Query Parameters (NEW)** + +```typescript +interface SessionsQueryParams { + /** Optional project filter. If omitted, returns all sessions. */ + project?: string; + /** Existing: filter to non-exited sessions only */ + active?: "true" | "false"; +} +``` + +**Response Shape (UNCHANGED)** + +```typescript +interface SessionsResponse { + sessions: DashboardSession[]; // Filtered by project if param provided + stats: DashboardStats; // Stats reflect filtered sessions only + orchestratorId: string | null; // Orchestrator for the project (if scoped) +} +``` + +**Behavior** + +| Query | Result | +| ----------------------------- | -------------------------------------------------------------------------------------------------------- | +| No params | All sessions (backward compatible) | +| `?project=my-app` | Only sessions where `session.projectId === "my-app"` OR session ID starts with project's `sessionPrefix` | +| `?project=all` | All sessions (explicit unscoped) | +| `?project=nonexistent` | Empty sessions array, stats all zeros, `orchestratorId: null` | +| `?active=true&project=my-app` | Active sessions for project only | + +**Project Resolution Logic** (reuse existing `resolveProject`) + +```typescript +function matchesProject(session: Session, projectId: string, config: OrchestratorConfig): boolean { + // Direct match + if (session.projectId === projectId) return true; + + // Prefix match (existing behavior in resolveProject) + const project = config.projects[projectId]; + if (project?.sessionPrefix && session.id.startsWith(project.sessionPrefix)) return true; + + return false; +} +``` + +### 2. GET /api/events (SSE) + +**Query Parameters (NEW)** + +```typescript +interface EventsQueryParams { + /** Optional project filter. If omitted, streams all sessions. */ + project?: string; +} +``` + +**SSE Event Shape (UNCHANGED)** + +```typescript +interface SSESnapshotEvent { + type: "snapshot"; + sessions: Array<{ + id: string; + status: SessionStatus; + activity: ActivityState | null; + attentionLevel: AttentionLevel; + lastActivityAt: string; + }>; // Filtered by project if param provided +} +``` + +**Behavior** + +| Query | Result | +| ----------------- | ------------------------------------- | +| No params | Stream all sessions | +| `?project=my-app` | Stream only sessions matching project | +| `?project=all` | Stream all sessions | + +--- + +## Frontend State Model Changes + +### 1. page.tsx (SSR Entry) + +**Current:** + +```typescript +export default async function Home() { + const { sessionManager } = await getServices(); + const allSessions = await sessionManager.list(); + // ... +} +``` + +**Target:** + +```typescript +export default async function Home({ searchParams }: { searchParams: { project?: string } }) { + const projectName = getProjectName(); + const projectFilter = searchParams.project ?? projectName; // Default to config project + + const res = await fetch(`${baseUrl}/api/sessions?project=${encodeURIComponent(projectFilter)}`); + const { sessions, stats, orchestratorId } = await res.json(); + + return ( + + ); +} +``` + +### 2. useSessionEvents Hook + +**Current:** + +```typescript +export function useSessionEvents(initialSessions: DashboardSession[]): DashboardSession[] { + useEffect(() => { + const es = new EventSource("/api/events"); + // ... + }, []); +} +``` + +**Target:** + +```typescript +export function useSessionEvents( + initialSessions: DashboardSession[], + project?: string, +): DashboardSession[] { + useEffect(() => { + const url = project ? `/api/events?project=${encodeURIComponent(project)}` : "/api/events"; + const es = new EventSource(url); + // ... + }, [project]); +} +``` + +### 3. Dashboard.tsx + +**Current:** + +```typescript +export function Dashboard({ initialSessions, stats, orchestratorId, projectName }: DashboardProps) { + const sessions = useSessionEvents(initialSessions); + // ... +} +``` + +**Target:** + +```typescript +export function Dashboard({ initialSessions, stats, orchestratorId, projectName }: DashboardProps) { + const sessions = useSessionEvents(initialSessions, projectName); + // ... +} +``` + +**Note:** `projectName` already exists as a prop. We repurpose it to also serve as the SSE filter key. + +--- + +## Migration / Backward Compatibility + +### Backward Compatibility Guarantees + +| Scenario | Before | After | Compatible? | +| ---------------------------------- | --------------------------------------------- | ------------------------------- | -------------- | +| Single project, no URL params | Shows all sessions (which is the one project) | Shows project-scoped sessions | ✅ Same result | +| Multi-project, `GET /api/sessions` | Returns all sessions | Returns all sessions (no param) | ✅ Same result | +| Multi-project, `GET /api/events` | Streams all sessions | Streams all sessions (no param) | ✅ Same result | +| Existing client using old API | Works | Works (params optional) | ✅ Same result | + +### Migration Steps (Zero Downtime) + +1. **Phase 1: API Support** — Add `project` query param support to both endpoints (optional param, defaults to all) +2. **Phase 2: Frontend Adoption** — Update `page.tsx` and `useSessionEvents` to pass project filter +3. **Phase 3: Documentation** — Update README and examples to document multi-project URL scheme + +**No database migration required** — `Session.projectId` already exists. + +### Breaking Changes + +**None.** All changes are additive. Existing deployments continue to work without modification. + +--- + +## Acceptance Criteria + +### Must Have + +- [ ] `GET /api/sessions?project=X` returns only sessions for project X +- [ ] `GET /api/events?project=X` streams only sessions for project X +- [ ] `DashboardStats` reflects only filtered sessions when project param present +- [ ] `orchestratorId` returns the orchestrator for the scoped project (not any orchestrator) +- [ ] `page.tsx` passes `projectName` as filter to both SSR fetch and SSE +- [ ] `useSessionEvents` accepts optional `project` param and constructs URL accordingly +- [ ] No project param = all sessions (backward compatible) +- [ ] Non-existent project = empty sessions, zero stats, null orchestrator +- [ ] Type safety: query params typed in route handlers + +### Should Have + +- [ ] URL `/?project=all` explicitly shows all sessions (for multi-project users who want overview) +- [ ] Project filter logged in API request for debugging + +### Nice to Have + +- [ ] Dashboard shows project name prominently when scoped +- [ ] Project switcher UI (future work, not in scope) + +--- + +## Test Matrix + +### Unit Tests + +| Test Case | File | Description | +| -------------------------------------------- | -------------------------------- | -------------------------------------------------------------------------------- | +| Project filter matches by projectId | `api/sessions/route.test.ts` | Session with `projectId: "my-app"` appears when `?project=my-app` | +| Project filter matches by sessionPrefix | `api/sessions/route.test.ts` | Session ID `app-123` appears when `?project=my-app` where `sessionPrefix: "app"` | +| No filter returns all | `api/sessions/route.test.ts` | All sessions returned when no query param | +| Non-existent project returns empty | `api/sessions/route.test.ts` | Empty array + zero stats when `?project=nonexistent` | +| Stats reflect filtered sessions | `api/sessions/route.test.ts` | `needsReview` = 1 when filtered set has 1 review-pending PR | +| Orchestrator scoped to project | `api/sessions/route.test.ts` | Returns `my-app-orchestrator` not `other-orchestrator` when `?project=my-app` | +| SSE filters by project | `api/events/route.test.ts` | Snapshot event only includes sessions matching project | +| useSessionEvents constructs URL with project | `hooks/useSessionEvents.test.ts` | Hook creates EventSource with `?project=X` param | + +### Integration Tests + +| Test Case | File | Description | +| --------------------------------- | --------------------------------- | ------------------------------------------------------- | +| Full SSR flow with project filter | `app/page.test.tsx` | Page renders with filtered sessions from SSR | +| Client-SSE sync | `__tests__/api-routes.test.ts` | SSE snapshot matches SSR initial state for same project | +| Multi-project isolation | `__tests__/multi-project.test.ts` | Switching projects via URL changes session set | + +### E2E Tests (Playwright) + +| Test Case | Description | +| ------------------- | --------------------------------------------------------------------------------- | +| Single project view | Load dashboard, verify sessions belong to configured project | +| URL project param | Navigate to `/?project=my-app`, verify only that project's sessions | +| All projects view | Navigate to `/?project=all`, verify all sessions shown | +| Real-time filter | Spawn session in project A, verify project B dashboard doesn't receive SSE update | + +--- + +## Self-Audit + +### Risks + +| Risk | Likelihood | Impact | Mitigation | +| --------------------------------------------- | ---------- | ------------------------------ | ----------------------------------------------------------------------- | +| Project name mismatch (config vs URL) | Medium | Confusion | Log warning when URL param doesn't match any configured project | +| Session missing projectId | Low | Incorrect filtering | Fallback to sessionPrefix matching (existing `resolveProject` behavior) | +| SSE client doesn't reconnect with new project | Low | Stale data | `useSessionEvents` recreates EventSource when `project` prop changes | +| Performance regression (filter overhead) | Very Low | Slower API | Filter is O(n) in-memory; negligible for typical session counts (<100) | +| Orchestrator not found for project | Medium | UI missing orchestrator button | Return `null`, gracefully hide orchestrator link | + +### Edge Cases + +1. **Session with `projectId` that doesn't match any config project** + - Current: Fallback to sessionPrefix or first project + - Target: Same fallback behavior; won't appear in project-scoped view unless matches prefix + +2. **Multiple orchestrators (one per project)** + - Current: Only one surfaced (first found) + - Target: Orchestrator for scoped project surfaced + +3. **Project renamed in config** + - Sessions with old `projectId` won't match new name + - Mitigation: sessionPrefix matching still works; user can use `?project=all` to find orphaned sessions + +4. **Empty project (no sessions)** + - Returns empty sessions, zero stats + - Dashboard shows "no sessions" message (existing behavior) + +5. **Special project name "all"** + - Reserved for showing all sessions + - If user has a project named "all", they must use exact match or rename project + +### Rollback Plan + +If issues arise post-deployment: + +1. **Immediate** — Remove `project` param from frontend, revert to unfiltered behavior +2. **API** — Leave backend filter in place (backward compatible), frontend just stops using it +3. **Full Rollback** — Revert commit, redeploy. No data migration needed. + +### Monitoring & Observability + +- Log API requests with `project` param (debug level) +- Track SSE connections per project (metric) +- Alert on empty project results for configured projects (might indicate config drift) + +--- + +## Out of Scope + +The following are explicitly **NOT** part of this change: + +- Project switcher UI component +- Per-project dashboard themes/branding +- Database schema changes +- Multi-tenant authentication/authorization +- Session migration between projects +- Project-level access control + +--- + +## References + +- `packages/web/src/lib/serialize.ts` — `resolveProject()` function (existing) +- `packages/core/src/types.ts` — `Session.projectId` field (existing) +- `agent-orchestrator.yaml.example` — Project configuration schema diff --git a/packages/web/src/__tests__/api-routes.test.ts b/packages/web/src/__tests__/api-routes.test.ts index 289f881e3..d9cc2328c 100644 --- a/packages/web/src/__tests__/api-routes.test.ts +++ b/packages/web/src/__tests__/api-routes.test.ts @@ -165,9 +165,16 @@ vi.mock("@/lib/services", () => ({ startBacklogPoller: vi.fn(() => {}), })); +vi.mock("@/lib/project-name", () => ({ + getProjectName: () => "My App", + getPrimaryProjectId: () => "my-app", + getAllProjects: () => [{ id: "my-app", name: "My App" }], +})); + // ── Import routes after mocking ─────────────────────────────────────── import { GET as sessionsGET } from "@/app/api/sessions/route"; +import { GET as projectsGET } from "@/app/api/projects/route"; import { POST as spawnPOST } from "@/app/api/spawn/route"; import { POST as sendPOST } from "@/app/api/sessions/[id]/send/route"; import { POST as messagePOST } from "@/app/api/sessions/[id]/message/route"; @@ -563,13 +570,13 @@ describe("API Routes", () => { describe("GET /api/events", () => { it("returns SSE content type", async () => { - const res = await eventsGET(); + const res = await eventsGET(makeRequest("http://localhost:3000/api/events")); expect(res.headers.get("Content-Type")).toBe("text/event-stream"); expect(res.headers.get("Cache-Control")).toBe("no-cache"); }); it("streams initial snapshot event", async () => { - const res = await eventsGET(); + const res = await eventsGET(makeRequest("http://localhost:3000/api/events")); const reader = res.body!.getReader(); const { value } = await reader.read(); reader.cancel(); @@ -583,5 +590,265 @@ describe("API Routes", () => { expect(event.sessions[0]).toHaveProperty("id"); expect(event.sessions[0]).toHaveProperty("attentionLevel"); }); + + it("excludes orchestrator sessions from snapshot", async () => { + const sessionsWithOrchestrator = [ + ...testSessions, + makeSession({ + id: "my-app-orchestrator", + projectId: "my-app", + status: "working", + activity: "active", + }), + ]; + (mockSessionManager.list as ReturnType).mockResolvedValueOnce( + sessionsWithOrchestrator, + ); + + 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 jsonStr = text.replace("data: ", "").trim(); + const event = JSON.parse(jsonStr); + + const sessionIds = event.sessions.map((s: { id: string }) => s.id); + expect(sessionIds).not.toContain("my-app-orchestrator"); + expect(sessionIds.every((id: string) => !id.endsWith("-orchestrator"))).toBe(true); + }); + + it("filters sessions by project query param", async () => { + const multiProjectSessions = [ + ...testSessions, + makeSession({ + id: "other-app-1", + projectId: "other-app", + status: "working", + activity: "active", + }), + ]; + (mockSessionManager.list as ReturnType).mockResolvedValueOnce( + multiProjectSessions, + ); + + const res = await eventsGET(makeRequest("http://localhost:3000/api/events?project=my-app")); + 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()); + + expect(event.sessions.every((s: { id: string }) => s.id !== "other-app-1")).toBe(true); + }); + + it("returns all non-orchestrator sessions when project=all", async () => { + const multiProjectSessions = [ + ...testSessions, + makeSession({ + id: "other-app-1", + projectId: "other-app", + status: "working", + activity: "active", + }), + makeSession({ + id: "my-app-orchestrator", + projectId: "my-app", + status: "working", + activity: "active", + }), + ]; + (mockSessionManager.list as ReturnType).mockResolvedValueOnce( + multiProjectSessions, + ); + + const res = await eventsGET(makeRequest("http://localhost:3000/api/events?project=all")); + 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()); + + // Should include both projects' worker sessions + const sessionIds = event.sessions.map((s: { id: string }) => s.id); + expect(sessionIds).toContain("other-app-1"); + // But exclude orchestrator + expect(sessionIds).not.toContain("my-app-orchestrator"); + }); + }); + + // ── GET /api/sessions?project=X (project filtering) ─────────────────────── + + describe("GET /api/sessions?project=X", () => { + it("filters sessions by projectId", async () => { + const multiProjectSessions = [ + ...testSessions, + makeSession({ + id: "other-app-1", + projectId: "other-app", + status: "working", + activity: "active", + }), + ]; + (mockSessionManager.list as ReturnType).mockResolvedValueOnce( + multiProjectSessions, + ); + + 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.sessions.every((s: { projectId: string }) => s.projectId === "my-app")).toBe( + true, + ); + }); + + it("filters sessions by sessionPrefix when projectId does not match", async () => { + const prefixMatchSessions = [ + makeSession({ id: "my-app-1", projectId: "", status: "working", activity: "active" }), + makeSession({ + id: "backend-1", + projectId: "backend", + status: "working", + activity: "active", + }), + ]; + (mockSessionManager.list as ReturnType).mockResolvedValueOnce( + prefixMatchSessions, + ); + + 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.sessions.length).toBe(1); + expect(data.sessions[0].id).toBe("my-app-1"); + }); + + it("returns all sessions when project=all", async () => { + const multiProjectSessions = [ + ...testSessions, + makeSession({ + id: "other-app-1", + projectId: "other-app", + status: "working", + activity: "active", + }), + ]; + (mockSessionManager.list as ReturnType).mockResolvedValueOnce( + multiProjectSessions, + ); + + const res = await sessionsGET(makeRequest("http://localhost:3000/api/sessions?project=all")); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.sessions.length).toBe(multiProjectSessions.length); + }); + + it("returns empty array for non-existent project", async () => { + const res = await sessionsGET( + makeRequest("http://localhost:3000/api/sessions?project=nonexistent"), + ); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.sessions).toEqual([]); + expect(data.stats.totalSessions).toBe(0); + }); + + it("finds orchestrator for the filtered project only", async () => { + const multiProjectSessions = [ + ...testSessions.filter((s) => !s.id.endsWith("-orchestrator")), + makeSession({ + id: "my-app-orchestrator", + projectId: "my-app", + status: "working", + activity: "active", + }), + makeSession({ + id: "other-app-orchestrator", + projectId: "other-app", + status: "working", + activity: "active", + }), + ]; + (mockSessionManager.list as ReturnType).mockResolvedValueOnce( + multiProjectSessions, + ); + + 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.orchestratorId).toBe("my-app-orchestrator"); + }); + + it("stats reflect only filtered sessions", async () => { + const multiProjectSessions = [ + makeSession({ + id: "my-app-1", + projectId: "my-app", + status: "needs_input", + activity: "waiting_input", + }), + makeSession({ + id: "my-app-2", + projectId: "my-app", + status: "mergeable", + activity: "idle", + pr: { + number: 1, + url: "", + title: "", + owner: "", + repo: "", + branch: "", + baseBranch: "", + isDraft: false, + }, + }), + makeSession({ + id: "other-app-1", + projectId: "other-app", + status: "needs_input", + activity: "waiting_input", + }), + ]; + (mockSessionManager.list as ReturnType).mockResolvedValueOnce( + multiProjectSessions, + ); + + 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.stats.totalSessions).toBe(2); + expect(data.stats.workingSessions).toBe(2); + }); + }); + + // ── GET /api/projects ──────────────────────────────────────────────── + + describe("GET /api/projects", () => { + it("returns list of configured projects", async () => { + const res = await projectsGET(makeRequest("http://localhost:3000/api/projects")); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.projects).toBeDefined(); + expect(Array.isArray(data.projects)).toBe(true); + expect(data.projects.length).toBe(1); + expect(data.projects[0]).toHaveProperty("id"); + expect(data.projects[0]).toHaveProperty("name"); + }); + + it("project includes id and name", async () => { + const res = await projectsGET(makeRequest("http://localhost:3000/api/projects")); + const data = await res.json(); + expect(data.projects[0].id).toBe("my-app"); + expect(data.projects[0].name).toBe("My App"); + }); }); }); diff --git a/packages/web/src/app/api/events/route.ts b/packages/web/src/app/api/events/route.ts index 653531f3a..f597af39d 100644 --- a/packages/web/src/app/api/events/route.ts +++ b/packages/web/src/app/api/events/route.ts @@ -1,34 +1,38 @@ import { getServices, startBacklogPoller } from "@/lib/services"; import { sessionToDashboard } from "@/lib/serialize"; import { getAttentionLevel } from "@/lib/types"; +import { filterWorkerSessions } from "@/lib/project-utils"; +import type { Session } from "@composio/ao-core"; export const dynamic = "force-dynamic"; -/** - * GET /api/events — SSE stream for real-time lifecycle events - * - * Sends session state updates to connected clients. - * Also starts the lifecycle manager and backlog poller on first connection. +/** GET /api/events — SSE stream for real-time lifecycle events + * Query params: + * - project: Filter to a specific project. "all" = no filter. */ -export async function GET(): Promise { +export async function GET(request: Request): Promise { + const { searchParams } = new URL(request.url); + const projectFilter = searchParams.get("project"); + const encoder = new TextEncoder(); let heartbeat: ReturnType | undefined; let updates: ReturnType | undefined; - // Start backlog poller (idempotent — lifecycle manager is started in services.ts) + const filterSessions = ( + sessions: Session[], + config: { projects: Record }, + ) => filterWorkerSessions(sessions, projectFilter, config.projects); + startBacklogPoller(); const stream = new ReadableStream({ start(controller) { - // Send initial snapshot void (async () => { try { - const { sessionManager, lifecycleManager } = await getServices(); + const { sessionManager, config } = await getServices(); const sessions = await sessionManager.list(); - const dashboardSessions = sessions.map(sessionToDashboard); - - // Include lifecycle state map for debugging/display - const lifecycleStates = Object.fromEntries(lifecycleManager.getStates()); + const filteredSessions = filterSessions(sessions, config); + const dashboardSessions = filteredSessions.map(sessionToDashboard); const initialEvent = { type: "snapshot", @@ -39,7 +43,6 @@ export async function GET(): Promise { attentionLevel: getAttentionLevel(s), lastActivityAt: s.lastActivityAt, })), - lifecycle: lifecycleStates, }; controller.enqueue(encoder.encode(`data: ${JSON.stringify(initialEvent)}\n\n`)); } catch { @@ -49,7 +52,6 @@ export async function GET(): Promise { } })(); - // Send periodic heartbeat heartbeat = setInterval(() => { try { controller.enqueue(encoder.encode(`: heartbeat\n\n`)); @@ -59,14 +61,14 @@ export async function GET(): Promise { } }, 15000); - // Poll for session state changes every 5 seconds updates = setInterval(() => { void (async () => { let dashboardSessions; try { - const { sessionManager } = await getServices(); + const { sessionManager, config } = await getServices(); const sessions = await sessionManager.list(); - dashboardSessions = sessions.map(sessionToDashboard); + const filteredSessions = filterSessions(sessions, config); + dashboardSessions = filteredSessions.map(sessionToDashboard); } catch { return; } diff --git a/packages/web/src/app/api/projects/route.ts b/packages/web/src/app/api/projects/route.ts new file mode 100644 index 000000000..a59556dcd --- /dev/null +++ b/packages/web/src/app/api/projects/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from "next/server"; +import { getAllProjects } from "@/lib/project-name"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + try { + const projects = getAllProjects(); + return NextResponse.json({ projects }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Failed to load projects" }, + { status: 500 }, + ); + } +} diff --git a/packages/web/src/app/api/sessions/route.ts b/packages/web/src/app/api/sessions/route.ts index 9d2fb84b6..f3f3c37d8 100644 --- a/packages/web/src/app/api/sessions/route.ts +++ b/packages/web/src/app/api/sessions/route.ts @@ -9,6 +9,7 @@ import { computeStats, } from "@/lib/serialize"; import { resolveGlobalPause } from "@/lib/global-pause"; +import { filterWorkerSessions, findOrchestratorSessionId } from "@/lib/project-utils"; const METADATA_ENRICH_TIMEOUT_MS = 3_000; const PR_ENRICH_TIMEOUT_MS = 4_000; @@ -29,25 +30,23 @@ async function settlesWithin(promise: Promise, timeoutMs: number): Prom } } -/** GET /api/sessions — List all sessions with full state +/** GET /api/sessions — List sessions with full state * Query params: + * - project: Filter to a specific project (by projectId or sessionPrefix). "all" = no filter. * - active=true: Only return non-exited sessions */ export async function GET(request: Request) { try { const { searchParams } = new URL(request.url); + const projectFilter = searchParams.get("project"); const activeOnly = searchParams.get("active") === "true"; const { config, registry, sessionManager } = await getServices(); const coreSessions = await sessionManager.list(); - // Find orchestrator session ID (if running) and expose to clients - const orchSession = coreSessions.find((s) => s.id.endsWith("-orchestrator")); - const orchestratorId = orchSession ? orchSession.id : null; - const globalPause = resolveGlobalPause(coreSessions); + const orchestratorId = findOrchestratorSessionId(coreSessions, projectFilter, config.projects); - // Filter out orchestrator sessions — they get their own button, not a card - let workerSessions = coreSessions.filter((s) => !s.id.endsWith("-orchestrator")); + let workerSessions = filterWorkerSessions(coreSessions, projectFilter, config.projects); // Convert to dashboard format let dashboardSessions = workerSessions.map(sessionToDashboard); @@ -90,7 +89,7 @@ export async function GET(request: Request) { sessions: dashboardSessions, stats: computeStats(dashboardSessions), orchestratorId, - globalPause, + globalPause: resolveGlobalPause(coreSessions), }); } catch (err) { return NextResponse.json( diff --git a/packages/web/src/app/page.tsx b/packages/web/src/app/page.tsx index 68194f7e9..3ac9d0173 100644 --- a/packages/web/src/app/page.tsx +++ b/packages/web/src/app/page.tsx @@ -1,4 +1,6 @@ import type { Metadata } from "next"; + +export const dynamic = "force-dynamic"; import { Dashboard } from "@/components/Dashboard"; import type { DashboardSession } from "@/lib/types"; import { getServices, getSCM } from "@/lib/services"; @@ -7,64 +9,66 @@ import { resolveProject, enrichSessionPR, enrichSessionsMetadata, - computeStats, } from "@/lib/serialize"; import { prCache, prCacheKey } from "@/lib/cache"; -import { getProjectName } from "@/lib/project-name"; +import { getPrimaryProjectId, getProjectName, getAllProjects } from "@/lib/project-name"; +import { filterWorkerSessions, findOrchestratorSessionId } from "@/lib/project-utils"; import { resolveGlobalPause, type GlobalPauseState } from "@/lib/global-pause"; -export const dynamic = "force-dynamic"; +function getSelectedProjectName(projectFilter: string | undefined): string { + if (projectFilter === "all") return "All Projects"; + const projects = getAllProjects(); + if (projectFilter) { + const selectedProject = projects.find((project) => project.id === projectFilter); + if (selectedProject) return selectedProject.name; + } + return getProjectName(); +} -export async function generateMetadata(): Promise { - const projectName = getProjectName(); - // Use absolute to opt out of the layout's "%s | project" template +export async function generateMetadata(props: { + searchParams: Promise<{ project?: string }>; +}): Promise { + const searchParams = await props.searchParams; + const projectFilter = searchParams.project ?? getPrimaryProjectId(); + const projectName = getSelectedProjectName(projectFilter); return { title: { absolute: `ao | ${projectName}` } }; } -export default async function Home() { +export default async function Home(props: { searchParams: Promise<{ project?: string }> }) { + const searchParams = await props.searchParams; let sessions: DashboardSession[] = []; - let orchestratorId: string | null = null; - let globalPause: GlobalPauseState | null = null; - let projectIds: string[] = []; - const projectName = getProjectName(); + let orchestratorId: string | null; + let globalPause: GlobalPauseState | null; + // Allow ?project=all to show all sessions (for multi-project setups) + const projectFilter = searchParams.project ?? getPrimaryProjectId(); + try { const { config, registry, sessionManager } = await getServices(); - projectIds = Object.keys(config.projects); const allSessions = await sessionManager.list(); + + orchestratorId = findOrchestratorSessionId(allSessions, projectFilter, config.projects); + globalPause = resolveGlobalPause(allSessions); - // Find the orchestrator session (any session ending with -orchestrator) - // Only set orchestratorId if an actual session exists (no fallback) - const orchSession = allSessions.find((s) => s.id.endsWith("-orchestrator")); - if (orchSession) { - orchestratorId = orchSession.id; - } + const coreSessions = filterWorkerSessions(allSessions, projectFilter, config.projects); - // Filter out orchestrator from worker sessions - const coreSessions = allSessions.filter((s) => !s.id.endsWith("-orchestrator")); sessions = coreSessions.map(sessionToDashboard); - // Enrich metadata (issue labels, agent summaries, issue titles) — cap at 3s const metaTimeout = new Promise((resolve) => setTimeout(resolve, 3_000)); await Promise.race([ enrichSessionsMetadata(coreSessions, sessions, config, registry), metaTimeout, ]); - // Enrich sessions that have PRs with live SCM data - // Skip enrichment for terminal sessions (merged, closed, done, terminated) const terminalStatuses = new Set(["merged", "killed", "cleanup", "done", "terminated"]); const enrichPromises = coreSessions.map((core, i) => { if (!core.pr) return Promise.resolve(); - // Check cache first (before terminal status check) const cacheKey = prCacheKey(core.pr.owner, core.pr.repo, core.pr.number); const cached = prCache.get(cacheKey); - // Apply cached data if available (for both terminal and non-terminal sessions) if (cached) { if (sessions[i].pr) { - // Apply ALL cached fields (not just some) sessions[i].pr.state = cached.state; sessions[i].pr.title = cached.title; sessions[i].pr.additions = cached.additions; @@ -85,8 +89,6 @@ export default async function Home() { sessions[i].pr.unresolvedComments = cached.unresolvedComments; } - // Skip enrichment if cache is fresh AND (terminal OR merged/closed) - // This allows terminal sessions to be enriched once when cache is missing/expired if ( terminalStatuses.has(core.status) || cached.state === "merged" || @@ -101,21 +103,26 @@ export default async function Home() { if (!scm) return Promise.resolve(); return enrichSessionPR(sessions[i], scm, core.pr); }); - // Cap enrichment at 4s — if GitHub is slow/rate-limited, serve stale data fast const enrichTimeout = new Promise((resolve) => setTimeout(resolve, 4_000)); await Promise.race([Promise.allSettled(enrichPromises), enrichTimeout]); } catch { - // Config not found or services unavailable — show empty dashboard + sessions = []; + orchestratorId = null; + globalPause = null; } + const projectName = getSelectedProjectName(projectFilter); + const projects = getAllProjects(); + const selectedProjectId = projectFilter === "all" ? undefined : projectFilter; + return ( ); } diff --git a/packages/web/src/components/Dashboard.tsx b/packages/web/src/components/Dashboard.tsx index cdca98b17..772a0da06 100644 --- a/packages/web/src/components/Dashboard.tsx +++ b/packages/web/src/components/Dashboard.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo, useState, useEffect, useCallback, type FormEvent } from "react"; +import { useEffect, useMemo, useState } from "react"; import { type DashboardSession, type DashboardStats, @@ -15,46 +15,36 @@ import { AttentionZone } from "./AttentionZone"; import { PRTableRow } from "./PRStatus"; import { DynamicFavicon } from "./DynamicFavicon"; import { useSessionEvents } from "@/hooks/useSessionEvents"; - -interface BacklogIssue { - id: string; - title: string; - url: string; - state: string; - labels: string[]; - projectId: string; -} +import { ProjectSidebar } from "./ProjectSidebar"; +import type { ProjectInfo } from "@/lib/project-name"; interface DashboardProps { initialSessions: DashboardSession[]; - stats: DashboardStats; orchestratorId?: string | null; + projectId?: string; projectName?: string; + projects?: ProjectInfo[]; initialGlobalPause?: GlobalPauseState | null; - projectIds?: string[]; } -type Tab = "board" | "backlog" | "verify" | "prs"; - const KANBAN_LEVELS = ["working", "pending", "review", "respond", "merge"] as const; export function Dashboard({ initialSessions, - stats: _initialStats, orchestratorId, + projectId, projectName, - initialGlobalPause, - projectIds = [], + projects = [], + initialGlobalPause = null, }: DashboardProps) { - const { sessions, globalPause } = useSessionEvents(initialSessions, initialGlobalPause ?? null); + const { sessions, globalPause } = useSessionEvents( + initialSessions, + initialGlobalPause, + projectId, + ); const [rateLimitDismissed, setRateLimitDismissed] = useState(false); - const [activeTab, setActiveTab] = useState("board"); - const [backlogIssues, setBacklogIssues] = useState([]); - const [backlogLoading, setBacklogLoading] = useState(false); - const [showCreateForm, setShowCreateForm] = useState(false); - const [verifyIssues, setVerifyIssues] = useState([]); - const [verifyLoading, setVerifyLoading] = useState(false); - + const [globalPauseDismissed, setGlobalPauseDismissed] = useState(false); + const showSidebar = projects.length > 1; const grouped = useMemo(() => { const zones: Record = { merge: [], @@ -77,77 +67,6 @@ export function Dashboard({ .sort((a, b) => mergeScore(a) - mergeScore(b)); }, [sessions]); - // Fetch backlog issues - const fetchBacklog = useCallback(async () => { - setBacklogLoading(true); - try { - const res = await fetch("/api/backlog"); - if (res.ok) { - const data = await res.json(); - setBacklogIssues(data.issues ?? []); - } - } catch { - // Non-critical - } finally { - setBacklogLoading(false); - } - }, []); - - // Fetch verify issues - const fetchVerify = useCallback(async () => { - setVerifyLoading(true); - try { - const res = await fetch("/api/verify"); - if (res.ok) { - const data = await res.json(); - setVerifyIssues(data.issues ?? []); - } - } catch { - // Non-critical - } finally { - setVerifyLoading(false); - } - }, []); - - useEffect(() => { - if (activeTab === "verify") { - fetchVerify(); - const interval = setInterval(fetchVerify, 30_000); - return () => clearInterval(interval); - } - }, [activeTab, fetchVerify]); - - const handleVerifyAction = async ( - issueId: string, - projectId: string, - action: "verify" | "fail", - ) => { - try { - const res = await fetch("/api/verify", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ issueId, projectId, action }), - }); - if (res.ok) { - setVerifyIssues((prev) => - prev.filter((i) => !(i.id === issueId && i.projectId === projectId)), - ); - } else { - console.error("Failed to update verify status:", await res.text()); - } - } catch (err) { - console.error("Failed to update verify status:", err); - } - }; - - useEffect(() => { - if (activeTab === "backlog") { - fetchBacklog(); - const interval = setInterval(fetchBacklog, 30_000); - return () => clearInterval(interval); - } - }, [activeTab, fetchBacklog]); - const handleSend = async (sessionId: string, message: string) => { const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/send`, { method: "POST", @@ -192,7 +111,6 @@ export function Dashboard({ () => sessions.some((s) => s.pr && isPRRateLimited(s.pr)), [sessions], ); - const liveStats = useMemo( () => ({ totalSessions: sessions.length, @@ -205,26 +123,28 @@ export function Dashboard({ }), [sessions], ); + const resumeAtLabel = useMemo(() => { + if (!globalPause) return null; + return new Date(globalPause.pausedUntil).toLocaleString(); + }, [globalPause]); - // Counts for tab badges - const backlogCount = backlogIssues.length; - const verifyCount = verifyIssues.length; - const prCount = openPRs.length; - const needsAttention = grouped.respond.length + grouped.merge.length; + useEffect(() => { + setGlobalPauseDismissed(false); + }, [globalPause?.pausedUntil, globalPause?.reason, globalPause?.sourceSessionId]); return ( -
- - - {/* Header */} -
-
-

- {projectName ?? "Orchestrator"} -

- -
-
+
+ {showSidebar && } +
+ + {/* Header */} +
+
+

+ {projectName ?? "Orchestrator"} +

+ +
{orchestratorId && ( )}
-
- {/* Tab bar */} -
- setActiveTab("board")} - badge={needsAttention > 0 ? needsAttention : undefined} - badgeColor="var(--color-status-error)" - > - Board - - setActiveTab("backlog")} - badge={backlogCount > 0 ? backlogCount : undefined} - badgeColor="var(--color-accent)" - > - Backlog - - setActiveTab("verify")} - badge={verifyCount > 0 ? verifyCount : undefined} - badgeColor="rgb(245, 158, 11)" - > - Verify - - setActiveTab("prs")} - badge={prCount > 0 ? prCount : undefined} - badgeColor="var(--color-status-ready)" - > - Pull Requests - -
- - {globalPause && ( -
- Orchestrator paused: {globalPause.reason}. Resume - after {new Date(globalPause.pausedUntil).toLocaleString()}. - {globalPause.sourceSessionId ? ` Source: ${globalPause.sourceSessionId}.` : ""} -
- )} - - {/* Rate limit notice */} - {anyRateLimited && !rateLimitDismissed && ( -
- - - - - - GitHub API rate limited — PR data (CI status, review state, sizes) may be stale. Will - retry automatically on next refresh. - - -
- )} - - {/* Board tab */} - {activeTab === "board" && ( - <> - {/* Kanban columns for active zones */} - {hasKanbanSessions ? ( -
- {KANBAN_LEVELS.map((level) => - grouped[level].length > 0 ? ( -
- -
- ) : null, + + Orchestrator paused: {globalPause.reason} + {resumeAtLabel && ( + Resume after {resumeAtLabel} )} -
- ) : ( - setActiveTab("backlog")} - className="rounded-md bg-[var(--color-accent)] px-4 py-2 text-[12px] font-semibold text-[var(--color-text-inverse)] hover:opacity-90" - > - View Backlog - - } - /> - )} - - {/* Done — full-width grid below Kanban */} - {grouped.done.length > 0 && ( -
- -
- )} - - )} - - {/* Backlog tab */} - {activeTab === "backlog" && ( -
-
-

- Issues labeled{" "} - - agent:backlog - {" "} - are auto-claimed by agents. Max {5} concurrent. -

+ {globalPause.sourceSessionId && ( + (Source: {globalPause.sourceSessionId}) + )} +
+ )} - {showCreateForm && ( - { - setShowCreateForm(false); - fetchBacklog(); - }} - onCancel={() => setShowCreateForm(false)} - /> - )} - - {backlogLoading && backlogIssues.length === 0 ? ( -
- Loading backlog... -
- ) : backlogIssues.length === 0 ? ( - - ) : ( -
- {backlogIssues.map((issue) => ( - - ))} -
- )} -
- )} - - {/* Verify tab */} - {activeTab === "verify" && ( -
-
-

- Issues labeled{" "} - + + + + + + GitHub API rate limited — PR data (CI status, review state, sizes) may be stale. Will + retry automatically on next refresh. + +

+ )} - {verifyLoading && verifyIssues.length === 0 ? ( -
- Loading issues to verify... -
- ) : verifyIssues.length === 0 ? ( - + {KANBAN_LEVELS.map((level) => + grouped[level].length > 0 ? ( +
+ +
+ ) : null, + )} +
+ )} + + {/* Done — full-width grid below Kanban */} + {grouped.done.length > 0 && ( +
+ - ) : ( -
- {verifyIssues.map((issue) => ( - handleVerifyAction(issue.id, issue.projectId, "verify")} - onFail={() => handleVerifyAction(issue.id, issue.projectId, "fail")} - /> - ))} +
+ )} + + {/* PR Table */} + {openPRs.length > 0 && ( +
+

+ Pull Requests +

+
+ + + + + + + + + + + + + {openPRs.map((pr) => ( + + ))} + +
+ PR + + Title + + Size + + CI + + Review + + Unresolved +
- )} -
- )} - - {/* PRs tab */} - {activeTab === "prs" && ( - <> - {openPRs.length > 0 ? ( -
-
- - - - - - - - - - - - - {openPRs.map((pr) => ( - - ))} - -
- PR - - Title - - Size - - CI - - Review - - Unresolved -
-
-
- ) : ( - - )} - - )} -
- ); -} - -// --------------------------------------------------------------------------- -// Sub-components -// --------------------------------------------------------------------------- - -function TabButton({ - active, - onClick, - badge, - badgeColor, - children, -}: { - active: boolean; - onClick: () => void; - badge?: number; - badgeColor?: string; - children: React.ReactNode; -}) { - return ( - - ); -} - -function EmptyState({ - title, - description, - action, -}: { - title: string; - description: string; - action?: React.ReactNode; -}) { - return ( -
-
{title}
-
- {description} -
- {action &&
{action}
} -
- ); -} - -function BacklogCard({ issue }: { issue: BacklogIssue }) { - return ( -
- - - - -
-
- #{issue.id} {issue.title} -
-
- {issue.projectId} - {issue.labels - .filter((l) => l !== "agent:backlog") - .map((label) => ( - - {label} - - ))} -
-
- - queued - -
- ); -} - -function VerifyCard({ - issue, - onVerify, - onFail, -}: { - issue: BacklogIssue; - onVerify: () => Promise; - onFail: () => Promise; -}) { - const [acting, setActing] = useState<"verify" | "fail" | null>(null); - - const handleAction = async (action: "verify" | "fail", handler: () => Promise) => { - setActing(action); - try { - await handler(); - } finally { - setActing(null); - } - }; - - return ( -
- - - - -
- - #{issue.id} {issue.title} - -
- {issue.projectId} - {issue.labels - .filter((l) => l !== "merged-unverified") - .map((label) => ( - - {label} - - ))} -
-
-
- - +
+ )}
); } -function CreateIssueForm({ - projectIds, - onCreated, - onCancel, -}: { - projectIds: string[]; - onCreated: () => void; - onCancel: () => void; -}) { - const [title, setTitle] = useState(""); - const [description, setDescription] = useState(""); - const [selectedProject, setSelectedProject] = useState(projectIds[0] ?? ""); - const [submitting, setSubmitting] = useState(false); - const [error, setError] = useState(null); - - const handleSubmit = async (e: FormEvent) => { - e.preventDefault(); - if (!title.trim() || !selectedProject) return; - - setSubmitting(true); - setError(null); - try { - const res = await fetch("/api/issues", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - projectId: selectedProject, - title: title.trim(), - description: description.trim(), - addToBacklog: true, - }), - }); - - if (!res.ok) { - const data = await res.json(); - throw new Error(data.error ?? "Failed to create issue"); - } - - setTitle(""); - setDescription(""); - onCreated(); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to create issue"); - } finally { - setSubmitting(false); - } - }; - - return ( -
- {projectIds.length > 1 && ( -
- -
- )} -
- setTitle(e.target.value)} - className="w-full rounded-md border border-[var(--color-border-default)] bg-[var(--color-bg-base)] px-3 py-2 text-[13px] text-[var(--color-text-primary)] placeholder-[var(--color-text-tertiary)] outline-none focus:border-[var(--color-accent)]" - autoFocus - /> -
-
-