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

417 lines
14 KiB
TypeScript

import "server-only";
/**
* Server-side singleton for core services.
*
* 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 {
getGlobalConfigPath,
loadConfig,
ConfigNotFoundError,
createPluginRegistry,
createSessionManager,
createLifecycleManager,
type LoadedConfig,
type PluginRegistry,
type OpenCodeSessionManager,
type LifecycleManager,
type SCM,
type ProjectConfig,
type Tracker,
type Issue,
type Session,
isOrchestratorSession,
TERMINAL_STATUSES,
} from "@aoagents/ao-core";
// Static plugin imports — webpack needs these to be string literals
import pluginRuntimeTmux from "@aoagents/ao-plugin-runtime-tmux";
import pluginRuntimeProcess from "@aoagents/ao-plugin-runtime-process";
import pluginAgentClaudeCode from "@aoagents/ao-plugin-agent-claude-code";
import pluginAgentCodex from "@aoagents/ao-plugin-agent-codex";
import pluginAgentCursor from "@aoagents/ao-plugin-agent-cursor";
import pluginAgentKimicode from "@aoagents/ao-plugin-agent-kimicode";
import pluginAgentGrok from "@aoagents/ao-plugin-agent-grok";
import pluginAgentOpencode from "@aoagents/ao-plugin-agent-opencode";
import pluginWorkspaceWorktree from "@aoagents/ao-plugin-workspace-worktree";
import pluginScmGithub from "@aoagents/ao-plugin-scm-github";
import pluginTrackerGithub from "@aoagents/ao-plugin-tracker-github";
import pluginTrackerLinear from "@aoagents/ao-plugin-tracker-linear";
export interface Services {
config: LoadedConfig;
registry: PluginRegistry;
sessionManager: OpenCodeSessionManager;
lifecycleManager: LifecycleManager;
}
// Cache in globalThis for Next.js HMR stability
const globalForServices = globalThis as typeof globalThis & {
_aoServices?: Services;
_aoServicesInit?: Promise<Services>;
_aoServicesGeneration?: number;
};
/** Get (or lazily initialize) the core services singleton. */
export function getServices(): Promise<Services> {
if (globalForServices._aoServices) {
return Promise.resolve(globalForServices._aoServices);
}
if (!globalForServices._aoServicesInit) {
const generation = globalForServices._aoServicesGeneration ?? 0;
const initPromise = initServices()
.then((services) => {
if ((globalForServices._aoServicesGeneration ?? 0) !== generation) {
services.lifecycleManager.stop();
return getServices();
}
globalForServices._aoServices = services;
return services;
})
.catch((err) => {
// Clear the cached promise so the next call retries instead of
// permanently returning a rejected promise.
if (globalForServices._aoServicesInit === initPromise) {
globalForServices._aoServicesInit = undefined;
}
throw err;
});
globalForServices._aoServicesInit = initPromise;
}
return globalForServices._aoServicesInit;
}
/** Clear the cached services singleton so subsequent requests reload config/plugins. */
export function invalidatePortfolioServicesCache(): void {
globalForServices._aoServicesGeneration = (globalForServices._aoServicesGeneration ?? 0) + 1;
if (globalForServices._aoServices) {
globalForServices._aoServices.lifecycleManager.stop();
}
globalForServices._aoServices = undefined;
globalForServices._aoServicesInit = undefined;
}
async function initServices(): Promise<Services> {
const config = loadDashboardConfig();
const registry = createPluginRegistry();
// Register plugins explicitly (webpack can't handle dynamic import() in core)
registry.register(pluginRuntimeTmux);
registry.register(pluginRuntimeProcess);
registry.register(pluginAgentClaudeCode);
registry.register(pluginAgentCodex);
registry.register(pluginAgentCursor);
registry.register(pluginAgentKimicode);
registry.register(pluginAgentGrok);
registry.register(pluginAgentOpencode);
registry.register(pluginWorkspaceWorktree);
registry.register(pluginScmGithub);
registry.register(pluginTrackerGithub);
registry.register(pluginTrackerLinear);
const sessionManager = createSessionManager({ config, registry });
// Lifecycle manager for webhook-triggered checks only — no independent polling.
// The CLI process (`ao`) runs the 30s polling loop and writes PR enrichment
// data to session metadata files. The dashboard reads from metadata instead
// of calling GitHub API directly. This means the dashboard is NOT self-sufficient:
// if the CLI process isn't running, sessions will have no PR enrichment data,
// no state transitions, and no reactions. The SSE endpoint surfaces whatever
// metadata the CLI has written — stale data is expected when CLI is down.
const lifecycleManager = createLifecycleManager({ config, registry, sessionManager });
return { config, registry, sessionManager, lifecycleManager };
}
function loadDashboardConfig(): LoadedConfig {
const globalConfigPath = getGlobalConfigPath();
try {
return loadConfig(globalConfigPath);
} catch (error) {
// The dashboard prefers the global portfolio config, but users may still
// launch it from a single repo that only has a local agent-orchestrator.yaml.
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
return loadConfig();
}
if (error instanceof ConfigNotFoundError) {
return loadConfig();
}
throw error;
}
}
// ---------------------------------------------------------------------------
// Backlog auto-claim — polls for labeled issues and auto-spawns agents
// ---------------------------------------------------------------------------
const BACKLOG_LABEL = "agent:backlog";
const BACKLOG_POLL_INTERVAL = 60_000; // 1 minute
const MAX_CONCURRENT_AGENTS = 5; // Max active agent sessions across all projects
const globalForBacklog = globalThis as typeof globalThis & {
_aoBacklogStarted?: boolean;
_aoBacklogTimer?: ReturnType<typeof setInterval>;
};
/** Start the backlog auto-claim loop. Idempotent — safe to call multiple times. */
export function startBacklogPoller(): void {
if (globalForBacklog._aoBacklogStarted) return;
globalForBacklog._aoBacklogStarted = true;
// Run immediately, then on interval
void pollBacklog();
globalForBacklog._aoBacklogTimer = setInterval(() => void pollBacklog(), BACKLOG_POLL_INTERVAL);
}
// Track which issues we've already processed to avoid repeated API calls
const processedIssues = new Set<string>();
/** Label GitHub issues for verification when their PRs have been merged. */
async function labelIssuesForVerification(
sessions: Session[],
config: LoadedConfig,
registry: PluginRegistry,
): Promise<void> {
const mergedSessions = sessions.filter(
(s) =>
s.lifecycle.pr.state === "merged" &&
s.issueId &&
!processedIssues.has(`${s.projectId}:${s.issueId}`),
);
for (const session of mergedSessions) {
const key = `${session.projectId}:${session.issueId}`;
const project = config.projects[session.projectId];
if (!project?.tracker?.plugin) {
processedIssues.add(key);
continue;
}
const tracker = registry.get<Tracker>("tracker", project.tracker.plugin);
if (!tracker?.updateIssue) {
processedIssues.add(key);
continue;
}
const issueId = session.issueId;
if (!issueId) {
processedIssues.add(key);
continue;
}
try {
await tracker.updateIssue(
issueId,
{
labels: ["merged-unverified"],
removeLabels: ["agent:backlog", "agent:in-progress"],
comment: `PR merged. Issue awaiting human verification on staging.`,
},
project,
);
} catch (err) {
console.error(`[backlog] Failed to close issue ${session.issueId}:`, err);
}
processedIssues.add(key);
}
}
/**
* Detect reopened issues (open + agent:done label) and swap the label
* back to agent:backlog so pollBacklog picks them up on the next cycle.
*/
async function relabelReopenedIssues(
config: LoadedConfig,
registry: PluginRegistry,
): Promise<void> {
for (const [, project] of Object.entries(config.projects)) {
if (!project.tracker?.plugin) continue;
const tracker = registry.get<Tracker>("tracker", project.tracker.plugin);
if (!tracker?.listIssues || !tracker.updateIssue) continue;
let reopened: Issue[];
try {
reopened = await tracker.listIssues(
{ state: "open", labels: ["agent:done"], limit: 20 },
project,
);
} catch {
continue;
}
for (const issue of reopened) {
try {
await tracker.updateIssue(
issue.id,
{
labels: [BACKLOG_LABEL],
removeLabels: ["agent:done"],
comment: "Issue reopened — returning to agent backlog.",
},
project,
);
console.log(`[backlog] Relabeled reopened issue ${issue.id}${BACKLOG_LABEL}`);
} catch (err) {
console.error(`[backlog] Failed to relabel reopened issue ${issue.id}:`, err);
}
}
}
}
export async function pollBacklog(): Promise<void> {
try {
const { config, registry, sessionManager } = await getServices();
// Get all sessions
const allSessions = await sessionManager.list();
// Label issues for verification when PRs are merged
await labelIssuesForVerification(allSessions, config, registry);
// Detect reopened issues: open state + agent:done label → relabel as agent:backlog
await relabelReopenedIssues(config, registry);
const allSessionPrefixes = Object.entries(config.projects).map(
([id, p]) => p.sessionPrefix ?? id,
);
const workerSessions = allSessions.filter(
(session) =>
!isOrchestratorSession(
session,
config.projects[session.projectId]?.sessionPrefix ?? session.projectId,
allSessionPrefixes,
) && !TERMINAL_STATUSES.has(session.status),
);
const activeIssueIds = new Set(
workerSessions
.map((session) => session.issueId?.toLowerCase())
.filter((issueId): issueId is string => Boolean(issueId)),
);
// Auto-scaling: respect max concurrent agents
let availableSlots = MAX_CONCURRENT_AGENTS - workerSessions.length;
if (availableSlots <= 0) return; // At capacity
for (const [projectId, project] of Object.entries(config.projects)) {
if (availableSlots <= 0) break;
if (!project.tracker?.plugin) continue;
const tracker = registry.get<Tracker>("tracker", project.tracker.plugin);
if (!tracker?.listIssues) continue;
let backlogIssues: Issue[];
try {
backlogIssues = await tracker.listIssues(
{ state: "open", labels: [BACKLOG_LABEL], limit: 10 },
project,
);
} catch {
continue; // Tracker unavailable — skip this project
}
for (const issue of backlogIssues) {
if (availableSlots <= 0) break;
// Skip if already being worked on
if (activeIssueIds.has(issue.id.toLowerCase())) continue;
try {
await sessionManager.spawn({ projectId, issueId: issue.id });
availableSlots--;
activeIssueIds.add(issue.id.toLowerCase());
// Mark as claimed on the tracker
if (tracker.updateIssue) {
await tracker.updateIssue(
issue.id,
{
labels: ["agent:in-progress"],
removeLabels: ["agent:backlog"],
comment: "Claimed by agent orchestrator — session spawned.",
},
project,
);
}
} catch (err) {
console.error(`[backlog] Failed to spawn session for issue ${issue.id}:`, err);
}
}
}
} catch (err) {
console.error("[backlog] Poll failed:", err);
}
}
/** Get backlog issues across all projects (for dashboard display). */
export async function getBacklogIssues(): Promise<Array<Issue & { projectId: string }>> {
const results: Array<Issue & { projectId: string }> = [];
try {
const { config, registry } = await getServices();
for (const [projectId, project] of Object.entries(config.projects)) {
if (!project.tracker?.plugin) continue;
const tracker = registry.get<Tracker>("tracker", project.tracker.plugin);
if (!tracker?.listIssues) continue;
try {
const issues = await tracker.listIssues(
{ state: "open", labels: [BACKLOG_LABEL], limit: 20 },
project,
);
for (const issue of issues) {
results.push({ ...issue, projectId });
}
} catch {
// Skip unavailable trackers
}
}
} catch {
// Services unavailable
}
return results;
}
/** Get issues labeled merged-unverified across all projects (for dashboard verify tab). */
export async function getVerifyIssues(): Promise<Array<Issue & { projectId: string }>> {
const results: Array<Issue & { projectId: string }> = [];
try {
const { config, registry } = await getServices();
for (const [projectId, project] of Object.entries(config.projects)) {
if (!project.tracker?.plugin) continue;
const tracker = registry.get<Tracker>("tracker", project.tracker.plugin);
if (!tracker?.listIssues) continue;
try {
const issues = await tracker.listIssues(
{ state: "open", labels: ["merged-unverified"], limit: 20 },
project,
);
for (const issue of issues) {
results.push({ ...issue, projectId });
}
} catch {
// Skip unavailable trackers
}
}
} catch {
// Services unavailable
}
return results;
}
/** Resolve the SCM plugin for a project. Returns null if not configured. */
export function getSCM(registry: PluginRegistry, project: ProjectConfig | undefined): SCM | null {
if (!project?.scm?.plugin) return null;
return registry.get<SCM>("scm", project.scm.plugin);
}