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:
parent
b7431424ff
commit
24f14015e5
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue