agent-orchestrator/packages/web/src/lib/serialize.ts

460 lines
15 KiB
TypeScript

import "server-only";
/**
* Core Session → DashboardSession serialization.
*
* Converts core types (Date objects, PRInfo) into dashboard types
* (string dates, flattened DashboardPR) suitable for JSON serialization.
*/
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,
DashboardOrchestratorLink,
} from "./types.js";
import { TTLCache, prCache, prCacheKey, type PREnrichmentData } from "./cache";
/** Cache for issue titles (5 min TTL — issue titles rarely change) */
const issueTitleCache = new TTLCache<string>(300_000);
/** Resolve which project a session belongs to. */
export function resolveProject(
core: Session,
projects: Record<string, ProjectConfig>,
): ProjectConfig | undefined {
// Try explicit projectId first
const direct = projects[core.projectId];
if (direct) return direct;
// Match by session prefix
const entry = Object.entries(projects).find(([, p]) => core.id.startsWith(p.sessionPrefix));
if (entry) return entry[1];
// Fall back to first project
const firstKey = Object.keys(projects)[0];
return firstKey ? projects[firstKey] : undefined;
}
/** Convert a core Session to a DashboardSession (without PR/issue enrichment). */
export function sessionToDashboard(session: Session): DashboardSession {
const agentSummary = session.agentInfo?.summary;
const summary = agentSummary ?? session.metadata["summary"] ?? null;
return {
id: session.id,
projectId: session.projectId,
status: session.status,
activity: session.activity,
branch: session.branch,
issueId: session.issueId, // Deprecated: kept for backwards compatibility
issueUrl: session.issueId, // issueId is actually the full URL
issueLabel: null, // Will be enriched by enrichSessionIssue()
issueTitle: null, // Will be enriched by enrichSessionIssueTitle()
summary,
summaryIsFallback: agentSummary ? (session.agentInfo?.summaryIsFallback ?? false) : false,
createdAt: session.createdAt.toISOString(),
lastActivityAt: session.lastActivityAt.toISOString(),
pr: session.pr ? basicPRToDashboard(session.pr) : null,
metadata: session.metadata,
};
}
export function listDashboardOrchestrators(
sessions: Session[],
projects: Record<string, ProjectConfig>,
): DashboardOrchestratorLink[] {
const allSessionPrefixes = Object.entries(projects).map(
([projectId, p]) => p.sessionPrefix ?? projectId,
);
return sessions
.filter((session) =>
isOrchestratorSession(
session,
projects[session.projectId]?.sessionPrefix ?? session.projectId,
allSessionPrefixes,
),
)
.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".
* Use enrichSessionPR() to populate with live data from SCM.
*/
function basicPRToDashboard(pr: PRInfo): DashboardPR {
return {
number: pr.number,
url: pr.url,
title: pr.title,
owner: pr.owner,
repo: pr.repo,
branch: pr.branch,
baseBranch: pr.baseBranch,
isDraft: pr.isDraft,
state: "open",
additions: 0,
deletions: 0,
ciStatus: "none", // "none" is neutral (no checks configured)
ciChecks: [],
reviewDecision: "none", // "none" is neutral (no review required)
mergeability: {
mergeable: false,
ciPassing: false, // Conservative default
approved: false,
noConflicts: true, // Optimistic default (conflicts are rare)
blockers: [],
},
unresolvedThreads: 0,
unresolvedComments: [],
enriched: false,
};
}
/**
* Enrich a DashboardSession's PR with live data from the SCM plugin.
* Uses cache to reduce API calls and handles rate limit errors gracefully.
*/
export async function enrichSessionPR(
dashboard: DashboardSession,
scm: SCM,
pr: PRInfo,
opts?: { cacheOnly?: boolean },
): Promise<boolean> {
if (!dashboard.pr) return false;
const cacheKey = prCacheKey(pr.owner, pr.repo, pr.number);
// Check cache first
const cached = prCache.get(cacheKey);
if (cached && dashboard.pr) {
dashboard.pr.state = cached.state;
dashboard.pr.title = cached.title;
dashboard.pr.additions = cached.additions;
dashboard.pr.deletions = cached.deletions;
dashboard.pr.ciStatus = cached.ciStatus;
dashboard.pr.ciChecks = cached.ciChecks;
dashboard.pr.reviewDecision = cached.reviewDecision;
dashboard.pr.mergeability = cached.mergeability;
dashboard.pr.unresolvedThreads = cached.unresolvedThreads;
dashboard.pr.unresolvedComments = cached.unresolvedComments;
dashboard.pr.enriched = true;
return true;
}
// Cache miss — if cacheOnly, signal caller to refresh in background
if (opts?.cacheOnly) return false;
// Fetch from SCM
const results = await Promise.allSettled([
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),
scm.getMergeability(pr),
scm.getPendingComments(pr),
]);
const [summaryR, checksR, ciR, reviewR, mergeR, commentsR] = results;
// Check if most critical requests failed (likely rate limit)
// Note: Some methods (like getCISummary) return fallback values instead of rejecting,
// so we can't rely on "all rejected" — check if majority failed instead
const failedCount = results.filter((r) => r.status === "rejected").length;
const mostFailed = failedCount >= results.length / 2;
if (mostFailed) {
const rejectedResults = results.filter(
(r) => r.status === "rejected",
) as PromiseRejectedResult[];
const firstError = rejectedResults[0]?.reason;
console.warn(
`[enrichSessionPR] ${failedCount}/${results.length} API calls failed for PR #${pr.number} (rate limited or unavailable):`,
String(firstError),
);
// Don't return early — apply any successful results below
}
// Apply successful results
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") {
dashboard.pr.ciChecks = checksR.value.map((c) => ({
name: c.name,
status: c.status,
url: c.url,
}));
}
if (ciR.status === "fulfilled") {
dashboard.pr.ciStatus = ciR.value;
}
if (reviewR.status === "fulfilled") {
dashboard.pr.reviewDecision = reviewR.value;
}
if (mergeR.status === "fulfilled") {
dashboard.pr.mergeability = mergeR.value;
} else {
// Mergeability failed — mark as unavailable
dashboard.pr.mergeability.blockers = ["Merge status unavailable"];
}
if (commentsR.status === "fulfilled") {
const comments = commentsR.value;
dashboard.pr.unresolvedThreads = comments.length;
dashboard.pr.unresolvedComments = comments.map((c) => ({
url: c.url,
path: c.path ?? "",
author: c.author,
body: c.body,
}));
}
// Mark as enriched — we attempted SCM API calls and applied whatever succeeded
dashboard.pr.enriched = true;
// Add rate-limit warning blocker if most requests failed
// (but we still applied any successful results above)
if (
mostFailed &&
!dashboard.pr.mergeability.blockers.includes("API rate limited or unavailable")
) {
dashboard.pr.mergeability.blockers.push("API rate limited or unavailable");
}
// If rate limited, cache the partial data with a long TTL (5 min) so we stop
// hammering the API on every page load. The rate-limit blocker flag tells the
// UI to show stale-data warnings instead of making decisions on bad data.
if (mostFailed) {
const rateLimitedData: PREnrichmentData = {
state: dashboard.pr.state,
title: dashboard.pr.title,
additions: dashboard.pr.additions,
deletions: dashboard.pr.deletions,
ciStatus: dashboard.pr.ciStatus,
ciChecks: dashboard.pr.ciChecks,
reviewDecision: dashboard.pr.reviewDecision,
mergeability: dashboard.pr.mergeability,
unresolvedThreads: dashboard.pr.unresolvedThreads,
unresolvedComments: dashboard.pr.unresolvedComments,
};
prCache.set(cacheKey, rateLimitedData, 60 * 60_000); // 60 min — GitHub rate limit resets hourly
return true;
}
const cacheData: PREnrichmentData = {
state: dashboard.pr.state,
title: dashboard.pr.title,
additions: dashboard.pr.additions,
deletions: dashboard.pr.deletions,
ciStatus: dashboard.pr.ciStatus,
ciChecks: dashboard.pr.ciChecks,
reviewDecision: dashboard.pr.reviewDecision,
mergeability: dashboard.pr.mergeability,
unresolvedThreads: dashboard.pr.unresolvedThreads,
unresolvedComments: dashboard.pr.unresolvedComments,
};
prCache.set(cacheKey, cacheData);
return true;
}
/** Enrich a DashboardSession's issue label using the tracker plugin. */
export function enrichSessionIssue(
dashboard: DashboardSession,
tracker: Tracker,
project: ProjectConfig,
): void {
if (!dashboard.issueUrl) return;
// Use tracker plugin to extract human-readable label from URL
if (tracker.issueLabel) {
try {
dashboard.issueLabel = tracker.issueLabel(dashboard.issueUrl, project);
} catch {
// If extraction fails, fall back to extracting from URL manually
const parts = dashboard.issueUrl.split("/");
dashboard.issueLabel = parts[parts.length - 1] || dashboard.issueUrl;
}
} else {
// Fallback if tracker doesn't implement issueLabel method
const parts = dashboard.issueUrl.split("/");
dashboard.issueLabel = parts[parts.length - 1] || dashboard.issueUrl;
}
}
/**
* Enrich a DashboardSession's summary by calling agent.getSessionInfo().
* Only fetches when the session doesn't already have a summary.
* Reads the agent's JSONL file on disk — fast local I/O, not an API call.
*/
export async function enrichSessionAgentSummary(
dashboard: DashboardSession,
coreSession: Session,
agent: Agent,
): Promise<void> {
if (dashboard.summary) return;
try {
const info = await agent.getSessionInfo(coreSession);
if (info?.summary) {
dashboard.summary = info.summary;
dashboard.summaryIsFallback = info.summaryIsFallback ?? false;
}
} catch {
// Can't read agent session info — keep summary null
}
}
/**
* Enrich a DashboardSession's issue title by calling tracker.getIssue().
* Extracts the identifier from the issue URL using issueLabel(),
* then fetches full issue details for the title.
*/
export async function enrichSessionIssueTitle(
dashboard: DashboardSession,
tracker: Tracker,
project: ProjectConfig,
): Promise<void> {
if (!dashboard.issueUrl || !dashboard.issueLabel) return;
// Check cache first
const cached = issueTitleCache.get(dashboard.issueUrl);
if (cached) {
dashboard.issueTitle = cached;
return;
}
try {
// Strip "#" prefix from GitHub-style labels to get the identifier
const identifier = dashboard.issueLabel.replace(/^#/, "");
const issue = await tracker.getIssue(identifier, project);
if (issue.title) {
dashboard.issueTitle = issue.title;
issueTitleCache.set(dashboard.issueUrl, issue.title);
}
} catch {
// Can't fetch issue — keep issueTitle null
}
}
/**
* Fast-path metadata enrichment: issue labels (sync) + agent summaries (local I/O).
* Does NOT call tracker API for issue titles — use enrichSessionsMetadata() for full enrichment.
*/
export async function enrichSessionsMetadataFast(
coreSessions: Session[],
dashboardSessions: DashboardSession[],
config: OrchestratorConfig,
registry: PluginRegistry,
): Promise<void> {
const { summaryPromises } = prepareSessionMetadataEnrichment(
coreSessions,
dashboardSessions,
config,
registry,
);
await Promise.allSettled(summaryPromises);
}
function prepareSessionMetadataEnrichment(
coreSessions: Session[],
dashboardSessions: DashboardSession[],
config: OrchestratorConfig,
registry: PluginRegistry,
): {
projects: Array<ProjectConfig | undefined>;
summaryPromises: Promise<void>[];
} {
const projects = coreSessions.map((core) => resolveProject(core, config.projects));
// Issue labels (synchronous string parsing, no API calls)
projects.forEach((project, i) => {
if (!dashboardSessions[i].issueUrl || !project?.tracker?.plugin) return;
const tracker = registry.get<Tracker>("tracker", project.tracker.plugin);
if (!tracker) return;
enrichSessionIssue(dashboardSessions[i], tracker, project);
});
// Agent summaries (local disk I/O — reads agent JSONL)
const summaryPromises = coreSessions.map((core, i) => {
if (dashboardSessions[i].summary) return Promise.resolve();
const agentName = projects[i]?.agent ?? config.defaults.agent;
if (!agentName) return Promise.resolve();
const agent = registry.get<Agent>("agent", agentName);
if (!agent) return Promise.resolve();
return enrichSessionAgentSummary(dashboardSessions[i], core, agent);
});
return { projects, summaryPromises };
}
/**
* Full metadata enrichment: issue labels, agent summaries, AND issue titles (tracker API).
* Used by /api/sessions for complete data. For SSR fast path, use enrichSessionsMetadataFast().
*/
export async function enrichSessionsMetadata(
coreSessions: Session[],
dashboardSessions: DashboardSession[],
config: OrchestratorConfig,
registry: PluginRegistry,
): Promise<void> {
const { projects, summaryPromises } = prepareSessionMetadataEnrichment(
coreSessions,
dashboardSessions,
config,
registry,
);
// Issue-title fetches depend on labels being set, but can run in parallel with summary I/O.
const issueTitlePromises = projects.map((project, i) => {
if (!dashboardSessions[i].issueUrl || !dashboardSessions[i].issueLabel) {
return Promise.resolve();
}
if (!project?.tracker?.plugin) return Promise.resolve();
const tracker = registry.get<Tracker>("tracker", project.tracker.plugin);
if (!tracker) return Promise.resolve();
return enrichSessionIssueTitle(dashboardSessions[i], tracker, project);
});
await Promise.allSettled([...summaryPromises, ...issueTitlePromises]);
}
/** Compute dashboard stats from a list of sessions. */
export function computeStats(sessions: DashboardSession[]): DashboardStats {
return {
totalSessions: sessions.length,
workingSessions: sessions.filter((s) => s.activity !== null && s.activity !== "exited").length,
openPRs: sessions.filter((s) => s.pr?.state === "open").length,
needsReview: sessions.filter((s) => s.pr && !s.pr.isDraft && s.pr.reviewDecision === "pending")
.length,
};
}