From 24f14015e58627dc5a8f72e56c34c19ea53fbb70 Mon Sep 17 00:00:00 2001 From: prateek Date: Sat, 14 Feb 2026 18:21:07 +0530 Subject: [PATCH] fix: wire dashboard to real session data with PR enrichment (#28) * fix: wire dashboard to real session data with PR enrichment - Parse owner/repo from GitHub PR URLs in session-manager for gh CLI calls - Add static plugin imports in web services.ts (webpack can't resolve dynamic imports) - Add PR enrichment to page.tsx server component with project matching fallback - Add project matching fallback in API route for sessions without projectId - Map bash "starting" status to "working" for backwards compatibility - Add fallback runtime handle for bash-created sessions without runtimeHandle - Add getPRSummary to SCM interface for fetching additions/deletions/title - Implement getPRSummary in scm-github plugin - Use getPRSummary in enrichSessionPR to populate PR diff stats - Add plugin-tracker-github dependency to web package Co-Authored-By: Claude Sonnet 4.5 * fix: update send test for fallback runtime handle behavior Co-Authored-By: Claude Sonnet 4.5 * fix: register workspace-worktree plugin in web services Co-Authored-By: Claude Sonnet 4.5 --------- Co-authored-by: Claude Sonnet 4.5 --- .../src/__tests__/session-manager.test.ts | 9 +++- packages/core/src/session-manager.ts | 45 ++++++++++++------- packages/core/src/types.ts | 8 ++++ packages/plugins/scm-github/src/index.ts | 26 +++++++++++ packages/web/package.json | 1 + packages/web/src/app/api/sessions/route.ts | 13 +++++- packages/web/src/app/page.tsx | 28 ++++++++++-- packages/web/src/lib/serialize.ts | 15 +++++-- packages/web/src/lib/services.ts | 21 ++++++++- pnpm-lock.yaml | 3 ++ 10 files changed, 141 insertions(+), 28 deletions(-) diff --git a/packages/core/src/__tests__/session-manager.test.ts b/packages/core/src/__tests__/session-manager.test.ts index c125c496b..6eecfad4b 100644 --- a/packages/core/src/__tests__/session-manager.test.ts +++ b/packages/core/src/__tests__/session-manager.test.ts @@ -468,7 +468,7 @@ describe("send", () => { await expect(sm.send("nope", "hello")).rejects.toThrow("not found"); }); - it("throws when no runtime handle", async () => { + it("falls back to session ID as runtime handle when no runtimeHandle stored", async () => { writeMetadata(dataDir, "app-1", { worktree: "/tmp", branch: "main", @@ -477,6 +477,11 @@ describe("send", () => { }); const sm = createSessionManager({ config, registry: mockRegistry }); - await expect(sm.send("app-1", "hello")).rejects.toThrow("No runtime handle"); + await sm.send("app-1", "hello"); + // Should use session ID "app-1" as the handle id with default runtime + expect(mockRuntime.sendMessage).toHaveBeenCalledWith( + { id: "app-1", runtimeName: "mock", data: {} }, + "hello", + ); }); }); diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index dc037a79c..d5685430d 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -84,6 +84,8 @@ const VALID_STATUSES: ReadonlySet = new Set([ /** Validate and normalize a status string. */ function validateStatus(raw: string | undefined): SessionStatus { + // Bash scripts write "starting" — treat as "working" + if (raw === "starting") return "working"; if (raw && VALID_STATUSES.has(raw)) return raw as SessionStatus; return "spawning"; } @@ -98,16 +100,21 @@ function metadataToSession(sessionId: SessionId, meta: Record): branch: meta["branch"] || null, issueId: meta["issue"] || null, pr: meta["pr"] - ? { - number: parseInt(meta["pr"].match(/\/(\d+)$/)?.[1] ?? "0", 10), - url: meta["pr"], - title: "", - owner: "", - repo: "", - branch: meta["branch"] ?? "", - baseBranch: "", - isDraft: false, - } + ? (() => { + // Parse owner/repo from GitHub PR URL: https://github.com/owner/repo/pull/123 + const prUrl = meta["pr"]; + const ghMatch = prUrl.match(/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/); + return { + number: ghMatch ? parseInt(ghMatch[3], 10) : parseInt(prUrl.match(/\/(\d+)$/)?.[1] ?? "0", 10), + url: prUrl, + title: "", + owner: ghMatch?.[1] ?? "", + repo: ghMatch?.[2] ?? "", + branch: meta["branch"] ?? "", + baseBranch: "", + isDraft: false, + }; + })() : null, workspacePath: meta["worktree"] || null, runtimeHandle: meta["runtimeHandle"] @@ -478,13 +485,17 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { const raw = readMetadataRaw(config.dataDir, sessionId); if (!raw) throw new Error(`Session ${sessionId} not found`); - if (!raw["runtimeHandle"]) { - throw new Error(`No runtime handle for session ${sessionId}`); - } - - const handle = safeJsonParse(raw["runtimeHandle"]); - if (!handle) { - throw new Error(`Corrupted runtime handle for session ${sessionId}`); + // Build handle: use stored runtimeHandle, or fall back to session ID as tmux session name + let handle: RuntimeHandle; + if (raw["runtimeHandle"]) { + const parsed = safeJsonParse(raw["runtimeHandle"]); + if (!parsed) { + throw new Error(`Corrupted runtime handle for session ${sessionId}`); + } + handle = parsed; + } else { + // Sessions created by bash scripts don't have runtimeHandle — use session ID as tmux handle + handle = { id: sessionId, runtimeName: config.defaults.runtime, data: {} }; } // Prefer handle.runtimeName to find the correct plugin diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index f86141479..5f72324d1 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -350,6 +350,14 @@ export interface SCM { /** Get current PR state */ getPRState(pr: PRInfo): Promise; + /** Get PR summary with stats (state, title, additions, deletions). Optional. */ + getPRSummary?(pr: PRInfo): Promise<{ + state: PRState; + title: string; + additions: number; + deletions: number; + }>; + /** Merge a PR */ mergePR(pr: PRInfo, method?: MergeMethod): Promise; diff --git a/packages/plugins/scm-github/src/index.ts b/packages/plugins/scm-github/src/index.ts index 66170f31d..a09d9ca6b 100644 --- a/packages/plugins/scm-github/src/index.ts +++ b/packages/plugins/scm-github/src/index.ts @@ -144,6 +144,32 @@ function createGitHubSCM(): SCM { return "open"; }, + async getPRSummary(pr: PRInfo) { + const raw = await gh([ + "pr", + "view", + String(pr.number), + "--repo", + repoFlag(pr), + "--json", + "state,title,additions,deletions", + ]); + const data: { + state: string; + title: string; + additions: number; + deletions: number; + } = JSON.parse(raw); + const s = data.state.toUpperCase(); + const state: PRState = s === "MERGED" ? "merged" : s === "CLOSED" ? "closed" : "open"; + return { + state, + title: data.title ?? "", + additions: data.additions ?? 0, + deletions: data.deletions ?? 0, + }; + }, + async mergePR(pr: PRInfo, method: MergeMethod = "squash"): Promise { const flag = method === "rebase" diff --git a/packages/web/package.json b/packages/web/package.json index 5f62cb1f0..55e0fd16f 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -20,6 +20,7 @@ "@agent-orchestrator/plugin-agent-claude-code": "workspace:*", "@agent-orchestrator/plugin-runtime-tmux": "workspace:*", "@agent-orchestrator/plugin-scm-github": "workspace:*", + "@agent-orchestrator/plugin-tracker-github": "workspace:*", "@agent-orchestrator/plugin-workspace-worktree": "workspace:*", "next": "^15.1.0", "react": "^19.0.0", diff --git a/packages/web/src/app/api/sessions/route.ts b/packages/web/src/app/api/sessions/route.ts index 74911d50d..8f32d86e5 100644 --- a/packages/web/src/app/api/sessions/route.ts +++ b/packages/web/src/app/api/sessions/route.ts @@ -13,7 +13,18 @@ export async function GET() { // Enrich sessions that have PRs with live SCM data (CI, reviews, mergeability) const enrichPromises = coreSessions.map((core, i) => { if (!core.pr) return Promise.resolve(); - const project = config.projects[core.projectId]; + // Try explicit projectId, then match by session prefix, then first project + let project = config.projects[core.projectId]; + if (!project) { + const projectEntry = Object.entries(config.projects).find(([, p]) => + core.id.startsWith(p.sessionPrefix), + ); + if (projectEntry) project = projectEntry[1]; + } + if (!project) { + const firstKey = Object.keys(config.projects)[0]; + if (firstKey) project = config.projects[firstKey]; + } const scm = getSCM(registry, project); if (!scm) return Promise.resolve(); return enrichSessionPR(dashboardSessions[i], scm, core.pr); diff --git a/packages/web/src/app/page.tsx b/packages/web/src/app/page.tsx index 9cfaacb83..8a5d70b0a 100644 --- a/packages/web/src/app/page.tsx +++ b/packages/web/src/app/page.tsx @@ -1,14 +1,36 @@ import { Dashboard } from "@/components/Dashboard"; import type { DashboardSession } from "@/lib/types"; -import { getServices } from "@/lib/services"; -import { sessionToDashboard, computeStats } from "@/lib/serialize"; +import { getServices, getSCM } from "@/lib/services"; +import { sessionToDashboard, enrichSessionPR, computeStats } from "@/lib/serialize"; + +export const dynamic = "force-dynamic"; export default async function Home() { let sessions: DashboardSession[] = []; try { - const { sessionManager } = await getServices(); + const { config, registry, sessionManager } = await getServices(); const coreSessions = await sessionManager.list(); sessions = coreSessions.map(sessionToDashboard); + + // Enrich sessions that have PRs with live SCM data + const enrichPromises = coreSessions.map((core, i) => { + if (!core.pr) return Promise.resolve(); + let project = config.projects[core.projectId]; + if (!project) { + const entry = Object.entries(config.projects).find(([, p]) => + core.id.startsWith(p.sessionPrefix), + ); + if (entry) project = entry[1]; + } + if (!project) { + const firstKey = Object.keys(config.projects)[0]; + if (firstKey) project = config.projects[firstKey]; + } + const scm = getSCM(registry, project); + if (!scm) return Promise.resolve(); + return enrichSessionPR(sessions[i], scm, core.pr); + }); + await Promise.allSettled(enrichPromises); } catch { // Config not found or services unavailable — show empty dashboard } diff --git a/packages/web/src/lib/serialize.ts b/packages/web/src/lib/serialize.ts index 9f166c624..20e29a4ee 100644 --- a/packages/web/src/lib/serialize.ts +++ b/packages/web/src/lib/serialize.ts @@ -63,7 +63,9 @@ export async function enrichSessionPR( if (!dashboard.pr) return; const results = await Promise.allSettled([ - scm.getPRState(pr), + scm.getPRSummary + ? scm.getPRSummary(pr) + : scm.getPRState(pr).then((state) => ({ state, title: "", additions: 0, deletions: 0 })), scm.getCIChecks(pr), scm.getCISummary(pr), scm.getReviewDecision(pr), @@ -71,10 +73,15 @@ export async function enrichSessionPR( scm.getPendingComments(pr), ]); - const [stateR, checksR, ciR, reviewR, mergeR, commentsR] = results; + const [summaryR, checksR, ciR, reviewR, mergeR, commentsR] = results; - if (stateR.status === "fulfilled") { - dashboard.pr.state = stateR.value; + if (summaryR.status === "fulfilled") { + dashboard.pr.state = summaryR.value.state; + dashboard.pr.additions = summaryR.value.additions; + dashboard.pr.deletions = summaryR.value.deletions; + if (summaryR.value.title) { + dashboard.pr.title = summaryR.value.title; + } } if (checksR.status === "fulfilled") { diff --git a/packages/web/src/lib/services.ts b/packages/web/src/lib/services.ts index 2f30ec4dd..16493fa75 100644 --- a/packages/web/src/lib/services.ts +++ b/packages/web/src/lib/services.ts @@ -3,6 +3,11 @@ * * Lazily initializes config, plugin registry, and session manager. * Cached in globalThis to survive Next.js HMR reloads in development. + * + * NOTE: Plugins are explicitly imported here because Next.js webpack + * cannot resolve dynamic `import(variable)` expressions used by the + * core plugin registry's loadBuiltins(). Static imports let webpack + * bundle them correctly. */ import { @@ -16,6 +21,13 @@ import { type ProjectConfig, } from "@agent-orchestrator/core"; +// Static plugin imports — webpack needs these to be string literals +import pluginRuntimeTmux from "@agent-orchestrator/plugin-runtime-tmux"; +import pluginAgentClaudeCode from "@agent-orchestrator/plugin-agent-claude-code"; +import pluginWorkspaceWorktree from "@agent-orchestrator/plugin-workspace-worktree"; +import pluginScmGithub from "@agent-orchestrator/plugin-scm-github"; +import pluginTrackerGithub from "@agent-orchestrator/plugin-tracker-github"; + export interface Services { config: OrchestratorConfig; registry: PluginRegistry; @@ -47,7 +59,14 @@ export function getServices(): Promise { async function initServices(): Promise { const config = loadConfig(); const registry = createPluginRegistry(); - await registry.loadFromConfig(config); + + // Register plugins explicitly (webpack can't handle dynamic import() in core) + registry.register(pluginRuntimeTmux); + registry.register(pluginAgentClaudeCode); + registry.register(pluginWorkspaceWorktree); + registry.register(pluginScmGithub); + registry.register(pluginTrackerGithub); + const sessionManager = createSessionManager({ config, registry }); const services = { config, registry, sessionManager }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dd68d3af3..b32042bf4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -439,6 +439,9 @@ importers: '@agent-orchestrator/plugin-scm-github': specifier: workspace:* version: link:../plugins/scm-github + '@agent-orchestrator/plugin-tracker-github': + specifier: workspace:* + version: link:../plugins/tracker-github '@agent-orchestrator/plugin-workspace-worktree': specifier: workspace:* version: link:../plugins/workspace-worktree