fix: infer projectId from session prefix to fix merge button

Legacy sessions (and sessions created before metadata had a `project` field)
cause the merge button to fail with "No SCM plugin configured" because
session.projectId is empty.

Add inferProjectId() that matches session ID prefix against configured
sessionPrefix values (e.g. "ao-18" matches prefix "ao"), with fallback
to the only configured project when there's just one. Use it in
metadataToSession() and the merge API route.

Also improve dashboard error display — show user-facing alerts with
parsed JSON error responses and formatted merge blockers instead of
console-only logging.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Prateek 2026-02-18 17:22:23 +05:30
parent b3ff9f113f
commit 2a8957ff84
5 changed files with 131 additions and 10 deletions

View File

@ -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");
});
});

View File

@ -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

View File

@ -84,6 +84,34 @@ function safeJsonParse<T>(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, ProjectConfig>,
): 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<string> = new Set([
"spawning",
@ -116,12 +144,15 @@ function validateStatus(raw: string | undefined): SessionStatus {
function metadataToSession(
sessionId: SessionId,
meta: Record<string, string>,
projects: Record<string, ProjectConfig>,
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);

View File

@ -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 },
);
}

View File

@ -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);
}
};