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 <noreply@anthropic.com>

* fix: update send test for fallback runtime handle behavior

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix: register workspace-worktree plugin in web services

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
prateek 2026-02-14 18:21:07 +05:30 committed by GitHub
parent b7431424ff
commit 24f14015e5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 141 additions and 28 deletions

View File

@ -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",
);
});
});

View File

@ -84,6 +84,8 @@ const VALID_STATUSES: ReadonlySet<string> = 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<string, string>):
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<RuntimeHandle>(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<RuntimeHandle>(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

View File

@ -350,6 +350,14 @@ export interface SCM {
/** Get current PR state */
getPRState(pr: PRInfo): Promise<PRState>;
/** 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<void>;

View File

@ -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<void> {
const flag =
method === "rebase"

View File

@ -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",

View File

@ -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);

View File

@ -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
}

View File

@ -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") {

View File

@ -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<Services> {
async function initServices(): Promise<Services> {
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 };

View File

@ -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