Merge pull request #433 from ComposioHQ/fix/project-scoped-orchestrators
fix: scope orchestrators per project across cli and web
This commit is contained in:
commit
dbdbaa54ea
|
|
@ -158,6 +158,25 @@ function buildSessionsFromDir(
|
|||
});
|
||||
}
|
||||
|
||||
function makeSession(overrides: Partial<Session> & { id: string; projectId: string }): Session {
|
||||
return {
|
||||
id: overrides.id,
|
||||
projectId: overrides.projectId,
|
||||
status: "working",
|
||||
activity: null,
|
||||
branch: null,
|
||||
issueId: null,
|
||||
pr: null,
|
||||
workspacePath: null,
|
||||
runtimeHandle: { id: overrides.id, runtimeName: "tmux", data: {} },
|
||||
agentInfo: null,
|
||||
createdAt: new Date(),
|
||||
lastActivityAt: new Date(),
|
||||
metadata: {},
|
||||
...overrides,
|
||||
} satisfies Session;
|
||||
}
|
||||
|
||||
vi.mock("../../src/lib/create-session-manager.js", () => ({
|
||||
getSessionManager: async (): Promise<SessionManager> => mockSessionManager as SessionManager,
|
||||
}));
|
||||
|
|
@ -730,4 +749,85 @@ describe("status command", () => {
|
|||
expect(parsed[0].prNumber).toBeNull();
|
||||
expect(mockDetectPR).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows one orchestrator per project without counting them as worker sessions", async () => {
|
||||
mockConfigRef.current = {
|
||||
...(mockConfigRef.current as Record<string, unknown>),
|
||||
projects: {
|
||||
"my-app": {
|
||||
name: "My App",
|
||||
repo: "org/my-app",
|
||||
path: join(tmpDir, "main-repo"),
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "app",
|
||||
scm: { plugin: "github" },
|
||||
},
|
||||
docs: {
|
||||
name: "Docs",
|
||||
repo: "org/docs",
|
||||
path: join(tmpDir, "docs-repo"),
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "docs",
|
||||
scm: { plugin: "github" },
|
||||
},
|
||||
},
|
||||
} as Record<string, unknown>;
|
||||
|
||||
mockSessionManager.list.mockResolvedValue([
|
||||
makeSession({
|
||||
id: "app-orchestrator",
|
||||
projectId: "my-app",
|
||||
metadata: { role: "orchestrator", summary: "Manage app agents" },
|
||||
}),
|
||||
makeSession({ id: "app-1", projectId: "my-app", branch: "feat/app", activity: "active" }),
|
||||
makeSession({
|
||||
id: "docs-orchestrator",
|
||||
projectId: "docs",
|
||||
metadata: { role: "orchestrator" },
|
||||
}),
|
||||
]);
|
||||
mockGit.mockResolvedValue(null);
|
||||
mockIntrospect.mockResolvedValue(null);
|
||||
|
||||
await program.parseAsync(["node", "test", "status"]);
|
||||
|
||||
const output = consoleSpy.mock.calls.map((c) => c[0]).join("\n");
|
||||
expect(output).toContain("Orchestrator:");
|
||||
expect(output).toContain("app-orchestrator");
|
||||
expect(output).toContain("docs-orchestrator");
|
||||
expect(output).toContain("1 active session across 2 projects · 2 orchestrators");
|
||||
});
|
||||
|
||||
it("includes orchestrators in JSON output with explicit roles", async () => {
|
||||
mockSessionManager.list.mockResolvedValue([
|
||||
makeSession({
|
||||
id: "app-orchestrator",
|
||||
projectId: "my-app",
|
||||
metadata: { role: "orchestrator" },
|
||||
}),
|
||||
makeSession({
|
||||
id: "app-1",
|
||||
projectId: "my-app",
|
||||
branch: "feat/json-worker",
|
||||
activity: "ready",
|
||||
}),
|
||||
]);
|
||||
mockGit.mockResolvedValue(null);
|
||||
|
||||
await program.parseAsync(["node", "test", "status", "--json"]);
|
||||
|
||||
const jsonCalls = consoleSpy.mock.calls.map((c) => c[0]).join("");
|
||||
const parsed = JSON.parse(jsonCalls);
|
||||
expect(parsed).toHaveLength(2);
|
||||
expect(
|
||||
parsed.find((entry: { name: string }) => entry.name === "app-orchestrator"),
|
||||
).toMatchObject({
|
||||
role: "orchestrator",
|
||||
project: "my-app",
|
||||
});
|
||||
expect(parsed.find((entry: { name: string }) => entry.name === "app-1")).toMatchObject({
|
||||
role: "worker",
|
||||
project: "my-app",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
type ActivityState,
|
||||
type Tracker,
|
||||
type ProjectConfig,
|
||||
isOrchestratorSession,
|
||||
loadConfig,
|
||||
} from "@composio/ao-core";
|
||||
import { git, getTmuxSessions, getTmuxActivity } from "../lib/shell.js";
|
||||
|
|
@ -27,6 +28,7 @@ import { getSessionManager } from "../lib/create-session-manager.js";
|
|||
|
||||
interface SessionInfo {
|
||||
name: string;
|
||||
role: "worker" | "orchestrator";
|
||||
branch: string | null;
|
||||
status: string | null;
|
||||
summary: string | null;
|
||||
|
|
@ -42,10 +44,6 @@ interface SessionInfo {
|
|||
activity: ActivityState | null;
|
||||
}
|
||||
|
||||
function isOrchestratorSession(session: Session): boolean {
|
||||
return session.metadata["role"] === "orchestrator" || session.id.endsWith("-orchestrator");
|
||||
}
|
||||
|
||||
async function gatherSessionInfo(
|
||||
session: Session,
|
||||
agent: Agent,
|
||||
|
|
@ -122,6 +120,7 @@ async function gatherSessionInfo(
|
|||
|
||||
return {
|
||||
name: session.id,
|
||||
role: isOrchestratorSession(session) ? "orchestrator" : "worker",
|
||||
branch,
|
||||
status,
|
||||
summary,
|
||||
|
|
@ -193,6 +192,18 @@ function printSessionRow(info: SessionInfo): void {
|
|||
}
|
||||
}
|
||||
|
||||
function printOrchestratorRow(info: SessionInfo): void {
|
||||
const lastActivity =
|
||||
info.lastActivity === "-" ? chalk.dim("unknown") : chalk.dim(info.lastActivity);
|
||||
console.log(
|
||||
` ${chalk.magenta("Orchestrator:")} ${chalk.green(info.name)} ${chalk.dim("(")}${lastActivity}${chalk.dim(")")}`,
|
||||
);
|
||||
const displaySummary = info.claudeSummary || info.summary;
|
||||
if (displaySummary) {
|
||||
console.log(` ${chalk.dim(displaySummary.slice(0, 60))}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function registerStatus(program: Command): void {
|
||||
program
|
||||
.command("status")
|
||||
|
|
@ -234,8 +245,9 @@ export function registerStatus(program: Command): void {
|
|||
|
||||
// Show projects that have no sessions too (if not filtered)
|
||||
const projectIds = opts.project ? [opts.project] : Object.keys(config.projects);
|
||||
let totalSessions = 0;
|
||||
const jsonOutput: SessionInfo[] = [];
|
||||
let totalWorkers = 0;
|
||||
let totalOrchestrators = 0;
|
||||
|
||||
for (const projectId of projectIds) {
|
||||
const projectConfig = config.projects[projectId];
|
||||
|
|
@ -262,27 +274,43 @@ export function registerStatus(program: Command): void {
|
|||
continue;
|
||||
}
|
||||
|
||||
totalSessions += projectSessions.length;
|
||||
|
||||
if (!opts.json) {
|
||||
printTableHeader();
|
||||
}
|
||||
|
||||
// Gather all session info in parallel
|
||||
const infoPromises = projectSessions.map((s) => gatherSessionInfo(s, agent, scm, config));
|
||||
const sessionInfos = await Promise.all(infoPromises);
|
||||
|
||||
const orchestrators = sessionInfos.filter((info) => info.role === "orchestrator");
|
||||
const workers = sessionInfos.filter((info) => info.role === "worker");
|
||||
|
||||
totalWorkers += workers.length;
|
||||
totalOrchestrators += orchestrators.length;
|
||||
|
||||
for (const info of sessionInfos) {
|
||||
if (opts.json) {
|
||||
jsonOutput.push(info);
|
||||
} else {
|
||||
printSessionRow(info);
|
||||
}
|
||||
}
|
||||
|
||||
if (!opts.json) {
|
||||
console.log();
|
||||
if (opts.json) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (orchestrators.length > 0) {
|
||||
for (const info of orchestrators) {
|
||||
printOrchestratorRow(info);
|
||||
}
|
||||
}
|
||||
|
||||
if (workers.length === 0) {
|
||||
console.log(chalk.dim(" (no active sessions)"));
|
||||
console.log();
|
||||
continue;
|
||||
}
|
||||
|
||||
printTableHeader();
|
||||
for (const info of workers) {
|
||||
printSessionRow(info);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
if (opts.json) {
|
||||
|
|
@ -290,7 +318,10 @@ export function registerStatus(program: Command): void {
|
|||
} else {
|
||||
console.log(
|
||||
chalk.dim(
|
||||
` ${totalSessions} active session${totalSessions !== 1 ? "s" : ""} across ${projectIds.length} project${projectIds.length !== 1 ? "s" : ""}`,
|
||||
` ${totalWorkers} active session${totalWorkers !== 1 ? "s" : ""} across ${projectIds.length} project${projectIds.length !== 1 ? "s" : ""}` +
|
||||
(totalOrchestrators > 0
|
||||
? ` · ${totalOrchestrators} orchestrator${totalOrchestrators !== 1 ? "s" : ""}`
|
||||
: ""),
|
||||
),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -126,6 +126,29 @@ describe("recoverSession", () => {
|
|||
expect(metadata?.["recoveredAt"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves project ownership when legacy metadata omits the project field", async () => {
|
||||
rootDir = join(tmpdir(), `ao-recovery-${randomUUID()}`);
|
||||
mkdirSync(rootDir, { recursive: true });
|
||||
mkdirSync(join(rootDir, "project"), { recursive: true });
|
||||
writeFileSync(join(rootDir, "agent-orchestrator.yaml"), "projects: {}\n", "utf-8");
|
||||
|
||||
const config = makeConfig(rootDir);
|
||||
const registry = makeRegistry();
|
||||
const assessment = makeAssessment({
|
||||
rawMetadata: {
|
||||
branch: "feature/recover",
|
||||
worktree: join(rootDir, "project"),
|
||||
status: "needs_input",
|
||||
},
|
||||
});
|
||||
const context = makeContext(rootDir);
|
||||
|
||||
const result = await recoverSession(assessment, config, registry, context);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.session?.projectId).toBe("app");
|
||||
});
|
||||
|
||||
it("returns the max-attempt reason when recovery escalates", async () => {
|
||||
rootDir = join(tmpdir(), `ao-recovery-${randomUUID()}`);
|
||||
mkdirSync(rootDir, { recursive: true });
|
||||
|
|
|
|||
|
|
@ -1184,6 +1184,20 @@ describe("list", () => {
|
|||
expect(sessions[0].id).toBe("app-1");
|
||||
});
|
||||
|
||||
it("preserves owning project ID for legacy metadata missing the project field", async () => {
|
||||
writeMetadata(sessionsDir, "app-1", {
|
||||
worktree: "/tmp",
|
||||
branch: "a",
|
||||
status: "working",
|
||||
});
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
const sessions = await sm.list("my-app");
|
||||
|
||||
expect(sessions).toHaveLength(1);
|
||||
expect(sessions[0].projectId).toBe("my-app");
|
||||
});
|
||||
|
||||
it("clears enrichment timeout when enrichment completes quickly", async () => {
|
||||
vi.useFakeTimers();
|
||||
const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout");
|
||||
|
|
@ -1450,6 +1464,20 @@ describe("get", () => {
|
|||
expect(await sm.get("nonexistent")).toBeNull();
|
||||
});
|
||||
|
||||
it("assigns owning project ID when loading legacy metadata without project", async () => {
|
||||
writeMetadata(sessionsDir, "app-1", {
|
||||
worktree: "/tmp",
|
||||
branch: "main",
|
||||
status: "working",
|
||||
});
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry });
|
||||
const session = await sm.get("app-1");
|
||||
|
||||
expect(session).not.toBeNull();
|
||||
expect(session?.projectId).toBe("my-app");
|
||||
});
|
||||
|
||||
it("auto-discovers and persists OpenCode session mapping when missing", async () => {
|
||||
const deleteLogPath = join(tmpDir, "opencode-get-remap.log");
|
||||
const mockBin = installMockOpencode(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { isOrchestratorSession } from "../types.js";
|
||||
|
||||
describe("isOrchestratorSession", () => {
|
||||
it("detects orchestrators by explicit role metadata", () => {
|
||||
expect(
|
||||
isOrchestratorSession({
|
||||
id: "app-control",
|
||||
metadata: { role: "orchestrator" },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to orchestrator naming for legacy sessions", () => {
|
||||
expect(isOrchestratorSession({ id: "app-orchestrator", metadata: {} })).toBe(true);
|
||||
});
|
||||
|
||||
it("does not classify worker sessions as orchestrators", () => {
|
||||
expect(isOrchestratorSession({ id: "app-7", metadata: { role: "worker" } })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -32,6 +32,7 @@ import {
|
|||
type Session,
|
||||
type EventPriority,
|
||||
type ProjectConfig as _ProjectConfig,
|
||||
isOrchestratorSession,
|
||||
} from "./types.js";
|
||||
import { updateMetadata } from "./metadata.js";
|
||||
import { getSessionsDir } from "./paths.js";
|
||||
|
|
@ -225,10 +226,6 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
let polling = false; // re-entrancy guard
|
||||
let allCompleteEmitted = false; // guard against repeated all_complete
|
||||
|
||||
function isOrchestratorSession(session: Session): boolean {
|
||||
return session.metadata["role"] === "orchestrator" || session.id.endsWith("-orchestrator");
|
||||
}
|
||||
|
||||
function setProjectPause(project: _ProjectConfig, sourceSessionId: string, until: Date): void {
|
||||
const sessionsDir = getSessionsDir(config.configPath, project.path);
|
||||
const orchestratorId = `${project.sessionPrefix}-orchestrator`;
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ export async function recoverSession(
|
|||
};
|
||||
|
||||
const session = sessionFromMetadata(sessionId, updatedMetadata, {
|
||||
projectId: assessment.projectId,
|
||||
status: preservedStatus,
|
||||
runtimeHandle: assessment.runtimeHandle,
|
||||
lastActivityAt: new Date(),
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ import {
|
|||
type PluginRegistry,
|
||||
type RuntimeHandle,
|
||||
type Issue,
|
||||
isOrchestratorSession,
|
||||
PR_STATE,
|
||||
} from "./types.js";
|
||||
import {
|
||||
|
|
@ -228,10 +229,12 @@ function sleep(ms: number): Promise<void> {
|
|||
function metadataToSession(
|
||||
sessionId: SessionId,
|
||||
meta: Record<string, string>,
|
||||
projectId: string,
|
||||
createdAt?: Date,
|
||||
modifiedAt?: Date,
|
||||
): Session {
|
||||
return sessionFromMetadata(sessionId, meta, {
|
||||
projectId,
|
||||
createdAt,
|
||||
lastActivityAt: modifiedAt ?? new Date(),
|
||||
});
|
||||
|
|
@ -556,6 +559,10 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
maybeAdd(id, readArchivedMetadataRaw(sessionsDir, id));
|
||||
}
|
||||
|
||||
if (criteria.sessionId) {
|
||||
maybeAdd(criteria.sessionId, readArchivedMetadataRaw(sessionsDir, criteria.sessionId));
|
||||
}
|
||||
|
||||
return [...new Set(ids)];
|
||||
}
|
||||
|
||||
|
|
@ -1201,7 +1208,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
|
||||
const existingRaw = readMetadataRaw(sessionsDir, sessionId);
|
||||
const existingOrchestrator = existingRaw?.["runtimeHandle"]
|
||||
? metadataToSession(sessionId, existingRaw)
|
||||
? metadataToSession(sessionId, existingRaw, orchestratorConfig.projectId)
|
||||
: null;
|
||||
if (existingOrchestrator?.runtimeHandle) {
|
||||
const existingAlive = await plugins.runtime
|
||||
|
|
@ -1210,7 +1217,11 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
if (existingAlive && orchestratorSessionStrategy === "reuse") {
|
||||
const persistedRaw = readMetadataRaw(sessionsDir, sessionId);
|
||||
if (persistedRaw?.["runtimeHandle"]) {
|
||||
const persisted = metadataToSession(sessionId, persistedRaw);
|
||||
const persisted = metadataToSession(
|
||||
sessionId,
|
||||
persistedRaw,
|
||||
orchestratorConfig.projectId,
|
||||
);
|
||||
persisted.metadata["orchestratorSessionReused"] = "true";
|
||||
return persisted;
|
||||
}
|
||||
|
|
@ -1239,7 +1250,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
// Check if the session now exists and is alive.
|
||||
const concurrentRaw = readMetadataRaw(sessionsDir, sessionId);
|
||||
const concurrentSession = concurrentRaw?.["runtimeHandle"]
|
||||
? metadataToSession(sessionId, concurrentRaw)
|
||||
? metadataToSession(sessionId, concurrentRaw, orchestratorConfig.projectId)
|
||||
: null;
|
||||
if (concurrentSession?.runtimeHandle) {
|
||||
const concurrentAlive = await plugins.runtime
|
||||
|
|
@ -1416,7 +1427,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
// If stat fails, timestamps will fall back to current time
|
||||
}
|
||||
|
||||
const session = metadataToSession(sessionName, raw, createdAt, modifiedAt);
|
||||
const session = metadataToSession(sessionName, raw, sessionProjectId, createdAt, modifiedAt);
|
||||
const selectedAgentName = raw["agent"];
|
||||
const effectiveAgentName = selectedAgentName ?? project.agent ?? config.defaults.agent;
|
||||
const plugins = resolvePlugins(project, effectiveAgentName);
|
||||
|
|
@ -1455,7 +1466,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
|
||||
async function get(sessionId: SessionId): Promise<Session | null> {
|
||||
// Try to find the session in any project's sessions directory
|
||||
for (const project of Object.values(config.projects)) {
|
||||
for (const [projectId, project] of Object.entries(config.projects)) {
|
||||
const sessionsDir = getProjectSessionsDir(project);
|
||||
const raw = readMetadataRaw(sessionsDir, sessionId);
|
||||
if (!raw) continue;
|
||||
|
|
@ -1478,7 +1489,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
modifiedAt,
|
||||
});
|
||||
|
||||
const session = metadataToSession(sessionId, repaired.raw, createdAt, modifiedAt);
|
||||
const session = metadataToSession(sessionId, repaired.raw, projectId, createdAt, modifiedAt);
|
||||
|
||||
const selectedAgentName = repaired.raw["agent"];
|
||||
const effectiveAgentName = selectedAgentName ?? project.agent ?? config.defaults.agent;
|
||||
|
|
@ -1606,7 +1617,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
// Never clean up orchestrator sessions — they manage the lifecycle.
|
||||
// Check explicit role metadata first, fall back to naming convention
|
||||
// for pre-existing sessions spawned before the role field was added.
|
||||
if (session.metadata["role"] === "orchestrator" || session.id.endsWith("-orchestrator")) {
|
||||
if (isOrchestratorSession(session)) {
|
||||
pushSkipped(session.projectId, session.id);
|
||||
continue;
|
||||
}
|
||||
|
|
@ -1678,7 +1689,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
const archived = readArchivedMetadataRaw(sessionsDir, archivedId);
|
||||
if (!archived) continue;
|
||||
|
||||
if (archived["role"] === "orchestrator" || archivedId.endsWith("-orchestrator")) {
|
||||
if (isOrchestratorSession({ id: archivedId, metadata: archived })) {
|
||||
pushSkipped(projectKey, archivedId);
|
||||
continue;
|
||||
}
|
||||
|
|
@ -2107,7 +2118,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
|
|||
// metadataToSession sets activity: null, so without enrichment a crashed
|
||||
// session (status "working", agent exited) would not be detected as terminal
|
||||
// and isRestorable would reject it.
|
||||
const session = metadataToSession(sessionId, raw);
|
||||
const session = metadataToSession(sessionId, raw, projectId);
|
||||
const plugins = resolvePlugins(project, raw["agent"]);
|
||||
await enrichSessionWithRuntimeState(session, plugins, true);
|
||||
|
||||
|
|
|
|||
|
|
@ -172,6 +172,13 @@ export interface Session {
|
|||
metadata: Record<string, string>;
|
||||
}
|
||||
|
||||
export function isOrchestratorSession(session: {
|
||||
id: SessionId;
|
||||
metadata?: Record<string, string>;
|
||||
}): boolean {
|
||||
return session.metadata?.["role"] === "orchestrator" || session.id.endsWith("-orchestrator");
|
||||
}
|
||||
|
||||
/** Config for creating a new session */
|
||||
export interface SessionSpawnConfig {
|
||||
projectId: string;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { parsePrFromUrl } from "./pr.js";
|
|||
import { safeJsonParse, validateStatus } from "./validation.js";
|
||||
|
||||
interface SessionFromMetadataOptions {
|
||||
projectId?: string;
|
||||
status?: SessionStatus;
|
||||
activity?: Session["activity"];
|
||||
runtimeHandle?: RuntimeHandle | null;
|
||||
|
|
@ -18,7 +19,7 @@ export function sessionFromMetadata(
|
|||
): Session {
|
||||
return {
|
||||
id: sessionId,
|
||||
projectId: meta["project"] ?? "",
|
||||
projectId: meta["project"] ?? options.projectId ?? "",
|
||||
status: options.status ?? validateStatus(meta["status"]),
|
||||
activity: options.activity ?? null,
|
||||
branch: meta["branch"] || null,
|
||||
|
|
|
|||
|
|
@ -61,6 +61,31 @@ const testSessions: Session[] = [
|
|||
}),
|
||||
];
|
||||
|
||||
const multiProjectSessions: Session[] = [
|
||||
makeSession({
|
||||
id: "app-orchestrator",
|
||||
projectId: "my-app",
|
||||
metadata: { role: "orchestrator" },
|
||||
}),
|
||||
makeSession({
|
||||
id: "backend-3",
|
||||
projectId: "my-app",
|
||||
status: "working",
|
||||
activity: "active",
|
||||
}),
|
||||
makeSession({
|
||||
id: "docs-orchestrator",
|
||||
projectId: "docs-app",
|
||||
metadata: { role: "orchestrator" },
|
||||
}),
|
||||
makeSession({
|
||||
id: "docs-2",
|
||||
projectId: "docs-app",
|
||||
status: "review_pending",
|
||||
activity: "idle",
|
||||
}),
|
||||
];
|
||||
|
||||
// ── Mock Services ─────────────────────────────────────────────────────
|
||||
|
||||
const mockSessionManager: SessionManager = {
|
||||
|
|
@ -164,6 +189,14 @@ const mockConfig: OrchestratorConfig = {
|
|||
sessionPrefix: "my-app",
|
||||
scm: { plugin: "github", webhook: {} },
|
||||
},
|
||||
"docs-app": {
|
||||
name: "Docs App",
|
||||
repo: "acme/docs-app",
|
||||
path: "/tmp/docs-app",
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "docs",
|
||||
scm: { plugin: "github" },
|
||||
},
|
||||
},
|
||||
notifiers: {},
|
||||
notificationRouting: { urgent: [], action: [], warning: [], info: [] },
|
||||
|
|
@ -285,6 +318,122 @@ describe("API Routes", () => {
|
|||
metadataSpy.mockRestore();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("returns per-project orchestrators and excludes them from worker sessions", async () => {
|
||||
(mockSessionManager.list as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
|
||||
multiProjectSessions,
|
||||
);
|
||||
|
||||
const res = await sessionsGET(makeRequest("http://localhost:3000/api/sessions"));
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
|
||||
expect(data.orchestratorId).toBeNull();
|
||||
expect(data.orchestrators).toEqual([
|
||||
{ id: "docs-orchestrator", projectId: "docs-app", projectName: "Docs App" },
|
||||
{ id: "app-orchestrator", projectId: "my-app", projectName: "My App" },
|
||||
]);
|
||||
expect(data.sessions.map((session: { id: string }) => session.id)).toEqual([
|
||||
"backend-3",
|
||||
"docs-2",
|
||||
]);
|
||||
expect(data.stats.totalSessions).toBe(2);
|
||||
});
|
||||
|
||||
it("supports project-scoped session queries for orchestrator detail views", async () => {
|
||||
(mockSessionManager.list as ReturnType<typeof vi.fn>).mockImplementationOnce(
|
||||
async (projectId?: string) =>
|
||||
multiProjectSessions.filter((session) => !projectId || session.projectId === projectId),
|
||||
);
|
||||
|
||||
const res = await sessionsGET(
|
||||
makeRequest("http://localhost:3000/api/sessions?project=docs-app"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
|
||||
expect(data.orchestratorId).toBe("docs-orchestrator");
|
||||
expect(data.orchestrators).toEqual([
|
||||
{ id: "docs-orchestrator", projectId: "docs-app", projectName: "Docs App" },
|
||||
]);
|
||||
expect(data.sessions.map((session: { id: string }) => session.id)).toEqual(["docs-2"]);
|
||||
expect(mockSessionManager.list).toHaveBeenCalledWith("docs-app");
|
||||
});
|
||||
|
||||
it("keeps global pause sourced from all projects even for project-scoped requests", async () => {
|
||||
const pausedUntil = new Date(Date.now() + 60_000).toISOString();
|
||||
const pausedSessions = [
|
||||
makeSession({
|
||||
id: "docs-orchestrator",
|
||||
projectId: "docs-app",
|
||||
metadata: {
|
||||
role: "orchestrator",
|
||||
globalPauseUntil: pausedUntil,
|
||||
globalPauseReason: "Rate limit hit",
|
||||
globalPauseSource: "docs-orchestrator",
|
||||
},
|
||||
}),
|
||||
makeSession({ id: "docs-1", projectId: "docs-app", status: "working", activity: "active" }),
|
||||
makeSession({
|
||||
id: "backend-3",
|
||||
projectId: "my-app",
|
||||
status: "working",
|
||||
activity: "active",
|
||||
}),
|
||||
];
|
||||
(mockSessionManager.list as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
async (projectId?: string) =>
|
||||
projectId
|
||||
? pausedSessions.filter((session) => session.projectId === projectId)
|
||||
: pausedSessions,
|
||||
);
|
||||
|
||||
const res = await sessionsGET(
|
||||
makeRequest("http://localhost:3000/api/sessions?project=my-app"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
|
||||
expect(data.globalPause).toMatchObject({
|
||||
pausedUntil,
|
||||
reason: "Rate limit hit",
|
||||
sourceSessionId: "docs-orchestrator",
|
||||
});
|
||||
expect(mockSessionManager.list).toHaveBeenNthCalledWith(1, "my-app");
|
||||
expect(mockSessionManager.list).toHaveBeenNthCalledWith(2);
|
||||
});
|
||||
|
||||
it("finds active global pause even when a metadata-role orchestrator appears first", async () => {
|
||||
const pausedUntil = new Date(Date.now() + 60_000).toISOString();
|
||||
const sessions = [
|
||||
makeSession({
|
||||
id: "control-session",
|
||||
projectId: "docs-app",
|
||||
metadata: { role: "orchestrator" },
|
||||
}),
|
||||
makeSession({
|
||||
id: "docs-orchestrator",
|
||||
projectId: "docs-app",
|
||||
metadata: {
|
||||
role: "orchestrator",
|
||||
globalPauseUntil: pausedUntil,
|
||||
globalPauseReason: "Rate limit hit",
|
||||
globalPauseSource: "docs-orchestrator",
|
||||
},
|
||||
}),
|
||||
];
|
||||
(mockSessionManager.list as ReturnType<typeof vi.fn>).mockResolvedValueOnce(sessions);
|
||||
|
||||
const res = await sessionsGET(makeRequest("http://localhost:3000/api/sessions"));
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
|
||||
expect(data.globalPause).toMatchObject({
|
||||
pausedUntil,
|
||||
reason: "Rate limit hit",
|
||||
sourceSessionId: "docs-orchestrator",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── POST /api/spawn ────────────────────────────────────────────────
|
||||
|
|
@ -651,6 +800,32 @@ describe("API Routes", () => {
|
|||
expect(sessionIds.every((id: string) => !id.endsWith("-orchestrator"))).toBe(true);
|
||||
});
|
||||
|
||||
it("excludes metadata-role orchestrators from snapshots", async () => {
|
||||
const sessionsWithMetadataRoleOrchestrator = [
|
||||
...testSessions,
|
||||
makeSession({
|
||||
id: "control-session",
|
||||
projectId: "my-app",
|
||||
status: "working",
|
||||
activity: "active",
|
||||
metadata: { role: "orchestrator" },
|
||||
}),
|
||||
];
|
||||
(mockSessionManager.list as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
|
||||
sessionsWithMetadataRoleOrchestrator,
|
||||
);
|
||||
|
||||
const res = await eventsGET(makeRequest("http://localhost:3000/api/events"));
|
||||
const reader = res.body!.getReader();
|
||||
const { value } = await reader.read();
|
||||
reader.cancel();
|
||||
const text = new TextDecoder().decode(value);
|
||||
const event = JSON.parse(text.replace("data: ", "").trim());
|
||||
|
||||
const sessionIds = event.sessions.map((s: { id: string }) => s.id);
|
||||
expect(sessionIds).not.toContain("control-session");
|
||||
});
|
||||
|
||||
it("filters sessions by project query param", async () => {
|
||||
const multiProjectSessions = [
|
||||
...testSessions,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { ACTIVITY_STATE } from "@composio/ao-core";
|
||||
import { ACTIVITY_STATE, isOrchestratorSession } from "@composio/ao-core";
|
||||
import { NextResponse } from "next/server";
|
||||
import { getServices, getSCM } from "@/lib/services";
|
||||
import {
|
||||
|
|
@ -7,9 +7,10 @@ import {
|
|||
enrichSessionPR,
|
||||
enrichSessionsMetadata,
|
||||
computeStats,
|
||||
listDashboardOrchestrators,
|
||||
} from "@/lib/serialize";
|
||||
import { resolveGlobalPause } from "@/lib/global-pause";
|
||||
import { filterWorkerSessions, findOrchestratorSessionId } from "@/lib/project-utils";
|
||||
import { filterProjectSessions } from "@/lib/project-utils";
|
||||
|
||||
const METADATA_ENRICH_TIMEOUT_MS = 3_000;
|
||||
const PR_ENRICH_TIMEOUT_MS = 4_000;
|
||||
|
|
@ -42,22 +43,26 @@ export async function GET(request: Request) {
|
|||
const activeOnly = searchParams.get("active") === "true";
|
||||
|
||||
const { config, registry, sessionManager } = await getServices();
|
||||
const coreSessions = await sessionManager.list();
|
||||
const requestedProjectId =
|
||||
projectFilter && projectFilter !== "all" && config.projects[projectFilter]
|
||||
? projectFilter
|
||||
: undefined;
|
||||
const coreSessions = await sessionManager.list(requestedProjectId);
|
||||
const allSessions = requestedProjectId ? await sessionManager.list() : coreSessions;
|
||||
const visibleSessions = filterProjectSessions(coreSessions, projectFilter, config.projects);
|
||||
const orchestrators = listDashboardOrchestrators(visibleSessions, config.projects);
|
||||
const orchestratorId = orchestrators.length === 1 ? (orchestrators[0]?.id ?? null) : null;
|
||||
|
||||
const orchestratorId = findOrchestratorSessionId(coreSessions, projectFilter, config.projects);
|
||||
let workerSessions = visibleSessions.filter((session) => !isOrchestratorSession(session));
|
||||
|
||||
let workerSessions = filterWorkerSessions(coreSessions, projectFilter, config.projects);
|
||||
|
||||
// Convert to dashboard format
|
||||
let dashboardSessions = workerSessions.map(sessionToDashboard);
|
||||
|
||||
// Filter to active sessions only if requested (keep workerSessions in sync)
|
||||
if (activeOnly) {
|
||||
const activeIndices = dashboardSessions
|
||||
.map((s, i) => (s.activity !== ACTIVITY_STATE.EXITED ? i : -1))
|
||||
.filter((i) => i !== -1);
|
||||
workerSessions = activeIndices.map((i) => workerSessions[i]);
|
||||
dashboardSessions = activeIndices.map((i) => dashboardSessions[i]);
|
||||
.map((session, index) => (session.activity !== ACTIVITY_STATE.EXITED ? index : -1))
|
||||
.filter((index) => index !== -1);
|
||||
workerSessions = activeIndices.map((index) => workerSessions[index]);
|
||||
dashboardSessions = activeIndices.map((index) => dashboardSessions[index]);
|
||||
}
|
||||
|
||||
const metadataSettled = await settlesWithin(
|
||||
|
|
@ -89,7 +94,8 @@ export async function GET(request: Request) {
|
|||
sessions: dashboardSessions,
|
||||
stats: computeStats(dashboardSessions),
|
||||
orchestratorId,
|
||||
globalPause: resolveGlobalPause(coreSessions),
|
||||
orchestrators,
|
||||
globalPause: resolveGlobalPause(allSessions),
|
||||
});
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
|
|
|
|||
|
|
@ -9,10 +9,11 @@ import {
|
|||
resolveProject,
|
||||
enrichSessionPR,
|
||||
enrichSessionsMetadata,
|
||||
listDashboardOrchestrators,
|
||||
} from "@/lib/serialize";
|
||||
import { prCache, prCacheKey } from "@/lib/cache";
|
||||
import { getPrimaryProjectId, getProjectName, getAllProjects } from "@/lib/project-name";
|
||||
import { filterWorkerSessions, findOrchestratorSessionId } from "@/lib/project-utils";
|
||||
import { filterProjectSessions, filterWorkerSessions } from "@/lib/project-utils";
|
||||
import { resolveGlobalPause, type GlobalPauseState } from "@/lib/global-pause";
|
||||
|
||||
function getSelectedProjectName(projectFilter: string | undefined): string {
|
||||
|
|
@ -36,27 +37,33 @@ export async function generateMetadata(props: {
|
|||
|
||||
export default async function Home(props: { searchParams: Promise<{ project?: string }> }) {
|
||||
const searchParams = await props.searchParams;
|
||||
let sessions: DashboardSession[] = [];
|
||||
let orchestratorId: string | null;
|
||||
let globalPause: GlobalPauseState | null;
|
||||
// Allow ?project=all to show all sessions (for multi-project setups)
|
||||
const projectFilter = searchParams.project ?? getPrimaryProjectId();
|
||||
const pageData: {
|
||||
sessions: DashboardSession[];
|
||||
globalPause: GlobalPauseState | null;
|
||||
orchestrators: Array<{ id: string; projectId: string; projectName: string }>;
|
||||
} = {
|
||||
sessions: [],
|
||||
globalPause: null,
|
||||
orchestrators: [],
|
||||
};
|
||||
|
||||
try {
|
||||
const { config, registry, sessionManager } = await getServices();
|
||||
const allSessions = await sessionManager.list();
|
||||
|
||||
orchestratorId = findOrchestratorSessionId(allSessions, projectFilter, config.projects);
|
||||
pageData.globalPause = resolveGlobalPause(allSessions);
|
||||
|
||||
globalPause = resolveGlobalPause(allSessions);
|
||||
const visibleSessions = filterProjectSessions(allSessions, projectFilter, config.projects);
|
||||
|
||||
pageData.orchestrators = listDashboardOrchestrators(visibleSessions, config.projects);
|
||||
|
||||
const coreSessions = filterWorkerSessions(allSessions, projectFilter, config.projects);
|
||||
|
||||
sessions = coreSessions.map(sessionToDashboard);
|
||||
pageData.sessions = coreSessions.map(sessionToDashboard);
|
||||
|
||||
const metaTimeout = new Promise<void>((resolve) => setTimeout(resolve, 3_000));
|
||||
await Promise.race([
|
||||
enrichSessionsMetadata(coreSessions, sessions, config, registry),
|
||||
enrichSessionsMetadata(coreSessions, pageData.sessions, config, registry),
|
||||
metaTimeout,
|
||||
]);
|
||||
|
||||
|
|
@ -68,25 +75,29 @@ export default async function Home(props: { searchParams: Promise<{ project?: st
|
|||
const cached = prCache.get(cacheKey);
|
||||
|
||||
if (cached) {
|
||||
if (sessions[i].pr) {
|
||||
sessions[i].pr.state = cached.state;
|
||||
sessions[i].pr.title = cached.title;
|
||||
sessions[i].pr.additions = cached.additions;
|
||||
sessions[i].pr.deletions = cached.deletions;
|
||||
sessions[i].pr.ciStatus = cached.ciStatus as "none" | "pending" | "passing" | "failing";
|
||||
sessions[i].pr.reviewDecision = cached.reviewDecision as
|
||||
if (pageData.sessions[i].pr) {
|
||||
pageData.sessions[i].pr.state = cached.state;
|
||||
pageData.sessions[i].pr.title = cached.title;
|
||||
pageData.sessions[i].pr.additions = cached.additions;
|
||||
pageData.sessions[i].pr.deletions = cached.deletions;
|
||||
pageData.sessions[i].pr.ciStatus = cached.ciStatus as
|
||||
| "none"
|
||||
| "pending"
|
||||
| "passing"
|
||||
| "failing";
|
||||
pageData.sessions[i].pr.reviewDecision = cached.reviewDecision as
|
||||
| "none"
|
||||
| "pending"
|
||||
| "approved"
|
||||
| "changes_requested";
|
||||
sessions[i].pr.ciChecks = cached.ciChecks.map((c) => ({
|
||||
pageData.sessions[i].pr.ciChecks = cached.ciChecks.map((c) => ({
|
||||
name: c.name,
|
||||
status: c.status as "pending" | "running" | "passed" | "failed" | "skipped",
|
||||
url: c.url,
|
||||
}));
|
||||
sessions[i].pr.mergeability = cached.mergeability;
|
||||
sessions[i].pr.unresolvedThreads = cached.unresolvedThreads;
|
||||
sessions[i].pr.unresolvedComments = cached.unresolvedComments;
|
||||
pageData.sessions[i].pr.mergeability = cached.mergeability;
|
||||
pageData.sessions[i].pr.unresolvedThreads = cached.unresolvedThreads;
|
||||
pageData.sessions[i].pr.unresolvedComments = cached.unresolvedComments;
|
||||
}
|
||||
|
||||
if (
|
||||
|
|
@ -101,14 +112,14 @@ export default async function Home(props: { searchParams: Promise<{ project?: st
|
|||
const project = resolveProject(core, config.projects);
|
||||
const scm = getSCM(registry, project);
|
||||
if (!scm) return Promise.resolve();
|
||||
return enrichSessionPR(sessions[i], scm, core.pr);
|
||||
return enrichSessionPR(pageData.sessions[i], scm, core.pr);
|
||||
});
|
||||
const enrichTimeout = new Promise<void>((resolve) => setTimeout(resolve, 4_000));
|
||||
await Promise.race([Promise.allSettled(enrichPromises), enrichTimeout]);
|
||||
} catch {
|
||||
sessions = [];
|
||||
orchestratorId = null;
|
||||
globalPause = null;
|
||||
pageData.sessions = [];
|
||||
pageData.globalPause = null;
|
||||
pageData.orchestrators = [];
|
||||
}
|
||||
|
||||
const projectName = getSelectedProjectName(projectFilter);
|
||||
|
|
@ -117,12 +128,12 @@ export default async function Home(props: { searchParams: Promise<{ project?: st
|
|||
|
||||
return (
|
||||
<Dashboard
|
||||
initialSessions={sessions}
|
||||
orchestratorId={orchestratorId}
|
||||
initialSessions={pageData.sessions}
|
||||
projectId={selectedProjectId}
|
||||
projectName={projectName}
|
||||
projects={projects}
|
||||
initialGlobalPause={globalPause}
|
||||
initialGlobalPause={pageData.globalPause}
|
||||
orchestrators={pageData.orchestrators}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { isOrchestratorSession } from "@composio/ao-core/types";
|
||||
import { SessionDetail } from "@/components/SessionDetail";
|
||||
import { type DashboardSession, getAttentionLevel, type AttentionLevel } from "@/lib/types";
|
||||
import { activityIcon } from "@/lib/activity-icons";
|
||||
|
|
@ -14,7 +15,7 @@ function truncate(s: string, max: number): string {
|
|||
function buildSessionTitle(session: DashboardSession): string {
|
||||
const id = session.id;
|
||||
const emoji = session.activity ? (activityIcon[session.activity] ?? "") : "";
|
||||
const isOrchestrator = id.endsWith("-orchestrator");
|
||||
const isOrchestrator = isOrchestratorSession(session);
|
||||
|
||||
let detail: string;
|
||||
|
||||
|
|
@ -43,12 +44,13 @@ interface ZoneCounts {
|
|||
export default function SessionPage() {
|
||||
const params = useParams();
|
||||
const id = params.id as string;
|
||||
const isOrchestrator = id.endsWith("-orchestrator");
|
||||
|
||||
const [session, setSession] = useState<DashboardSession | null>(null);
|
||||
const [zoneCounts, setZoneCounts] = useState<ZoneCounts | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const sessionProjectId = session?.projectId ?? null;
|
||||
const sessionIsOrchestrator = session ? isOrchestratorSession(session) : false;
|
||||
|
||||
// Update document title based on session data
|
||||
useEffect(() => {
|
||||
|
|
@ -81,23 +83,30 @@ export default function SessionPage() {
|
|||
}, [id]);
|
||||
|
||||
const fetchZoneCounts = useCallback(async () => {
|
||||
if (!isOrchestrator) return;
|
||||
if (!sessionIsOrchestrator || !sessionProjectId) return;
|
||||
try {
|
||||
const res = await fetch("/api/sessions");
|
||||
const res = await fetch(`/api/sessions?project=${encodeURIComponent(sessionProjectId)}`);
|
||||
if (!res.ok) return;
|
||||
const body = (await res.json()) as { sessions: DashboardSession[] };
|
||||
const sessions = body.sessions ?? [];
|
||||
const counts: ZoneCounts = { merge: 0, respond: 0, review: 0, pending: 0, working: 0, done: 0 };
|
||||
const counts: ZoneCounts = {
|
||||
merge: 0,
|
||||
respond: 0,
|
||||
review: 0,
|
||||
pending: 0,
|
||||
working: 0,
|
||||
done: 0,
|
||||
};
|
||||
for (const s of sessions) {
|
||||
if (!s.id.endsWith("-orchestrator")) {
|
||||
if (!isOrchestratorSession(s)) {
|
||||
counts[getAttentionLevel(s) as AttentionLevel]++;
|
||||
}
|
||||
}
|
||||
setZoneCounts(counts);
|
||||
} catch {
|
||||
// non-critical — status strip just won't show
|
||||
// non-critical - status strip just won't show
|
||||
}
|
||||
}, [isOrchestrator]);
|
||||
}, [sessionIsOrchestrator, sessionProjectId]);
|
||||
|
||||
// Initial fetch — session first, zone counts after (avoids blocking on slow /api/sessions)
|
||||
useEffect(() => {
|
||||
|
|
@ -127,7 +136,9 @@ export default function SessionPage() {
|
|||
if (error || !session) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-[var(--color-bg-base)]">
|
||||
<div className="text-[13px] text-[var(--color-status-error)]">{error ?? "Session not found"}</div>
|
||||
<div className="text-[13px] text-[var(--color-status-error)]">
|
||||
{error ?? "Session not found"}
|
||||
</div>
|
||||
<a href="/" className="text-[12px] text-[var(--color-accent)] hover:underline">
|
||||
← Back to dashboard
|
||||
</a>
|
||||
|
|
@ -138,7 +149,7 @@ export default function SessionPage() {
|
|||
return (
|
||||
<SessionDetail
|
||||
session={session}
|
||||
isOrchestrator={isOrchestrator}
|
||||
isOrchestrator={sessionIsOrchestrator}
|
||||
orchestratorZones={zoneCounts ?? undefined}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
type DashboardPR,
|
||||
type AttentionLevel,
|
||||
type GlobalPauseState,
|
||||
type DashboardOrchestratorLink,
|
||||
getAttentionLevel,
|
||||
isPRRateLimited,
|
||||
} from "@/lib/types";
|
||||
|
|
@ -20,22 +21,22 @@ import type { ProjectInfo } from "@/lib/project-name";
|
|||
|
||||
interface DashboardProps {
|
||||
initialSessions: DashboardSession[];
|
||||
orchestratorId?: string | null;
|
||||
projectId?: string;
|
||||
projectName?: string;
|
||||
projects?: ProjectInfo[];
|
||||
initialGlobalPause?: GlobalPauseState | null;
|
||||
orchestrators?: DashboardOrchestratorLink[];
|
||||
}
|
||||
|
||||
const KANBAN_LEVELS = ["working", "pending", "review", "respond", "merge"] as const;
|
||||
|
||||
export function Dashboard({
|
||||
initialSessions,
|
||||
orchestratorId,
|
||||
projectId,
|
||||
projectName,
|
||||
projects = [],
|
||||
initialGlobalPause = null,
|
||||
orchestrators = [],
|
||||
}: DashboardProps) {
|
||||
const { sessions, globalPause } = useSessionEvents(
|
||||
initialSessions,
|
||||
|
|
@ -45,6 +46,8 @@ export function Dashboard({
|
|||
const [rateLimitDismissed, setRateLimitDismissed] = useState(false);
|
||||
const [globalPauseDismissed, setGlobalPauseDismissed] = useState(false);
|
||||
const showSidebar = projects.length > 1;
|
||||
const allProjectsView = showSidebar && projectId === undefined;
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const zones: Record<AttentionLevel, DashboardSession[]> = {
|
||||
merge: [],
|
||||
|
|
@ -62,11 +65,43 @@ export function Dashboard({
|
|||
|
||||
const openPRs = useMemo(() => {
|
||||
return sessions
|
||||
.filter((s): s is DashboardSession & { pr: DashboardPR } => s.pr?.state === "open")
|
||||
.map((s) => s.pr)
|
||||
.filter(
|
||||
(session): session is DashboardSession & { pr: DashboardPR } =>
|
||||
session.pr?.state === "open",
|
||||
)
|
||||
.map((session) => session.pr)
|
||||
.sort((a, b) => mergeScore(a) - mergeScore(b));
|
||||
}, [sessions]);
|
||||
|
||||
const projectOverviews = useMemo(() => {
|
||||
if (!allProjectsView) return [];
|
||||
|
||||
return projects.map((project) => {
|
||||
const projectSessions = sessions.filter((session) => session.projectId === project.id);
|
||||
const counts: Record<AttentionLevel, number> = {
|
||||
merge: 0,
|
||||
respond: 0,
|
||||
review: 0,
|
||||
pending: 0,
|
||||
working: 0,
|
||||
done: 0,
|
||||
};
|
||||
|
||||
for (const session of projectSessions) {
|
||||
counts[getAttentionLevel(session)]++;
|
||||
}
|
||||
|
||||
return {
|
||||
project,
|
||||
orchestrator:
|
||||
orchestrators.find((orchestrator) => orchestrator.projectId === project.id) ?? null,
|
||||
sessionCount: projectSessions.length,
|
||||
openPRCount: projectSessions.filter((session) => session.pr?.state === "open").length,
|
||||
counts,
|
||||
};
|
||||
});
|
||||
}, [allProjectsView, orchestrators, projects, sessions]);
|
||||
|
||||
const handleSend = async (sessionId: string, message: string) => {
|
||||
const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/send`, {
|
||||
method: "POST",
|
||||
|
|
@ -105,24 +140,27 @@ export function Dashboard({
|
|||
}
|
||||
};
|
||||
|
||||
const hasKanbanSessions = KANBAN_LEVELS.some((l) => grouped[l].length > 0);
|
||||
const hasKanbanSessions = KANBAN_LEVELS.some((level) => grouped[level].length > 0);
|
||||
|
||||
const anyRateLimited = useMemo(
|
||||
() => sessions.some((s) => s.pr && isPRRateLimited(s.pr)),
|
||||
() => sessions.some((session) => session.pr && isPRRateLimited(session.pr)),
|
||||
[sessions],
|
||||
);
|
||||
|
||||
const liveStats = useMemo<DashboardStats>(
|
||||
() => ({
|
||||
totalSessions: sessions.length,
|
||||
workingSessions: sessions.filter((s) => s.activity !== null && s.activity !== "exited")
|
||||
.length,
|
||||
openPRs: sessions.filter((s) => s.pr?.state === "open").length,
|
||||
workingSessions: sessions.filter(
|
||||
(session) => session.activity !== null && session.activity !== "exited",
|
||||
).length,
|
||||
openPRs: sessions.filter((session) => session.pr?.state === "open").length,
|
||||
needsReview: sessions.filter(
|
||||
(s) => s.pr && !s.pr.isDraft && s.pr.reviewDecision === "pending",
|
||||
(session) => session.pr && !session.pr.isDraft && session.pr.reviewDecision === "pending",
|
||||
).length,
|
||||
}),
|
||||
[sessions],
|
||||
);
|
||||
|
||||
const resumeAtLabel = useMemo(() => {
|
||||
if (!globalPause) return null;
|
||||
return new Date(globalPause.pausedUntil).toLocaleString();
|
||||
|
|
@ -137,7 +175,6 @@ export function Dashboard({
|
|||
{showSidebar && <ProjectSidebar projects={projects} activeProjectId={projectId} />}
|
||||
<div className="flex-1 overflow-y-auto px-8 py-7">
|
||||
<DynamicFavicon sessions={sessions} projectName={projectName} />
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex items-center justify-between border-b border-[var(--color-border-subtle)] pb-6">
|
||||
<div className="flex items-center gap-6">
|
||||
<h1 className="text-[17px] font-semibold tracking-[-0.02em] text-[var(--color-text-primary)]">
|
||||
|
|
@ -145,27 +182,9 @@ export function Dashboard({
|
|||
</h1>
|
||||
<StatusLine stats={liveStats} />
|
||||
</div>
|
||||
{orchestratorId && (
|
||||
<a
|
||||
href={`/sessions/${encodeURIComponent(orchestratorId)}`}
|
||||
className="orchestrator-btn flex items-center gap-2 rounded-[7px] px-4 py-2 text-[12px] font-semibold hover:no-underline"
|
||||
>
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-[var(--color-accent)] opacity-80" />
|
||||
orchestrator
|
||||
<svg
|
||||
className="h-3 w-3 opacity-70"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6M15 3h6v6M10 14L21 3" />
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
{!allProjectsView && <OrchestratorControl orchestrators={orchestrators} />}
|
||||
</div>
|
||||
|
||||
{/* Global pause banner */}
|
||||
{globalPause && !globalPauseDismissed && (
|
||||
<div className="mb-6 flex items-center gap-2.5 rounded border border-[rgba(239,68,68,0.25)] bg-[rgba(239,68,68,0.05)] px-3.5 py-2.5 text-[11px] text-[var(--color-status-error)]">
|
||||
<svg
|
||||
|
|
@ -205,7 +224,6 @@ export function Dashboard({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Rate limit notice */}
|
||||
{anyRateLimited && !rateLimitDismissed && (
|
||||
<div className="mb-6 flex items-center gap-2.5 rounded border border-[rgba(245,158,11,0.25)] bg-[rgba(245,158,11,0.05)] px-3.5 py-2.5 text-[11px] text-[var(--color-status-attention)]">
|
||||
<svg
|
||||
|
|
@ -240,8 +258,9 @@ export function Dashboard({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Kanban columns for active zones */}
|
||||
{hasKanbanSessions && (
|
||||
{allProjectsView && <ProjectOverviewGrid overviews={projectOverviews} />}
|
||||
|
||||
{!allProjectsView && hasKanbanSessions && (
|
||||
<div className="mb-8 flex gap-4 overflow-x-auto pb-2">
|
||||
{KANBAN_LEVELS.map((level) =>
|
||||
grouped[level].length > 0 ? (
|
||||
|
|
@ -261,8 +280,7 @@ export function Dashboard({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Done — full-width grid below Kanban */}
|
||||
{grouped.done.length > 0 && (
|
||||
{!allProjectsView && grouped.done.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<AttentionZone
|
||||
level="done"
|
||||
|
|
@ -276,7 +294,6 @@ export function Dashboard({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* PR Table */}
|
||||
{openPRs.length > 0 && (
|
||||
<div className="mx-auto max-w-[900px]">
|
||||
<h2 className="mb-3 px-1 text-[10px] font-bold uppercase tracking-[0.10em] text-[var(--color-text-tertiary)]">
|
||||
|
|
@ -320,6 +337,164 @@ export function Dashboard({
|
|||
);
|
||||
}
|
||||
|
||||
function OrchestratorControl({ orchestrators }: { orchestrators: DashboardOrchestratorLink[] }) {
|
||||
if (orchestrators.length === 0) return null;
|
||||
|
||||
if (orchestrators.length === 1) {
|
||||
const orchestrator = orchestrators[0];
|
||||
return (
|
||||
<a
|
||||
href={`/sessions/${encodeURIComponent(orchestrator.id)}`}
|
||||
className="orchestrator-btn flex items-center gap-2 rounded-[7px] px-4 py-2 text-[12px] font-semibold hover:no-underline"
|
||||
>
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-[var(--color-accent)] opacity-80" />
|
||||
orchestrator
|
||||
<svg
|
||||
className="h-3 w-3 opacity-70"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6M15 3h6v6M10 14L21 3" />
|
||||
</svg>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<details className="group relative">
|
||||
<summary className="orchestrator-btn flex cursor-pointer list-none items-center gap-2 rounded-[7px] px-4 py-2 text-[12px] font-semibold hover:no-underline">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-[var(--color-accent)] opacity-80" />
|
||||
{orchestrators.length} orchestrators
|
||||
<svg
|
||||
className="h-3 w-3 opacity-70 transition-transform group-open:rotate-90"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
</summary>
|
||||
<div className="absolute right-0 top-[calc(100%+0.5rem)] z-10 min-w-[220px] overflow-hidden rounded-[10px] border border-[var(--color-border-default)] bg-[var(--color-bg-elevated)] shadow-[0_18px_40px_rgba(0,0,0,0.18)]">
|
||||
{orchestrators.map((orchestrator, index) => (
|
||||
<a
|
||||
key={orchestrator.id}
|
||||
href={`/sessions/${encodeURIComponent(orchestrator.id)}`}
|
||||
className={`flex items-center justify-between gap-3 px-4 py-3 text-[12px] hover:bg-[var(--color-bg-hover)] hover:no-underline ${
|
||||
index > 0 ? "border-t border-[var(--color-border-subtle)]" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2 text-[var(--color-text-primary)]">
|
||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--color-accent)] opacity-80" />
|
||||
<span className="truncate">{orchestrator.projectName}</span>
|
||||
</span>
|
||||
<svg
|
||||
className="h-3 w-3 shrink-0 opacity-60"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6M15 3h6v6M10 14L21 3" />
|
||||
</svg>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectOverviewGrid({
|
||||
overviews,
|
||||
}: {
|
||||
overviews: Array<{
|
||||
project: ProjectInfo;
|
||||
orchestrator: DashboardOrchestratorLink | null;
|
||||
sessionCount: number;
|
||||
openPRCount: number;
|
||||
counts: Record<AttentionLevel, number>;
|
||||
}>;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-8 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{overviews.map(({ project, orchestrator, sessionCount, openPRCount, counts }) => (
|
||||
<section
|
||||
key={project.id}
|
||||
className="rounded-[10px] border border-[var(--color-border-default)] bg-[var(--color-bg-surface)] p-4"
|
||||
>
|
||||
<div className="mb-4 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-[14px] font-semibold text-[var(--color-text-primary)]">
|
||||
{project.name}
|
||||
</h2>
|
||||
<div className="mt-1 text-[11px] text-[var(--color-text-muted)]">
|
||||
{sessionCount} active session{sessionCount !== 1 ? "s" : ""}
|
||||
{openPRCount > 0 ? ` · ${openPRCount} open PR${openPRCount !== 1 ? "s" : ""}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={`/?project=${encodeURIComponent(project.id)}`}
|
||||
className="rounded-[7px] border border-[var(--color-border-default)] px-3 py-1.5 text-[11px] font-medium text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-hover)] hover:no-underline"
|
||||
>
|
||||
Open project
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
<ProjectMetric label="Merge" value={counts.merge} tone="var(--color-status-ready)" />
|
||||
<ProjectMetric
|
||||
label="Respond"
|
||||
value={counts.respond}
|
||||
tone="var(--color-status-error)"
|
||||
/>
|
||||
<ProjectMetric label="Review" value={counts.review} tone="var(--color-accent-orange)" />
|
||||
<ProjectMetric
|
||||
label="Pending"
|
||||
value={counts.pending}
|
||||
tone="var(--color-status-attention)"
|
||||
/>
|
||||
<ProjectMetric
|
||||
label="Working"
|
||||
value={counts.working}
|
||||
tone="var(--color-status-working)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-[var(--color-border-subtle)] pt-3">
|
||||
<div className="text-[11px] text-[var(--color-text-muted)]">
|
||||
{orchestrator ? "Per-project orchestrator available" : "No running orchestrator"}
|
||||
</div>
|
||||
{orchestrator ? (
|
||||
<a
|
||||
href={`/sessions/${encodeURIComponent(orchestrator.id)}`}
|
||||
className="orchestrator-btn flex items-center gap-2 rounded-[7px] px-3 py-1.5 text-[11px] font-semibold hover:no-underline"
|
||||
>
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-[var(--color-accent)] opacity-80" />
|
||||
orchestrator
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectMetric({ label, value, tone }: { label: string; value: number; tone: string }) {
|
||||
return (
|
||||
<div className="min-w-[78px] rounded-[8px] border border-[var(--color-border-subtle)] px-2.5 py-2">
|
||||
<div className="text-[10px] uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
|
||||
{label}
|
||||
</div>
|
||||
<div className="mt-1 text-[18px] font-semibold tabular-nums" style={{ color: tone }}>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusLine({ stats }: { stats: DashboardStats }) {
|
||||
if (stats.totalSessions === 0) {
|
||||
return <span className="text-[13px] text-[var(--color-text-muted)]">no sessions</span>;
|
||||
|
|
@ -338,16 +513,18 @@ function StatusLine({ stats }: { stats: DashboardStats }) {
|
|||
|
||||
return (
|
||||
<div className="flex items-baseline gap-0.5">
|
||||
{parts.map((p, i) => (
|
||||
<span key={p.label} className="flex items-baseline">
|
||||
{i > 0 && <span className="mx-3 text-[11px] text-[var(--color-border-strong)]">·</span>}
|
||||
{parts.map((part, index) => (
|
||||
<span key={part.label} className="flex items-baseline">
|
||||
{index > 0 && (
|
||||
<span className="mx-3 text-[11px] text-[var(--color-border-strong)]">·</span>
|
||||
)}
|
||||
<span
|
||||
className="text-[20px] font-bold tabular-nums tracking-tight"
|
||||
style={{ color: p.color ?? "var(--color-text-primary)" }}
|
||||
style={{ color: part.color ?? "var(--color-text-primary)" }}
|
||||
>
|
||||
{p.value}
|
||||
{part.value}
|
||||
</span>
|
||||
<span className="ml-1.5 text-[11px] text-[var(--color-text-muted)]">{p.label}</span>
|
||||
<span className="ml-1.5 text-[11px] text-[var(--color-text-muted)]">{part.label}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import {
|
|||
GLOBAL_PAUSE_REASON_KEY,
|
||||
GLOBAL_PAUSE_SOURCE_KEY,
|
||||
GLOBAL_PAUSE_UNTIL_KEY,
|
||||
isOrchestratorSession,
|
||||
parsePauseUntil,
|
||||
} from "@composio/ao-core";
|
||||
|
||||
|
|
@ -14,14 +15,17 @@ export interface GlobalPauseState {
|
|||
export function resolveGlobalPause(
|
||||
sessions: Array<{ id: string; metadata: Record<string, string> }>,
|
||||
): GlobalPauseState | null {
|
||||
const orchestrator = sessions.find((session) => session.id.endsWith("-orchestrator"));
|
||||
const pausedUntilRaw = orchestrator?.metadata[GLOBAL_PAUSE_UNTIL_KEY];
|
||||
const parsed = parsePauseUntil(pausedUntilRaw);
|
||||
if (!parsed || parsed.getTime() <= Date.now()) return null;
|
||||
for (const session of sessions) {
|
||||
if (!isOrchestratorSession(session)) continue;
|
||||
const parsed = parsePauseUntil(session.metadata[GLOBAL_PAUSE_UNTIL_KEY]);
|
||||
if (!parsed || parsed.getTime() <= Date.now()) continue;
|
||||
|
||||
return {
|
||||
pausedUntil: parsed.toISOString(),
|
||||
reason: orchestrator?.metadata[GLOBAL_PAUSE_REASON_KEY] ?? "Model rate limit reached",
|
||||
sourceSessionId: orchestrator?.metadata[GLOBAL_PAUSE_SOURCE_KEY] ?? null,
|
||||
};
|
||||
return {
|
||||
pausedUntil: parsed.toISOString(),
|
||||
reason: session.metadata[GLOBAL_PAUSE_REASON_KEY] ?? "Model rate limit reached",
|
||||
sourceSessionId: session.metadata[GLOBAL_PAUSE_SOURCE_KEY] ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { isOrchestratorSession } from "@composio/ao-core";
|
||||
|
||||
type ProjectWithPrefix = { sessionPrefix?: string };
|
||||
type SessionLike = { id: string; projectId: string };
|
||||
type SessionLike = { id: string; projectId: string; metadata?: Record<string, string> };
|
||||
|
||||
/**
|
||||
* Check if a session belongs to a specific project.
|
||||
|
|
@ -17,27 +19,16 @@ function matchesProject(
|
|||
if (session.projectId === projectId) return true;
|
||||
const project = projects[projectId];
|
||||
if (project?.sessionPrefix && session.id.startsWith(project.sessionPrefix)) return true;
|
||||
return false;
|
||||
return projects[session.projectId]?.sessionPrefix === projectId;
|
||||
}
|
||||
|
||||
function isOrchestratorSession(session: { id: string }): boolean {
|
||||
return session.id.endsWith("-orchestrator");
|
||||
}
|
||||
|
||||
export function findOrchestratorSessionId<T extends SessionLike>(
|
||||
export function filterProjectSessions<T extends SessionLike>(
|
||||
sessions: T[],
|
||||
projectFilter: string | null | undefined,
|
||||
projects: Record<string, ProjectWithPrefix>,
|
||||
): string | null {
|
||||
if (projectFilter && projectFilter !== "all") {
|
||||
const session = sessions.find(
|
||||
(s) => isOrchestratorSession(s) && matchesProject(s, projectFilter, projects),
|
||||
);
|
||||
return session?.id ?? null;
|
||||
}
|
||||
|
||||
const session = sessions.find((s) => isOrchestratorSession(s));
|
||||
return session?.id ?? null;
|
||||
): T[] {
|
||||
if (!projectFilter || projectFilter === "all") return sessions;
|
||||
return sessions.filter((session) => matchesProject(session, projectFilter, projects));
|
||||
}
|
||||
|
||||
export function filterWorkerSessions<T extends SessionLike>(
|
||||
|
|
@ -46,6 +37,5 @@ export function filterWorkerSessions<T extends SessionLike>(
|
|||
projects: Record<string, ProjectWithPrefix>,
|
||||
): T[] {
|
||||
const workers = sessions.filter((s) => !isOrchestratorSession(s));
|
||||
if (!projectFilter || projectFilter === "all") return workers;
|
||||
return workers.filter((s) => matchesProject(s, projectFilter, projects));
|
||||
return filterProjectSessions(workers, projectFilter, projects);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,17 +5,23 @@
|
|||
* (string dates, flattened DashboardPR) suitable for JSON serialization.
|
||||
*/
|
||||
|
||||
import type {
|
||||
Session,
|
||||
Agent,
|
||||
SCM,
|
||||
PRInfo,
|
||||
Tracker,
|
||||
ProjectConfig,
|
||||
OrchestratorConfig,
|
||||
PluginRegistry,
|
||||
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 } from "./types.js";
|
||||
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) */
|
||||
|
|
@ -55,9 +61,7 @@ export function sessionToDashboard(session: Session): DashboardSession {
|
|||
issueLabel: null, // Will be enriched by enrichSessionIssue()
|
||||
issueTitle: null, // Will be enriched by enrichSessionIssueTitle()
|
||||
summary,
|
||||
summaryIsFallback: agentSummary
|
||||
? (session.agentInfo?.summaryIsFallback ?? false)
|
||||
: false,
|
||||
summaryIsFallback: agentSummary ? (session.agentInfo?.summaryIsFallback ?? false) : false,
|
||||
createdAt: session.createdAt.toISOString(),
|
||||
lastActivityAt: session.lastActivityAt.toISOString(),
|
||||
pr: session.pr ? basicPRToDashboard(session.pr) : null,
|
||||
|
|
@ -65,6 +69,20 @@ export function sessionToDashboard(session: Session): DashboardSession {
|
|||
};
|
||||
}
|
||||
|
||||
export function listDashboardOrchestrators(
|
||||
sessions: Session[],
|
||||
projects: Record<string, ProjectConfig>,
|
||||
): DashboardOrchestratorLink[] {
|
||||
return sessions
|
||||
.filter((session) => isOrchestratorSession(session))
|
||||
.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".
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import {
|
|||
type Session,
|
||||
type DecomposerConfig,
|
||||
DEFAULT_DECOMPOSER_CONFIG,
|
||||
isOrchestratorSession,
|
||||
TERMINAL_STATUSES,
|
||||
} from "@composio/ao-core";
|
||||
|
||||
|
|
@ -219,7 +220,7 @@ export async function pollBacklog(): Promise<void> {
|
|||
await relabelReopenedIssues(config, registry);
|
||||
|
||||
const workerSessions = allSessions.filter(
|
||||
(s) => !s.id.endsWith("-orchestrator") && !TERMINAL_STATUSES.has(s.status),
|
||||
(session) => !isOrchestratorSession(session) && !TERMINAL_STATUSES.has(session.status),
|
||||
);
|
||||
const activeIssueIds = new Set(
|
||||
workerSessions.filter((s) => s.issueId).map((s) => s.issueId!.toLowerCase()),
|
||||
|
|
|
|||
|
|
@ -130,6 +130,12 @@ export interface DashboardStats {
|
|||
needsReview: number;
|
||||
}
|
||||
|
||||
export interface DashboardOrchestratorLink {
|
||||
id: string;
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
}
|
||||
|
||||
/** SSE snapshot event from /api/events */
|
||||
export interface SSESnapshotEvent {
|
||||
type: "snapshot";
|
||||
|
|
|
|||
Loading…
Reference in New Issue