diff --git a/packages/core/src/__tests__/session-manager.test.ts b/packages/core/src/__tests__/session-manager.test.ts index 96d56edff..ed3746342 100644 --- a/packages/core/src/__tests__/session-manager.test.ts +++ b/packages/core/src/__tests__/session-manager.test.ts @@ -3,7 +3,7 @@ import { mkdirSync, rmSync, writeFileSync, existsSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { randomUUID } from "node:crypto"; -import { createSessionManager } from "../session-manager.js"; +import { createSessionManager, inferProjectId } from "../session-manager.js"; import { writeMetadata, readMetadata, readMetadataRaw, deleteMetadata } from "../metadata.js"; import { getSessionsDir, getProjectBaseDir } from "../paths.js"; import { @@ -1335,3 +1335,62 @@ describe("PluginRegistry.loadBuiltins importFn", () => { expect(importedPackages).toContain("@composio/ao-plugin-runtime-tmux"); }); }); + +describe("inferProjectId", () => { + it("matches session ID prefix to project sessionPrefix", () => { + const projects = { + "my-app": { ...config.projects["my-app"], sessionPrefix: "app" }, + integrator: { + ...config.projects["my-app"], + name: "Integrator", + sessionPrefix: "int", + path: "/tmp/integrator", + }, + }; + expect(inferProjectId("app-1", projects)).toBe("my-app"); + expect(inferProjectId("int-42", projects)).toBe("integrator"); + expect(inferProjectId("app-100", projects)).toBe("my-app"); + }); + + it("falls back to single project when prefix doesn't match", () => { + const projects = { + "my-app": { ...config.projects["my-app"], sessionPrefix: "app" }, + }; + expect(inferProjectId("unknown-5", projects)).toBe("my-app"); + }); + + it("returns empty string when no match and multiple projects", () => { + const projects = { + "my-app": { ...config.projects["my-app"], sessionPrefix: "app" }, + integrator: { + ...config.projects["my-app"], + name: "Integrator", + sessionPrefix: "int", + path: "/tmp/integrator", + }, + }; + expect(inferProjectId("unknown-5", projects)).toBe(""); + }); + + it("returns empty string for empty projects config", () => { + expect(inferProjectId("app-1", {})).toBe(""); + }); + + it("uses list to infer projectId for legacy sessions without project field", async () => { + // Write a session without project field (legacy metadata) + writeMetadata(sessionsDir, "app-5", { + worktree: "/tmp", + branch: "feat/legacy", + status: "working", + // no project field + }); + + const sm = createSessionManager({ config, registry: mockRegistry }); + const sessions = await sm.list(); + + const legacySession = sessions.find((s) => s.id === "app-5"); + expect(legacySession).toBeDefined(); + // Should infer "my-app" because "app" prefix matches + expect(legacySession!.projectId).toBe("my-app"); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ed9b47b72..e448a29d9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -44,7 +44,7 @@ export { } from "./tmux.js"; // Session manager — session CRUD -export { createSessionManager } from "./session-manager.js"; +export { createSessionManager, inferProjectId } from "./session-manager.js"; export type { SessionManagerDeps } from "./session-manager.js"; // Lifecycle manager — state machine + reaction engine diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index c58a286cd..bdd942891 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -84,6 +84,34 @@ function safeJsonParse(str: string): T | null { } } +/** + * Infer projectId from session ID prefix by matching against configured sessionPrefix values. + * + * Strategy: + * 1. Match session ID prefix against each project's sessionPrefix (e.g. "ao-18" matches prefix "ao") + * 2. Fallback: if only one project is configured, use that + * 3. Returns empty string if no match found + */ +export function inferProjectId( + sessionId: string, + projects: Record, +): string { + // Strategy 1: Match session ID prefix against configured sessionPrefix values + for (const [projectKey, project] of Object.entries(projects)) { + if (project.sessionPrefix && sessionId.startsWith(`${project.sessionPrefix}-`)) { + return projectKey; + } + } + + // Strategy 2: If only one project is configured, use that + const projectKeys = Object.keys(projects); + if (projectKeys.length === 1) { + return projectKeys[0]; + } + + return ""; +} + /** Valid session statuses for validation. */ const VALID_STATUSES: ReadonlySet = new Set([ "spawning", @@ -116,12 +144,15 @@ function validateStatus(raw: string | undefined): SessionStatus { function metadataToSession( sessionId: SessionId, meta: Record, + projects: Record, createdAt?: Date, modifiedAt?: Date, ): Session { + const rawProject = meta["project"] ?? ""; + const projectId = rawProject || inferProjectId(sessionId, projects); return { id: sessionId, - projectId: meta["project"] ?? "", + projectId, status: validateStatus(meta["status"]), activity: null, branch: meta["branch"] || null, @@ -691,7 +722,7 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { // If stat fails, timestamps will fall back to current time } - const session = metadataToSession(sessionName, raw, createdAt, modifiedAt); + const session = metadataToSession(sessionName, raw, config.projects, createdAt, modifiedAt); const plugins = resolvePlugins(project); // Cap per-session enrichment at 2s — subprocess calls (tmux/ps) can be @@ -725,7 +756,7 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { // If stat fails, timestamps will fall back to current time } - const session = metadataToSession(sessionId, raw, createdAt, modifiedAt); + const session = metadataToSession(sessionId, raw, config.projects, createdAt, modifiedAt); const plugins = resolvePlugins(project); await ensureHandleAndEnrich(session, sessionId, project, plugins); diff --git a/packages/web/src/app/api/prs/[id]/merge/route.ts b/packages/web/src/app/api/prs/[id]/merge/route.ts index 6063f70fe..96ce7c756 100644 --- a/packages/web/src/app/api/prs/[id]/merge/route.ts +++ b/packages/web/src/app/api/prs/[id]/merge/route.ts @@ -1,5 +1,6 @@ import { type NextRequest, NextResponse } from "next/server"; import { getServices, getSCM } from "@/lib/services"; +import { inferProjectId } from "@composio/ao-core"; /** POST /api/prs/:id/merge — Merge a PR */ export async function POST(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) { @@ -18,11 +19,24 @@ export async function POST(_request: NextRequest, { params }: { params: Promise< return NextResponse.json({ error: "PR not found" }, { status: 404 }); } - const project = config.projects[session.projectId]; + // Resolve project — use session.projectId, infer from prefix, or fall back to single project + let project = config.projects[session.projectId]; + if (!project) { + const inferred = inferProjectId(session.id, config.projects); + if (inferred) { + project = config.projects[inferred]; + } + } + const scm = getSCM(registry, project); if (!scm) { return NextResponse.json( - { error: "No SCM plugin configured for this project" }, + { + error: "No SCM plugin configured for this project", + detail: session.projectId + ? `Project "${session.projectId}" not found in config` + : `Session "${session.id}" has no project field and prefix could not be matched`, + }, { status: 500 }, ); } diff --git a/packages/web/src/components/Dashboard.tsx b/packages/web/src/components/Dashboard.tsx index a414fc741..96ca8ebfb 100644 --- a/packages/web/src/components/Dashboard.tsx +++ b/packages/web/src/components/Dashboard.tsx @@ -54,7 +54,8 @@ export function Dashboard({ sessions, stats, orchestratorId, projectName }: Dash body: JSON.stringify({ message }), }); if (!res.ok) { - console.error(`Failed to send message to ${sessionId}:`, await res.text()); + const text = await res.text().catch(() => "Unknown error"); + alert(`Failed to send message to ${sessionId}: ${text}`); } }; @@ -64,14 +65,30 @@ export function Dashboard({ sessions, stats, orchestratorId, projectName }: Dash method: "POST", }); if (!res.ok) { - console.error(`Failed to kill ${sessionId}:`, await res.text()); + const text = await res.text().catch(() => "Unknown error"); + alert(`Failed to kill ${sessionId}: ${text}`); } }; const handleMerge = async (prNumber: number) => { const res = await fetch(`/api/prs/${prNumber}/merge`, { method: "POST" }); if (!res.ok) { - console.error(`Failed to merge PR #${prNumber}:`, await res.text()); + let errorMessage = `Failed to merge PR #${prNumber}`; + try { + const body = await res.json(); + if (body.error) { + errorMessage += `:\n\n${body.error}`; + } + if (body.detail) { + errorMessage += `\n${body.detail}`; + } + if (body.blockers && Array.isArray(body.blockers) && body.blockers.length > 0) { + errorMessage += `\n\nBlockers:\n${body.blockers.map((b: string) => `• ${b}`).join("\n")}`; + } + } catch { + errorMessage += `:\n\n${await res.text().catch(() => "Unknown error")}`; + } + alert(errorMessage); } };