feat: wire web dashboard API routes to real core services
Replace all mock data in web API routes with real SessionManager, SCM, and plugin calls. Add services singleton for lazy initialization and a serialization layer for core Session → DashboardSession conversion. Closes INT-1346 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1eba42097b
commit
ecf470599d
|
|
@ -17,6 +17,10 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@agent-orchestrator/core": "workspace:*",
|
||||
"@agent-orchestrator/plugin-agent-claude-code": "workspace:*",
|
||||
"@agent-orchestrator/plugin-runtime-tmux": "workspace:*",
|
||||
"@agent-orchestrator/plugin-scm-github": "workspace:*",
|
||||
"@agent-orchestrator/plugin-workspace-worktree": "workspace:*",
|
||||
"next": "^15.1.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,142 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
import type { Session, SessionManager, OrchestratorConfig, PluginRegistry, SCM } from "@agent-orchestrator/core";
|
||||
|
||||
// ── Mock Data ─────────────────────────────────────────────────────────
|
||||
// Provides test sessions covering the key states the dashboard needs.
|
||||
|
||||
function makeSession(overrides: Partial<Session> & { id: string }): Session {
|
||||
return {
|
||||
projectId: "my-app",
|
||||
status: "working",
|
||||
activity: "active",
|
||||
branch: null,
|
||||
issueId: null,
|
||||
pr: null,
|
||||
workspacePath: null,
|
||||
runtimeHandle: null,
|
||||
agentInfo: null,
|
||||
createdAt: new Date(),
|
||||
lastActivityAt: new Date(),
|
||||
metadata: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const testSessions: Session[] = [
|
||||
makeSession({ id: "backend-3", status: "needs_input", activity: "waiting_input" }),
|
||||
makeSession({
|
||||
id: "backend-7",
|
||||
status: "mergeable",
|
||||
activity: "idle",
|
||||
pr: {
|
||||
number: 432,
|
||||
url: "https://github.com/acme/my-app/pull/432",
|
||||
title: "feat: health check",
|
||||
owner: "acme",
|
||||
repo: "my-app",
|
||||
branch: "feat/health-check",
|
||||
baseBranch: "main",
|
||||
isDraft: false,
|
||||
},
|
||||
}),
|
||||
makeSession({ id: "backend-9", status: "working", activity: "active" }),
|
||||
makeSession({
|
||||
id: "frontend-1",
|
||||
status: "killed",
|
||||
activity: "exited",
|
||||
projectId: "my-app",
|
||||
issueId: "INT-1270",
|
||||
branch: "feat/INT-1270-table",
|
||||
}),
|
||||
];
|
||||
|
||||
// ── Mock Services ─────────────────────────────────────────────────────
|
||||
|
||||
const mockSessionManager: SessionManager = {
|
||||
list: vi.fn(async () => testSessions),
|
||||
get: vi.fn(async (id: string) => testSessions.find((s) => s.id === id) ?? null),
|
||||
spawn: vi.fn(async (config) =>
|
||||
makeSession({
|
||||
id: `session-${Date.now()}`,
|
||||
projectId: config.projectId,
|
||||
issueId: config.issueId ?? null,
|
||||
status: "spawning",
|
||||
}),
|
||||
),
|
||||
kill: vi.fn(async (id: string) => {
|
||||
if (!testSessions.find((s) => s.id === id)) {
|
||||
throw new Error(`Session ${id} not found`);
|
||||
}
|
||||
}),
|
||||
send: vi.fn(async (id: string) => {
|
||||
if (!testSessions.find((s) => s.id === id)) {
|
||||
throw new Error(`Session ${id} not found`);
|
||||
}
|
||||
}),
|
||||
cleanup: vi.fn(async () => ({ killed: [], skipped: [], errors: [] })),
|
||||
};
|
||||
|
||||
const mockSCM: SCM = {
|
||||
name: "github",
|
||||
detectPR: vi.fn(async () => null),
|
||||
getPRState: vi.fn(async () => "open" as const),
|
||||
mergePR: vi.fn(async () => {}),
|
||||
closePR: vi.fn(async () => {}),
|
||||
getCIChecks: vi.fn(async () => []),
|
||||
getCISummary: vi.fn(async () => "passing" as const),
|
||||
getReviews: vi.fn(async () => []),
|
||||
getReviewDecision: vi.fn(async () => "approved" as const),
|
||||
getPendingComments: vi.fn(async () => []),
|
||||
getAutomatedComments: vi.fn(async () => []),
|
||||
getMergeability: vi.fn(async () => ({
|
||||
mergeable: true,
|
||||
ciPassing: true,
|
||||
approved: true,
|
||||
noConflicts: true,
|
||||
blockers: [],
|
||||
})),
|
||||
};
|
||||
|
||||
const mockRegistry: PluginRegistry = {
|
||||
register: vi.fn(),
|
||||
get: vi.fn(() => mockSCM) as PluginRegistry["get"],
|
||||
list: vi.fn(() => []),
|
||||
loadBuiltins: vi.fn(async () => {}),
|
||||
loadFromConfig: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
const mockConfig: OrchestratorConfig = {
|
||||
dataDir: "/tmp/ao-test",
|
||||
worktreeDir: "/tmp/ao-worktrees",
|
||||
port: 3000,
|
||||
defaults: { runtime: "tmux", agent: "claude-code", workspace: "worktree", notifiers: [] },
|
||||
projects: {
|
||||
"my-app": {
|
||||
name: "My App",
|
||||
repo: "acme/my-app",
|
||||
path: "/tmp/my-app",
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "my-app",
|
||||
scm: { plugin: "github" },
|
||||
},
|
||||
},
|
||||
notifiers: {},
|
||||
notificationRouting: { urgent: [], action: [], warning: [], info: [] },
|
||||
reactions: {},
|
||||
};
|
||||
|
||||
vi.mock("@/lib/services", () => ({
|
||||
getServices: vi.fn(async () => ({
|
||||
config: mockConfig,
|
||||
registry: mockRegistry,
|
||||
sessionManager: mockSessionManager,
|
||||
})),
|
||||
getSCM: vi.fn(() => mockSCM),
|
||||
}));
|
||||
|
||||
// ── Import routes after mocking ───────────────────────────────────────
|
||||
|
||||
// Import route handlers directly
|
||||
import { GET as sessionsGET } from "@/app/api/sessions/route";
|
||||
import { POST as spawnPOST } from "@/app/api/spawn/route";
|
||||
import { POST as sendPOST } from "@/app/api/sessions/[id]/send/route";
|
||||
|
|
@ -17,6 +152,15 @@ function makeRequest(url: string, init?: RequestInit): NextRequest {
|
|||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Re-set default return values
|
||||
(mockSessionManager.list as ReturnType<typeof vi.fn>).mockResolvedValue(testSessions);
|
||||
(mockSessionManager.get as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
async (id: string) => testSessions.find((s) => s.id === id) ?? null,
|
||||
);
|
||||
});
|
||||
|
||||
describe("API Routes", () => {
|
||||
// ── GET /api/sessions ──────────────────────────────────────────────
|
||||
|
||||
|
|
@ -27,7 +171,7 @@ describe("API Routes", () => {
|
|||
const data = await res.json();
|
||||
expect(data.sessions).toBeDefined();
|
||||
expect(Array.isArray(data.sessions)).toBe(true);
|
||||
expect(data.sessions.length).toBeGreaterThan(0);
|
||||
expect(data.sessions.length).toBe(testSessions.length);
|
||||
expect(data.stats).toBeDefined();
|
||||
expect(data.stats.totalSessions).toBe(data.sessions.length);
|
||||
});
|
||||
|
|
@ -67,7 +211,6 @@ describe("API Routes", () => {
|
|||
const data = await res.json();
|
||||
expect(data.session).toBeDefined();
|
||||
expect(data.session.projectId).toBe("my-app");
|
||||
expect(data.session.issueId).toBe("INT-100");
|
||||
expect(data.session.status).toBe("spawning");
|
||||
});
|
||||
|
||||
|
|
@ -123,6 +266,9 @@ describe("API Routes", () => {
|
|||
});
|
||||
|
||||
it("returns 404 for unknown session", async () => {
|
||||
(mockSessionManager.send as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
||||
new Error("Session nonexistent not found"),
|
||||
);
|
||||
const req = makeRequest("/api/sessions/nonexistent/send", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ message: "hello" }),
|
||||
|
|
@ -180,6 +326,9 @@ describe("API Routes", () => {
|
|||
});
|
||||
|
||||
it("returns 404 for unknown session", async () => {
|
||||
(mockSessionManager.kill as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
||||
new Error("Session nonexistent not found"),
|
||||
);
|
||||
const req = makeRequest("/api/sessions/nonexistent/kill", { method: "POST" });
|
||||
const res = await killPOST(req, { params: Promise.resolve({ id: "nonexistent" }) });
|
||||
expect(res.status).toBe(404);
|
||||
|
|
@ -217,7 +366,6 @@ describe("API Routes", () => {
|
|||
|
||||
describe("POST /api/prs/:id/merge", () => {
|
||||
it("merges a mergeable PR", async () => {
|
||||
// PR #432 (backend-7) is mergeable in mock data
|
||||
const req = makeRequest("/api/prs/432/merge", { method: "POST" });
|
||||
const res = await mergePOST(req, { params: Promise.resolve({ id: "432" }) });
|
||||
expect(res.status).toBe(200);
|
||||
|
|
@ -233,9 +381,15 @@ describe("API Routes", () => {
|
|||
});
|
||||
|
||||
it("returns 422 for non-mergeable PR", async () => {
|
||||
// PR #428 (backend-5) has failing CI → not mergeable
|
||||
const req = makeRequest("/api/prs/428/merge", { method: "POST" });
|
||||
const res = await mergePOST(req, { params: Promise.resolve({ id: "428" }) });
|
||||
(mockSCM.getMergeability as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
mergeable: false,
|
||||
ciPassing: false,
|
||||
approved: false,
|
||||
noConflicts: true,
|
||||
blockers: ["CI checks failing", "Needs review"],
|
||||
});
|
||||
const req = makeRequest("/api/prs/432/merge", { method: "POST" });
|
||||
const res = await mergePOST(req, { params: Promise.resolve({ id: "432" }) });
|
||||
expect(res.status).toBe(422);
|
||||
const data = await res.json();
|
||||
expect(data.error).toMatch(/not mergeable/);
|
||||
|
|
@ -250,19 +404,10 @@ describe("API Routes", () => {
|
|||
expect(data.error).toMatch(/Invalid PR number/);
|
||||
});
|
||||
|
||||
it("returns 422 for draft PR", async () => {
|
||||
// PR #440 (frontend-9) is a draft
|
||||
const req = makeRequest("/api/prs/440/merge", { method: "POST" });
|
||||
const res = await mergePOST(req, { params: Promise.resolve({ id: "440" }) });
|
||||
expect(res.status).toBe(422);
|
||||
const data = await res.json();
|
||||
expect(data.error).toMatch(/draft/i);
|
||||
});
|
||||
|
||||
it("returns 409 for merged PR", async () => {
|
||||
// PR #410 (backend-1) is merged
|
||||
const req = makeRequest("/api/prs/410/merge", { method: "POST" });
|
||||
const res = await mergePOST(req, { params: Promise.resolve({ id: "410" }) });
|
||||
(mockSCM.getPRState as ReturnType<typeof vi.fn>).mockResolvedValueOnce("merged");
|
||||
const req = makeRequest("/api/prs/432/merge", { method: "POST" });
|
||||
const res = await mergePOST(req, { params: Promise.resolve({ id: "432" }) });
|
||||
expect(res.status).toBe(409);
|
||||
const data = await res.json();
|
||||
expect(data.error).toMatch(/merged/);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { mockSessions } from "@/lib/mock-data";
|
||||
import { getServices } from "@/lib/services";
|
||||
import { sessionToDashboard } from "@/lib/serialize";
|
||||
import { getAttentionLevel } from "@/lib/types";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
|
@ -7,7 +8,7 @@ export const dynamic = "force-dynamic";
|
|||
* GET /api/events — SSE stream for real-time lifecycle events
|
||||
*
|
||||
* Sends session state updates to connected clients.
|
||||
* In production, this will be wired to the core EventBus.
|
||||
* Polls SessionManager.list() on an interval (no SSE push from core yet).
|
||||
*/
|
||||
export async function GET(): Promise<Response> {
|
||||
const encoder = new TextEncoder();
|
||||
|
|
@ -16,18 +17,31 @@ export async function GET(): Promise<Response> {
|
|||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
// Send initial state
|
||||
const initialEvent = {
|
||||
type: "snapshot",
|
||||
sessions: mockSessions.map((s) => ({
|
||||
id: s.id,
|
||||
status: s.status,
|
||||
activity: s.activity,
|
||||
attentionLevel: getAttentionLevel(s),
|
||||
lastActivityAt: s.lastActivityAt,
|
||||
})),
|
||||
};
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(initialEvent)}\n\n`));
|
||||
// Send initial snapshot
|
||||
void (async () => {
|
||||
try {
|
||||
const { sessionManager } = await getServices();
|
||||
const sessions = await sessionManager.list();
|
||||
const dashboardSessions = sessions.map(sessionToDashboard);
|
||||
|
||||
const initialEvent = {
|
||||
type: "snapshot",
|
||||
sessions: dashboardSessions.map((s) => ({
|
||||
id: s.id,
|
||||
status: s.status,
|
||||
activity: s.activity,
|
||||
attentionLevel: getAttentionLevel(s),
|
||||
lastActivityAt: s.lastActivityAt,
|
||||
})),
|
||||
};
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(initialEvent)}\n\n`));
|
||||
} catch {
|
||||
// If services aren't available, send empty snapshot
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: "snapshot", sessions: [] })}\n\n`),
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
// Send periodic heartbeat
|
||||
heartbeat = setInterval(() => {
|
||||
|
|
@ -39,24 +53,29 @@ export async function GET(): Promise<Response> {
|
|||
}
|
||||
}, 15000);
|
||||
|
||||
// Simulate activity updates every 5 seconds
|
||||
// Poll for session state changes every 5 seconds
|
||||
updates = setInterval(() => {
|
||||
try {
|
||||
if (mockSessions.length === 0) return;
|
||||
const randomSession = mockSessions[Math.floor(Math.random() * mockSessions.length)];
|
||||
const event = {
|
||||
type: "session.activity",
|
||||
sessionId: randomSession.id,
|
||||
activity: randomSession.activity,
|
||||
status: randomSession.status,
|
||||
attentionLevel: getAttentionLevel(randomSession),
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
|
||||
} catch {
|
||||
clearInterval(updates);
|
||||
clearInterval(heartbeat);
|
||||
}
|
||||
void (async () => {
|
||||
try {
|
||||
const { sessionManager } = await getServices();
|
||||
const sessions = await sessionManager.list();
|
||||
const dashboardSessions = sessions.map(sessionToDashboard);
|
||||
|
||||
for (const s of dashboardSessions) {
|
||||
const event = {
|
||||
type: "session.activity",
|
||||
sessionId: s.id,
|
||||
activity: s.activity,
|
||||
status: s.status,
|
||||
attentionLevel: getAttentionLevel(s),
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
|
||||
}
|
||||
} catch {
|
||||
// Silently skip failed polls — next interval will retry
|
||||
}
|
||||
})();
|
||||
}, 5000);
|
||||
},
|
||||
cancel() {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { mockSessions } from "@/lib/mock-data";
|
||||
import { getServices, getSCM } from "@/lib/services";
|
||||
|
||||
/** POST /api/prs/:id/merge — Merge a PR */
|
||||
export async function POST(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
|
|
@ -9,26 +9,41 @@ export async function POST(_request: NextRequest, { params }: { params: Promise<
|
|||
}
|
||||
const prNumber = Number(id);
|
||||
|
||||
const session = mockSessions.find((s) => s.pr?.number === prNumber);
|
||||
if (!session?.pr) {
|
||||
return NextResponse.json({ error: "PR not found" }, { status: 404 });
|
||||
}
|
||||
try {
|
||||
const { config, registry, sessionManager } = await getServices();
|
||||
const sessions = await sessionManager.list();
|
||||
|
||||
if (session.pr.state !== "open") {
|
||||
return NextResponse.json({ error: `PR is ${session.pr.state}, not open` }, { status: 409 });
|
||||
}
|
||||
const session = sessions.find((s) => s.pr?.number === prNumber);
|
||||
if (!session?.pr) {
|
||||
return NextResponse.json({ error: "PR not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (session.pr.isDraft) {
|
||||
return NextResponse.json({ error: "Cannot merge a draft PR" }, { status: 422 });
|
||||
}
|
||||
const project = config.projects[session.projectId];
|
||||
const scm = getSCM(registry, project);
|
||||
if (!scm) {
|
||||
return NextResponse.json({ error: "No SCM plugin configured for this project" }, { status: 500 });
|
||||
}
|
||||
|
||||
if (!session.pr.mergeability.mergeable) {
|
||||
// Validate PR is in a mergeable state
|
||||
const state = await scm.getPRState(session.pr);
|
||||
if (state !== "open") {
|
||||
return NextResponse.json({ error: `PR is ${state}, not open` }, { status: 409 });
|
||||
}
|
||||
|
||||
const mergeability = await scm.getMergeability(session.pr);
|
||||
if (!mergeability.mergeable) {
|
||||
return NextResponse.json(
|
||||
{ error: "PR is not mergeable", blockers: mergeability.blockers },
|
||||
{ status: 422 },
|
||||
);
|
||||
}
|
||||
|
||||
await scm.mergePR(session.pr, "squash");
|
||||
return NextResponse.json({ ok: true, prNumber, method: "squash" });
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: "PR is not mergeable", blockers: session.pr.mergeability.blockers },
|
||||
{ status: 422 },
|
||||
{ error: err instanceof Error ? err.message : "Failed to merge PR" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: wire to core SCM.mergePR()
|
||||
return NextResponse.json({ ok: true, prNumber, method: "squash" });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { getMockSession } from "@/lib/mock-data";
|
||||
import { validateIdentifier } from "@/lib/validation";
|
||||
import { getServices } from "@/lib/services";
|
||||
|
||||
/** POST /api/sessions/:id/kill — Kill a session */
|
||||
export async function POST(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
|
|
@ -10,11 +10,13 @@ export async function POST(_request: NextRequest, { params }: { params: Promise<
|
|||
return NextResponse.json({ error: idErr }, { status: 400 });
|
||||
}
|
||||
|
||||
const session = getMockSession(id);
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
||||
try {
|
||||
const { sessionManager } = await getServices();
|
||||
await sessionManager.kill(id);
|
||||
return NextResponse.json({ ok: true, sessionId: id });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Failed to kill session";
|
||||
const status = msg.includes("not found") ? 404 : 500;
|
||||
return NextResponse.json({ error: msg }, { status });
|
||||
}
|
||||
|
||||
// TODO: wire to core SessionManager.kill()
|
||||
return NextResponse.json({ ok: true, sessionId: id });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { getMockSession } from "@/lib/mock-data";
|
||||
import { validateIdentifier } from "@/lib/validation";
|
||||
import { getServices } from "@/lib/services";
|
||||
import { sessionToDashboard } from "@/lib/serialize";
|
||||
|
||||
/** Terminal states that can be restored */
|
||||
const RESTORABLE_STATUSES = new Set(["killed", "cleanup"]);
|
||||
|
|
@ -17,22 +18,35 @@ export async function POST(_request: NextRequest, { params }: { params: Promise<
|
|||
return NextResponse.json({ error: idErr }, { status: 400 });
|
||||
}
|
||||
|
||||
const session = getMockSession(id);
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
||||
try {
|
||||
const { sessionManager } = await getServices();
|
||||
const session = await sessionManager.get(id);
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (NON_RESTORABLE_STATUSES.has(session.status)) {
|
||||
return NextResponse.json({ error: "Cannot restore a merged session" }, { status: 409 });
|
||||
}
|
||||
|
||||
const isTerminal =
|
||||
RESTORABLE_STATUSES.has(session.status) || RESTORABLE_ACTIVITIES.has(session.activity);
|
||||
|
||||
if (!isTerminal) {
|
||||
return NextResponse.json({ error: "Session is not in a terminal state" }, { status: 409 });
|
||||
}
|
||||
|
||||
// Re-spawn with the same project and issue to create a fresh session
|
||||
const newSession = await sessionManager.spawn({
|
||||
projectId: session.projectId,
|
||||
issueId: session.issueId ?? undefined,
|
||||
branch: session.branch ?? undefined,
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true, sessionId: id, newSession: sessionToDashboard(newSession) });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Failed to restore session";
|
||||
const status = msg.includes("not found") ? 404 : 500;
|
||||
return NextResponse.json({ error: msg }, { status });
|
||||
}
|
||||
|
||||
if (NON_RESTORABLE_STATUSES.has(session.status)) {
|
||||
return NextResponse.json({ error: "Cannot restore a merged session" }, { status: 409 });
|
||||
}
|
||||
|
||||
const isTerminal =
|
||||
RESTORABLE_STATUSES.has(session.status) || RESTORABLE_ACTIVITIES.has(session.activity);
|
||||
|
||||
if (!isTerminal) {
|
||||
return NextResponse.json({ error: "Session is not in a terminal state" }, { status: 409 });
|
||||
}
|
||||
|
||||
// TODO: wire to core SessionManager.restore()
|
||||
return NextResponse.json({ ok: true, sessionId: id });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { getMockSession } from "@/lib/mock-data";
|
||||
import { validateIdentifier, validateString, stripControlChars } from "@/lib/validation";
|
||||
import { getServices } from "@/lib/services";
|
||||
|
||||
const MAX_MESSAGE_LENGTH = 10_000;
|
||||
|
||||
|
|
@ -12,11 +12,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
|||
return NextResponse.json({ error: idErr }, { status: 400 });
|
||||
}
|
||||
|
||||
const session = getMockSession(id);
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => null)) as Record<string, unknown> | null;
|
||||
const messageErr = validateString(body?.message, "message", MAX_MESSAGE_LENGTH);
|
||||
if (messageErr) {
|
||||
|
|
@ -34,6 +29,13 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
|||
);
|
||||
}
|
||||
|
||||
// TODO: wire to core SessionManager.send()
|
||||
return NextResponse.json({ ok: true, sessionId: id, message });
|
||||
try {
|
||||
const { sessionManager } = await getServices();
|
||||
await sessionManager.send(id, message);
|
||||
return NextResponse.json({ ok: true, sessionId: id, message });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Failed to send message";
|
||||
const status = msg.includes("not found") ? 404 : 500;
|
||||
return NextResponse.json({ error: msg }, { status });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,33 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { mockSessions, getMockStats } from "@/lib/mock-data";
|
||||
import { getServices, getSCM } from "@/lib/services";
|
||||
import { sessionToDashboard, enrichSessionPR, computeStats } from "@/lib/serialize";
|
||||
|
||||
/** GET /api/sessions — List all sessions with full state */
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
sessions: mockSessions,
|
||||
stats: getMockStats(),
|
||||
});
|
||||
try {
|
||||
const { config, registry, sessionManager } = await getServices();
|
||||
const coreSessions = await sessionManager.list();
|
||||
|
||||
const dashboardSessions = coreSessions.map(sessionToDashboard);
|
||||
|
||||
// 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];
|
||||
const scm = getSCM(registry, project);
|
||||
if (!scm) return Promise.resolve();
|
||||
return enrichSessionPR(dashboardSessions[i], scm, core.pr);
|
||||
});
|
||||
await Promise.allSettled(enrichPromises);
|
||||
|
||||
return NextResponse.json({
|
||||
sessions: dashboardSessions,
|
||||
stats: computeStats(dashboardSessions),
|
||||
});
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : "Failed to list sessions" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { validateIdentifier } from "@/lib/validation";
|
||||
import { getServices } from "@/lib/services";
|
||||
import { sessionToDashboard } from "@/lib/serialize";
|
||||
|
||||
/** POST /api/spawn — Spawn a new session */
|
||||
export async function POST(request: NextRequest) {
|
||||
|
|
@ -20,20 +22,18 @@ export async function POST(request: NextRequest) {
|
|||
}
|
||||
}
|
||||
|
||||
// TODO: wire to core SessionManager.spawn()
|
||||
const mockSession = {
|
||||
id: `session-${Date.now()}`,
|
||||
projectId: body.projectId as string,
|
||||
issueId: (body.issueId as string) ?? null,
|
||||
status: "spawning",
|
||||
activity: "active",
|
||||
branch: null,
|
||||
summary: `Spawning session for ${(body.issueId as string) ?? body.projectId}`,
|
||||
createdAt: new Date().toISOString(),
|
||||
lastActivityAt: new Date().toISOString(),
|
||||
pr: null,
|
||||
metadata: {},
|
||||
};
|
||||
try {
|
||||
const { sessionManager } = await getServices();
|
||||
const session = await sessionManager.spawn({
|
||||
projectId: body.projectId as string,
|
||||
issueId: (body.issueId as string) ?? undefined,
|
||||
});
|
||||
|
||||
return NextResponse.json({ session: mockSession }, { status: 201 });
|
||||
return NextResponse.json({ session: sessionToDashboard(session) }, { status: 201 });
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : "Failed to spawn session" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,17 @@
|
|||
import { Dashboard } from "@/components/Dashboard";
|
||||
import { mockSessions, getMockStats } from "@/lib/mock-data";
|
||||
import type { DashboardSession } from "@/lib/types";
|
||||
import { getServices } from "@/lib/services";
|
||||
import { sessionToDashboard, computeStats } from "@/lib/serialize";
|
||||
|
||||
export default function Home() {
|
||||
return <Dashboard sessions={mockSessions} stats={getMockStats()} />;
|
||||
export default async function Home() {
|
||||
let sessions: DashboardSession[] = [];
|
||||
try {
|
||||
const { sessionManager } = await getServices();
|
||||
const coreSessions = await sessionManager.list();
|
||||
sessions = coreSessions.map(sessionToDashboard);
|
||||
} catch {
|
||||
// Config not found or services unavailable — show empty dashboard
|
||||
}
|
||||
|
||||
return <Dashboard sessions={sessions} stats={computeStats(sessions)} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { notFound } from "next/navigation";
|
||||
import { getMockSession } from "@/lib/mock-data";
|
||||
import { getServices } from "@/lib/services";
|
||||
import { sessionToDashboard } from "@/lib/serialize";
|
||||
import { SessionDetail } from "@/components/SessionDetail";
|
||||
|
||||
interface Props {
|
||||
|
|
@ -8,11 +9,17 @@ interface Props {
|
|||
|
||||
export default async function SessionPage({ params }: Props) {
|
||||
const { id } = await params;
|
||||
const session = getMockSession(id);
|
||||
|
||||
if (!session) {
|
||||
const { sessionManager } = await getServices().catch(() => {
|
||||
notFound();
|
||||
// notFound() throws, so this never runs, but TS needs the return type
|
||||
return null as never;
|
||||
});
|
||||
|
||||
const coreSession = await sessionManager.get(id);
|
||||
if (!coreSession) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return <SessionDetail session={session} />;
|
||||
return <SessionDetail session={sessionToDashboard(coreSession)} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,538 +0,0 @@
|
|||
import type { DashboardSession, DashboardStats } from "./types";
|
||||
|
||||
/**
|
||||
* Mock data for the dashboard.
|
||||
* Covers all attention zones: urgent, action, warning, ok, done.
|
||||
*
|
||||
* TODO: Remove this file when wiring to real data sources. These fixtures
|
||||
* are imported by API routes and pages, so they end up in the server bundle.
|
||||
* Consider moving to __fixtures__/ or gating behind an env check once
|
||||
* real SessionManager integration is in place.
|
||||
*/
|
||||
|
||||
const now = new Date();
|
||||
const ago = (minutes: number) => new Date(now.getTime() - minutes * 60000).toISOString();
|
||||
|
||||
export const mockSessions: DashboardSession[] = [
|
||||
// URGENT: needs human input — agent is blocked asking a question
|
||||
{
|
||||
id: "backend-3",
|
||||
projectId: "my-app",
|
||||
status: "needs_input",
|
||||
activity: "waiting_input",
|
||||
branch: "feat/INT-1280-auth-refactor",
|
||||
issueId: "INT-1280",
|
||||
summary: "Refactoring authentication to use JWT — blocked on key rotation strategy",
|
||||
createdAt: ago(180),
|
||||
lastActivityAt: ago(2),
|
||||
pr: {
|
||||
number: 421,
|
||||
url: "https://github.com/acme/my-app/pull/421",
|
||||
title: "feat: refactor auth to use JWT with key rotation",
|
||||
owner: "acme",
|
||||
repo: "my-app",
|
||||
branch: "feat/INT-1280-auth-refactor",
|
||||
baseBranch: "main",
|
||||
isDraft: false,
|
||||
state: "open",
|
||||
additions: 342,
|
||||
deletions: 187,
|
||||
ciStatus: "passing",
|
||||
ciChecks: [
|
||||
{ name: "build", status: "passed", url: "https://github.com/acme/my-app/actions/runs/1" },
|
||||
{ name: "test", status: "passed", url: "https://github.com/acme/my-app/actions/runs/2" },
|
||||
{ name: "lint", status: "passed", url: "https://github.com/acme/my-app/actions/runs/3" },
|
||||
],
|
||||
reviewDecision: "pending",
|
||||
mergeability: {
|
||||
mergeable: false,
|
||||
ciPassing: true,
|
||||
approved: false,
|
||||
noConflicts: true,
|
||||
blockers: ["Needs review"],
|
||||
},
|
||||
unresolvedThreads: 0,
|
||||
unresolvedComments: [],
|
||||
},
|
||||
metadata: {},
|
||||
},
|
||||
|
||||
// URGENT: CI failing
|
||||
{
|
||||
id: "backend-5",
|
||||
projectId: "my-app",
|
||||
status: "ci_failed",
|
||||
activity: "idle",
|
||||
branch: "feat/INT-1295-rate-limiting",
|
||||
issueId: "INT-1295",
|
||||
summary: "Adding rate limiting middleware — CI failing on integration tests",
|
||||
createdAt: ago(120),
|
||||
lastActivityAt: ago(15),
|
||||
pr: {
|
||||
number: 428,
|
||||
url: "https://github.com/acme/my-app/pull/428",
|
||||
title: "feat: add rate limiting middleware with Redis backend",
|
||||
owner: "acme",
|
||||
repo: "my-app",
|
||||
branch: "feat/INT-1295-rate-limiting",
|
||||
baseBranch: "main",
|
||||
isDraft: false,
|
||||
state: "open",
|
||||
additions: 567,
|
||||
deletions: 23,
|
||||
ciStatus: "failing",
|
||||
ciChecks: [
|
||||
{ name: "build", status: "passed", url: "https://github.com/acme/my-app/actions/runs/10" },
|
||||
{
|
||||
name: "unit-tests",
|
||||
status: "passed",
|
||||
url: "https://github.com/acme/my-app/actions/runs/11",
|
||||
},
|
||||
{
|
||||
name: "integration-tests",
|
||||
status: "failed",
|
||||
url: "https://github.com/acme/my-app/actions/runs/12",
|
||||
},
|
||||
{ name: "lint", status: "passed", url: "https://github.com/acme/my-app/actions/runs/13" },
|
||||
],
|
||||
reviewDecision: "none",
|
||||
mergeability: {
|
||||
mergeable: false,
|
||||
ciPassing: false,
|
||||
approved: false,
|
||||
noConflicts: true,
|
||||
blockers: ["CI checks failing", "Needs review"],
|
||||
},
|
||||
unresolvedThreads: 0,
|
||||
unresolvedComments: [],
|
||||
},
|
||||
metadata: {},
|
||||
},
|
||||
|
||||
// URGENT: changes requested + unresolved comments
|
||||
{
|
||||
id: "frontend-2",
|
||||
projectId: "my-app",
|
||||
status: "changes_requested",
|
||||
activity: "idle",
|
||||
branch: "feat/INT-1301-search-ui",
|
||||
issueId: "INT-1301",
|
||||
summary: "Implementing search UI — changes requested by reviewer",
|
||||
createdAt: ago(300),
|
||||
lastActivityAt: ago(45),
|
||||
pr: {
|
||||
number: 415,
|
||||
url: "https://github.com/acme/my-app/pull/415",
|
||||
title: "feat: add full-text search with autocomplete",
|
||||
owner: "acme",
|
||||
repo: "my-app",
|
||||
branch: "feat/INT-1301-search-ui",
|
||||
baseBranch: "main",
|
||||
isDraft: false,
|
||||
state: "open",
|
||||
additions: 891,
|
||||
deletions: 156,
|
||||
ciStatus: "passing",
|
||||
ciChecks: [
|
||||
{ name: "build", status: "passed" },
|
||||
{ name: "test", status: "passed" },
|
||||
{ name: "lint", status: "passed" },
|
||||
{ name: "e2e", status: "passed" },
|
||||
],
|
||||
reviewDecision: "changes_requested",
|
||||
mergeability: {
|
||||
mergeable: false,
|
||||
ciPassing: true,
|
||||
approved: false,
|
||||
noConflicts: true,
|
||||
blockers: ["Changes requested"],
|
||||
},
|
||||
unresolvedThreads: 3,
|
||||
unresolvedComments: [
|
||||
{
|
||||
url: "https://github.com/acme/my-app/pull/415#discussion_r1",
|
||||
path: "src/components/Search.tsx",
|
||||
author: "reviewer1",
|
||||
body: "This should debounce the API calls",
|
||||
},
|
||||
{
|
||||
url: "https://github.com/acme/my-app/pull/415#discussion_r2",
|
||||
path: "src/hooks/useSearch.ts",
|
||||
author: "reviewer1",
|
||||
body: "Missing error handling for network failures",
|
||||
},
|
||||
{
|
||||
url: "https://github.com/acme/my-app/pull/415#discussion_r3",
|
||||
path: "src/components/SearchResults.tsx",
|
||||
author: "reviewer2",
|
||||
body: "Accessibility: needs aria-live region for results",
|
||||
},
|
||||
],
|
||||
},
|
||||
metadata: {},
|
||||
},
|
||||
|
||||
// ACTION: ready to merge
|
||||
{
|
||||
id: "backend-7",
|
||||
projectId: "my-app",
|
||||
status: "mergeable",
|
||||
activity: "idle",
|
||||
branch: "feat/INT-1310-health-check",
|
||||
issueId: "INT-1310",
|
||||
summary: "Added health check endpoint with dependency status",
|
||||
createdAt: ago(90),
|
||||
lastActivityAt: ago(30),
|
||||
pr: {
|
||||
number: 432,
|
||||
url: "https://github.com/acme/my-app/pull/432",
|
||||
title: "feat: add /health endpoint with dependency checks",
|
||||
owner: "acme",
|
||||
repo: "my-app",
|
||||
branch: "feat/INT-1310-health-check",
|
||||
baseBranch: "main",
|
||||
isDraft: false,
|
||||
state: "open",
|
||||
additions: 145,
|
||||
deletions: 8,
|
||||
ciStatus: "passing",
|
||||
ciChecks: [
|
||||
{ name: "build", status: "passed" },
|
||||
{ name: "test", status: "passed" },
|
||||
{ name: "lint", status: "passed" },
|
||||
],
|
||||
reviewDecision: "approved",
|
||||
mergeability: {
|
||||
mergeable: true,
|
||||
ciPassing: true,
|
||||
approved: true,
|
||||
noConflicts: true,
|
||||
blockers: [],
|
||||
},
|
||||
unresolvedThreads: 0,
|
||||
unresolvedComments: [],
|
||||
},
|
||||
metadata: {},
|
||||
},
|
||||
|
||||
// ACTION: ready to merge (another one)
|
||||
{
|
||||
id: "frontend-4",
|
||||
projectId: "my-app",
|
||||
status: "mergeable",
|
||||
activity: "idle",
|
||||
branch: "fix/INT-1315-date-picker",
|
||||
issueId: "INT-1315",
|
||||
summary: "Fixed date picker timezone handling",
|
||||
createdAt: ago(60),
|
||||
lastActivityAt: ago(20),
|
||||
pr: {
|
||||
number: 435,
|
||||
url: "https://github.com/acme/my-app/pull/435",
|
||||
title: "fix: date picker timezone conversion for UTC users",
|
||||
owner: "acme",
|
||||
repo: "my-app",
|
||||
branch: "fix/INT-1315-date-picker",
|
||||
baseBranch: "main",
|
||||
isDraft: false,
|
||||
state: "open",
|
||||
additions: 47,
|
||||
deletions: 12,
|
||||
ciStatus: "passing",
|
||||
ciChecks: [
|
||||
{ name: "build", status: "passed" },
|
||||
{ name: "test", status: "passed" },
|
||||
{ name: "lint", status: "passed" },
|
||||
],
|
||||
reviewDecision: "approved",
|
||||
mergeability: {
|
||||
mergeable: true,
|
||||
ciPassing: true,
|
||||
approved: true,
|
||||
noConflicts: true,
|
||||
blockers: [],
|
||||
},
|
||||
unresolvedThreads: 0,
|
||||
unresolvedComments: [],
|
||||
},
|
||||
metadata: {},
|
||||
},
|
||||
|
||||
// WARNING: PR open, needs review
|
||||
{
|
||||
id: "backend-8",
|
||||
projectId: "my-app",
|
||||
status: "review_pending",
|
||||
activity: "idle",
|
||||
branch: "feat/INT-1318-websocket",
|
||||
issueId: "INT-1318",
|
||||
summary: "WebSocket support for real-time notifications",
|
||||
createdAt: ago(240),
|
||||
lastActivityAt: ago(60),
|
||||
pr: {
|
||||
number: 430,
|
||||
url: "https://github.com/acme/my-app/pull/430",
|
||||
title: "feat: WebSocket server for real-time push notifications",
|
||||
owner: "acme",
|
||||
repo: "my-app",
|
||||
branch: "feat/INT-1318-websocket",
|
||||
baseBranch: "main",
|
||||
isDraft: false,
|
||||
state: "open",
|
||||
additions: 723,
|
||||
deletions: 45,
|
||||
ciStatus: "passing",
|
||||
ciChecks: [
|
||||
{ name: "build", status: "passed" },
|
||||
{ name: "test", status: "passed" },
|
||||
{ name: "lint", status: "passed" },
|
||||
{ name: "integration-tests", status: "passed" },
|
||||
],
|
||||
reviewDecision: "pending",
|
||||
mergeability: {
|
||||
mergeable: false,
|
||||
ciPassing: true,
|
||||
approved: false,
|
||||
noConflicts: true,
|
||||
blockers: ["Needs review"],
|
||||
},
|
||||
unresolvedThreads: 0,
|
||||
unresolvedComments: [],
|
||||
},
|
||||
metadata: {},
|
||||
},
|
||||
|
||||
// WARNING: CI pending
|
||||
{
|
||||
id: "frontend-5",
|
||||
projectId: "my-app",
|
||||
status: "pr_open",
|
||||
activity: "idle",
|
||||
branch: "feat/INT-1322-dark-mode",
|
||||
issueId: "INT-1322",
|
||||
summary: "Implementing dark mode theme toggle",
|
||||
createdAt: ago(45),
|
||||
lastActivityAt: ago(10),
|
||||
pr: {
|
||||
number: 438,
|
||||
url: "https://github.com/acme/my-app/pull/438",
|
||||
title: "feat: dark mode with system preference detection",
|
||||
owner: "acme",
|
||||
repo: "my-app",
|
||||
branch: "feat/INT-1322-dark-mode",
|
||||
baseBranch: "main",
|
||||
isDraft: false,
|
||||
state: "open",
|
||||
additions: 312,
|
||||
deletions: 89,
|
||||
ciStatus: "pending",
|
||||
ciChecks: [
|
||||
{ name: "build", status: "running" },
|
||||
{ name: "test", status: "pending" },
|
||||
{ name: "lint", status: "passed" },
|
||||
],
|
||||
reviewDecision: "none",
|
||||
mergeability: {
|
||||
mergeable: false,
|
||||
ciPassing: false,
|
||||
approved: false,
|
||||
noConflicts: true,
|
||||
blockers: ["CI checks pending", "Needs review"],
|
||||
},
|
||||
unresolvedThreads: 0,
|
||||
unresolvedComments: [],
|
||||
},
|
||||
metadata: {},
|
||||
},
|
||||
|
||||
// OK: actively working
|
||||
{
|
||||
id: "backend-9",
|
||||
projectId: "my-app",
|
||||
status: "working",
|
||||
activity: "active",
|
||||
branch: "feat/INT-1325-caching",
|
||||
issueId: "INT-1325",
|
||||
summary: "Implementing Redis caching layer for API responses",
|
||||
createdAt: ago(30),
|
||||
lastActivityAt: ago(0),
|
||||
pr: null,
|
||||
metadata: {},
|
||||
},
|
||||
|
||||
// OK: actively working with PR
|
||||
{
|
||||
id: "frontend-6",
|
||||
projectId: "my-app",
|
||||
status: "working",
|
||||
activity: "active",
|
||||
branch: "feat/INT-1328-notifications",
|
||||
issueId: "INT-1328",
|
||||
summary: "Building notification center component",
|
||||
createdAt: ago(55),
|
||||
lastActivityAt: ago(0),
|
||||
pr: {
|
||||
number: 440,
|
||||
url: "https://github.com/acme/my-app/pull/440",
|
||||
title: "feat: notification center with mark-as-read",
|
||||
owner: "acme",
|
||||
repo: "my-app",
|
||||
branch: "feat/INT-1328-notifications",
|
||||
baseBranch: "main",
|
||||
isDraft: true,
|
||||
state: "open",
|
||||
additions: 234,
|
||||
deletions: 0,
|
||||
ciStatus: "pending",
|
||||
ciChecks: [
|
||||
{ name: "build", status: "running" },
|
||||
{ name: "test", status: "pending" },
|
||||
],
|
||||
reviewDecision: "none",
|
||||
mergeability: {
|
||||
mergeable: false,
|
||||
ciPassing: false,
|
||||
approved: false,
|
||||
noConflicts: true,
|
||||
blockers: ["Draft PR", "CI pending"],
|
||||
},
|
||||
unresolvedThreads: 0,
|
||||
unresolvedComments: [],
|
||||
},
|
||||
metadata: {},
|
||||
},
|
||||
|
||||
// OK: actively working — has merge conflict on PR
|
||||
{
|
||||
id: "backend-11",
|
||||
projectId: "my-app",
|
||||
status: "working",
|
||||
activity: "active",
|
||||
branch: "feat/INT-1335-email-templates",
|
||||
issueId: "INT-1335",
|
||||
summary: "Implementing email template engine with MJML",
|
||||
createdAt: ago(100),
|
||||
lastActivityAt: ago(1),
|
||||
pr: {
|
||||
number: 442,
|
||||
url: "https://github.com/acme/my-app/pull/442",
|
||||
title: "feat: email template engine with MJML support",
|
||||
owner: "acme",
|
||||
repo: "my-app",
|
||||
branch: "feat/INT-1335-email-templates",
|
||||
baseBranch: "main",
|
||||
isDraft: false,
|
||||
state: "open",
|
||||
additions: 489,
|
||||
deletions: 32,
|
||||
ciStatus: "passing",
|
||||
ciChecks: [
|
||||
{ name: "build", status: "passed", url: "https://github.com/acme/my-app/actions/runs/20" },
|
||||
{ name: "test", status: "passed", url: "https://github.com/acme/my-app/actions/runs/21" },
|
||||
{ name: "lint", status: "passed", url: "https://github.com/acme/my-app/actions/runs/22" },
|
||||
],
|
||||
reviewDecision: "pending",
|
||||
mergeability: {
|
||||
mergeable: false,
|
||||
ciPassing: true,
|
||||
approved: false,
|
||||
noConflicts: false,
|
||||
blockers: ["Merge conflict", "Needs review"],
|
||||
},
|
||||
unresolvedThreads: 0,
|
||||
unresolvedComments: [],
|
||||
},
|
||||
metadata: {},
|
||||
},
|
||||
|
||||
// OK: spawning
|
||||
{
|
||||
id: "backend-10",
|
||||
projectId: "my-app",
|
||||
status: "spawning",
|
||||
activity: "active",
|
||||
branch: null,
|
||||
issueId: "INT-1330",
|
||||
summary: "Setting up workspace for database migration task",
|
||||
createdAt: ago(1),
|
||||
lastActivityAt: ago(0),
|
||||
pr: null,
|
||||
metadata: {},
|
||||
},
|
||||
|
||||
// DONE: merged
|
||||
{
|
||||
id: "backend-1",
|
||||
projectId: "my-app",
|
||||
status: "merged",
|
||||
activity: "exited",
|
||||
branch: "feat/INT-1260-logging",
|
||||
issueId: "INT-1260",
|
||||
summary: "Structured logging with correlation IDs",
|
||||
createdAt: ago(480),
|
||||
lastActivityAt: ago(120),
|
||||
pr: {
|
||||
number: 410,
|
||||
url: "https://github.com/acme/my-app/pull/410",
|
||||
title: "feat: structured logging with request correlation",
|
||||
owner: "acme",
|
||||
repo: "my-app",
|
||||
branch: "feat/INT-1260-logging",
|
||||
baseBranch: "main",
|
||||
isDraft: false,
|
||||
state: "merged",
|
||||
additions: 456,
|
||||
deletions: 234,
|
||||
ciStatus: "passing",
|
||||
ciChecks: [
|
||||
{ name: "build", status: "passed" },
|
||||
{ name: "test", status: "passed" },
|
||||
],
|
||||
reviewDecision: "approved",
|
||||
mergeability: {
|
||||
mergeable: false,
|
||||
ciPassing: true,
|
||||
approved: true,
|
||||
noConflicts: true,
|
||||
blockers: [],
|
||||
},
|
||||
unresolvedThreads: 0,
|
||||
unresolvedComments: [],
|
||||
},
|
||||
metadata: {},
|
||||
},
|
||||
|
||||
// DONE: killed
|
||||
{
|
||||
id: "frontend-1",
|
||||
projectId: "my-app",
|
||||
status: "killed",
|
||||
activity: "exited",
|
||||
branch: "feat/INT-1270-table",
|
||||
issueId: "INT-1270",
|
||||
summary: "Data table component (superseded by INT-1301)",
|
||||
createdAt: ago(600),
|
||||
lastActivityAt: ago(300),
|
||||
pr: null,
|
||||
metadata: {},
|
||||
},
|
||||
];
|
||||
|
||||
export function getMockStats(): DashboardStats {
|
||||
const sessions = mockSessions;
|
||||
return {
|
||||
totalSessions: sessions.length,
|
||||
workingSessions: sessions.filter((s) => s.activity === "active").length,
|
||||
openPRs: sessions.filter((s) => s.pr?.state === "open").length,
|
||||
needsReview: sessions.filter(
|
||||
(s) =>
|
||||
s.pr &&
|
||||
!s.pr.isDraft &&
|
||||
(s.pr.reviewDecision === "pending" || s.pr.reviewDecision === "none"),
|
||||
).length,
|
||||
};
|
||||
}
|
||||
|
||||
export function getMockSession(id: string): DashboardSession | null {
|
||||
return mockSessions.find((s) => s.id === id) ?? null;
|
||||
}
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
/**
|
||||
* Core Session → DashboardSession serialization.
|
||||
*
|
||||
* Converts core types (Date objects, PRInfo) into dashboard types
|
||||
* (string dates, flattened DashboardPR) suitable for JSON serialization.
|
||||
*/
|
||||
|
||||
import type { Session, SCM, PRInfo } from "@agent-orchestrator/core";
|
||||
import type { DashboardSession, DashboardPR, DashboardStats } from "./types.js";
|
||||
|
||||
/** Convert a core Session to a DashboardSession (without PR enrichment). */
|
||||
export function sessionToDashboard(session: Session): DashboardSession {
|
||||
return {
|
||||
id: session.id,
|
||||
projectId: session.projectId,
|
||||
status: session.status,
|
||||
activity: session.activity,
|
||||
branch: session.branch,
|
||||
issueId: session.issueId,
|
||||
summary: session.agentInfo?.summary ?? session.metadata["summary"] ?? null,
|
||||
createdAt: session.createdAt.toISOString(),
|
||||
lastActivityAt: session.lastActivityAt.toISOString(),
|
||||
pr: session.pr ? basicPRToDashboard(session.pr) : null,
|
||||
metadata: session.metadata,
|
||||
};
|
||||
}
|
||||
|
||||
/** Convert minimal PRInfo to a DashboardPR with default values for enriched fields. */
|
||||
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",
|
||||
ciChecks: [],
|
||||
reviewDecision: "none",
|
||||
mergeability: {
|
||||
mergeable: false,
|
||||
ciPassing: false,
|
||||
approved: false,
|
||||
noConflicts: true,
|
||||
blockers: [],
|
||||
},
|
||||
unresolvedThreads: 0,
|
||||
unresolvedComments: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** Enrich a DashboardSession's PR with live data from the SCM plugin. */
|
||||
export async function enrichSessionPR(
|
||||
dashboard: DashboardSession,
|
||||
scm: SCM,
|
||||
pr: PRInfo,
|
||||
): Promise<void> {
|
||||
if (!dashboard.pr) return;
|
||||
|
||||
const results = await Promise.allSettled([
|
||||
scm.getPRState(pr),
|
||||
scm.getCIChecks(pr),
|
||||
scm.getCISummary(pr),
|
||||
scm.getReviewDecision(pr),
|
||||
scm.getMergeability(pr),
|
||||
scm.getPendingComments(pr),
|
||||
]);
|
||||
|
||||
const [stateR, checksR, ciR, reviewR, mergeR, commentsR] = results;
|
||||
|
||||
if (stateR.status === "fulfilled") {
|
||||
dashboard.pr.state = stateR.value;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/** Compute dashboard stats from a list of sessions. */
|
||||
export function computeStats(sessions: DashboardSession[]): DashboardStats {
|
||||
return {
|
||||
totalSessions: sessions.length,
|
||||
workingSessions: sessions.filter((s) => s.activity === "active").length,
|
||||
openPRs: sessions.filter((s) => s.pr?.state === "open").length,
|
||||
needsReview: sessions.filter(
|
||||
(s) =>
|
||||
s.pr &&
|
||||
!s.pr.isDraft &&
|
||||
(s.pr.reviewDecision === "pending" || s.pr.reviewDecision === "none"),
|
||||
).length,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import {
|
||||
loadConfig,
|
||||
createPluginRegistry,
|
||||
createSessionManager,
|
||||
type OrchestratorConfig,
|
||||
type PluginRegistry,
|
||||
type SessionManager,
|
||||
type SCM,
|
||||
type ProjectConfig,
|
||||
} from "@agent-orchestrator/core";
|
||||
|
||||
export interface Services {
|
||||
config: OrchestratorConfig;
|
||||
registry: PluginRegistry;
|
||||
sessionManager: SessionManager;
|
||||
}
|
||||
|
||||
// Cache in globalThis for Next.js HMR stability
|
||||
const globalForServices = globalThis as typeof globalThis & {
|
||||
_aoServices?: Services;
|
||||
_aoServicesInit?: Promise<Services>;
|
||||
};
|
||||
|
||||
/** Get (or lazily initialize) the core services singleton. */
|
||||
export function getServices(): Promise<Services> {
|
||||
if (globalForServices._aoServices) {
|
||||
return Promise.resolve(globalForServices._aoServices);
|
||||
}
|
||||
if (!globalForServices._aoServicesInit) {
|
||||
globalForServices._aoServicesInit = initServices();
|
||||
}
|
||||
return globalForServices._aoServicesInit;
|
||||
}
|
||||
|
||||
async function initServices(): Promise<Services> {
|
||||
const config = loadConfig();
|
||||
const registry = createPluginRegistry();
|
||||
await registry.loadFromConfig(config);
|
||||
const sessionManager = createSessionManager({ config, registry });
|
||||
|
||||
const services = { config, registry, sessionManager };
|
||||
globalForServices._aoServices = services;
|
||||
return services;
|
||||
}
|
||||
|
||||
/** 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) return null;
|
||||
return registry.get<SCM>("scm", project.scm.plugin);
|
||||
}
|
||||
|
|
@ -427,6 +427,18 @@ importers:
|
|||
'@agent-orchestrator/core':
|
||||
specifier: workspace:*
|
||||
version: link:../core
|
||||
'@agent-orchestrator/plugin-agent-claude-code':
|
||||
specifier: workspace:*
|
||||
version: link:../plugins/agent-claude-code
|
||||
'@agent-orchestrator/plugin-runtime-tmux':
|
||||
specifier: workspace:*
|
||||
version: link:../plugins/runtime-tmux
|
||||
'@agent-orchestrator/plugin-scm-github':
|
||||
specifier: workspace:*
|
||||
version: link:../plugins/scm-github
|
||||
'@agent-orchestrator/plugin-workspace-worktree':
|
||||
specifier: workspace:*
|
||||
version: link:../plugins/workspace-worktree
|
||||
next:
|
||||
specifier: ^15.1.0
|
||||
version: 15.5.12(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
|
|
|
|||
Loading…
Reference in New Issue