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:
parent
b3ff9f113f
commit
2a8957ff84
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue