Compare commits
2 Commits
feat/ms24-
...
fix/missio
| Author | SHA1 | Date | |
|---|---|---|---|
| 811a32eb61 | |||
| 23036cb1dd |
93
apps/web/src/app/api/orchestrator/[...path]/route.ts
Normal file
93
apps/web/src/app/api/orchestrator/[...path]/route.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
const DEFAULT_ORCHESTRATOR_URL = "http://localhost:3001";
|
||||||
|
|
||||||
|
function getOrchestratorUrl(): string {
|
||||||
|
return (
|
||||||
|
process.env.ORCHESTRATOR_URL ??
|
||||||
|
process.env.NEXT_PUBLIC_ORCHESTRATOR_URL ??
|
||||||
|
process.env.NEXT_PUBLIC_API_URL ??
|
||||||
|
DEFAULT_ORCHESTRATOR_URL
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic catch-all proxy for orchestrator API routes.
|
||||||
|
*
|
||||||
|
* Forwards any request to /api/orchestrator/<path> → ORCHESTRATOR_URL/<path>
|
||||||
|
* with the ORCHESTRATOR_API_KEY injected server-side so it never reaches the browser.
|
||||||
|
*
|
||||||
|
* Supports GET, POST, PATCH, DELETE, PUT.
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* GET /api/orchestrator/mission-control/sessions
|
||||||
|
* → GET ORCHESTRATOR_URL/api/mission-control/sessions
|
||||||
|
* POST /api/orchestrator/mission-control/sessions/abc/kill
|
||||||
|
* → POST ORCHESTRATOR_URL/api/mission-control/sessions/abc/kill
|
||||||
|
*/
|
||||||
|
async function proxyToOrchestrator(
|
||||||
|
request: NextRequest,
|
||||||
|
context: { params: Promise<{ path: string[] }> }
|
||||||
|
): Promise<NextResponse> {
|
||||||
|
const orchestratorApiKey = process.env.ORCHESTRATOR_API_KEY;
|
||||||
|
if (!orchestratorApiKey) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "ORCHESTRATOR_API_KEY is not configured on the web server." },
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { path } = await context.params;
|
||||||
|
const upstreamPath = `/${path.join("/")}`;
|
||||||
|
const search = request.nextUrl.search;
|
||||||
|
const upstreamUrl = `${getOrchestratorUrl()}${upstreamPath}${search}`;
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
controller.abort();
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"X-API-Key": orchestratorApiKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
const contentType = request.headers.get("Content-Type");
|
||||||
|
if (contentType) {
|
||||||
|
headers["Content-Type"] = contentType;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasBody = request.method !== "GET" && request.method !== "HEAD";
|
||||||
|
const body = hasBody ? await request.text() : null;
|
||||||
|
|
||||||
|
const upstream = await fetch(upstreamUrl, {
|
||||||
|
method: request.method,
|
||||||
|
headers,
|
||||||
|
...(body !== null ? { body } : {}),
|
||||||
|
cache: "no-store",
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
const responseText = await upstream.text();
|
||||||
|
return new NextResponse(responseText, {
|
||||||
|
status: upstream.status,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": upstream.headers.get("Content-Type") ?? "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error && error.name === "AbortError"
|
||||||
|
? "Orchestrator request timed out."
|
||||||
|
: "Unable to reach orchestrator.";
|
||||||
|
return NextResponse.json({ error: message }, { status: 502 });
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GET = proxyToOrchestrator;
|
||||||
|
export const POST = proxyToOrchestrator;
|
||||||
|
export const PATCH = proxyToOrchestrator;
|
||||||
|
export const PUT = proxyToOrchestrator;
|
||||||
|
export const DELETE = proxyToOrchestrator;
|
||||||
@@ -158,7 +158,9 @@ async function fetchAuditLog(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await apiGet<AuditLogResponse>(`/api/mission-control/audit-log?${params.toString()}`);
|
return await apiGet<AuditLogResponse>(
|
||||||
|
`/api/orchestrator/api/mission-control/audit-log?${params.toString()}`
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isRateLimitError(error)) {
|
if (isRateLimitError(error)) {
|
||||||
return createEmptyAuditLogResponse(page, "Rate limited - retrying...");
|
return createEmptyAuditLogResponse(page, "Rate limited - retrying...");
|
||||||
|
|||||||
@@ -59,9 +59,12 @@ describe("BargeInInput", (): void => {
|
|||||||
await user.click(screen.getByRole("button", { name: "Send" }));
|
await user.click(screen.getByRole("button", { name: "Send" }));
|
||||||
|
|
||||||
await waitFor((): void => {
|
await waitFor((): void => {
|
||||||
expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/session-1/inject", {
|
expect(mockApiPost).toHaveBeenCalledWith(
|
||||||
|
"/api/orchestrator/api/mission-control/sessions/session-1/inject",
|
||||||
|
{
|
||||||
content: "execute plan",
|
content: "execute plan",
|
||||||
});
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(onSent).toHaveBeenCalledTimes(1);
|
expect(onSent).toHaveBeenCalledTimes(1);
|
||||||
@@ -83,12 +86,18 @@ describe("BargeInInput", (): void => {
|
|||||||
|
|
||||||
const calls = mockApiPost.mock.calls as [string, unknown?][];
|
const calls = mockApiPost.mock.calls as [string, unknown?][];
|
||||||
|
|
||||||
expect(calls[0]).toEqual(["/api/mission-control/sessions/session-2/pause", undefined]);
|
expect(calls[0]).toEqual([
|
||||||
|
"/api/orchestrator/api/mission-control/sessions/session-2/pause",
|
||||||
|
undefined,
|
||||||
|
]);
|
||||||
expect(calls[1]).toEqual([
|
expect(calls[1]).toEqual([
|
||||||
"/api/mission-control/sessions/session-2/inject",
|
"/api/orchestrator/api/mission-control/sessions/session-2/inject",
|
||||||
{ content: "hello world" },
|
{ content: "hello world" },
|
||||||
]);
|
]);
|
||||||
expect(calls[2]).toEqual(["/api/mission-control/sessions/session-2/resume", undefined]);
|
expect(calls[2]).toEqual([
|
||||||
|
"/api/orchestrator/api/mission-control/sessions/session-2/resume",
|
||||||
|
undefined,
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("submits with Enter and does not submit on Shift+Enter", async (): Promise<void> => {
|
it("submits with Enter and does not submit on Shift+Enter", async (): Promise<void> => {
|
||||||
@@ -105,9 +114,12 @@ describe("BargeInInput", (): void => {
|
|||||||
fireEvent.keyDown(textarea, { key: "Enter", code: "Enter", shiftKey: false });
|
fireEvent.keyDown(textarea, { key: "Enter", code: "Enter", shiftKey: false });
|
||||||
|
|
||||||
await waitFor((): void => {
|
await waitFor((): void => {
|
||||||
expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/session-3/inject", {
|
expect(mockApiPost).toHaveBeenCalledWith(
|
||||||
|
"/api/orchestrator/api/mission-control/sessions/session-3/inject",
|
||||||
|
{
|
||||||
content: "first",
|
content: "first",
|
||||||
});
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export function BargeInInput({ sessionId, onSent }: BargeInInputProps): React.JS
|
|||||||
}
|
}
|
||||||
|
|
||||||
const encodedSessionId = encodeURIComponent(sessionId);
|
const encodedSessionId = encodeURIComponent(sessionId);
|
||||||
const baseEndpoint = `/api/mission-control/sessions/${encodedSessionId}`;
|
const baseEndpoint = `/api/orchestrator/api/mission-control/sessions/${encodedSessionId}`;
|
||||||
let didPause = false;
|
let didPause = false;
|
||||||
let didInject = false;
|
let didInject = false;
|
||||||
|
|
||||||
|
|||||||
@@ -177,9 +177,12 @@ describe("GlobalAgentRoster", (): void => {
|
|||||||
fireEvent.click(screen.getByRole("button", { name: "Kill session killme12" }));
|
fireEvent.click(screen.getByRole("button", { name: "Kill session killme12" }));
|
||||||
|
|
||||||
await waitFor((): void => {
|
await waitFor((): void => {
|
||||||
expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/killme123456/kill", {
|
expect(mockApiPost).toHaveBeenCalledWith(
|
||||||
|
"/api/orchestrator/api/mission-control/sessions/killme123456/kill",
|
||||||
|
{
|
||||||
force: false,
|
force: false,
|
||||||
});
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ function groupByProvider(sessions: MissionControlSession[]): ProviderSessionGrou
|
|||||||
|
|
||||||
async function fetchSessions(): Promise<MissionControlSession[]> {
|
async function fetchSessions(): Promise<MissionControlSession[]> {
|
||||||
const payload = await apiGet<MissionControlSession[] | SessionsPayload>(
|
const payload = await apiGet<MissionControlSession[] | SessionsPayload>(
|
||||||
"/api/mission-control/sessions"
|
"/api/orchestrator/api/mission-control/sessions"
|
||||||
);
|
);
|
||||||
return Array.isArray(payload) ? payload : payload.sessions;
|
return Array.isArray(payload) ? payload : payload.sessions;
|
||||||
}
|
}
|
||||||
@@ -118,9 +118,12 @@ export function GlobalAgentRoster({
|
|||||||
|
|
||||||
const killMutation = useMutation({
|
const killMutation = useMutation({
|
||||||
mutationFn: async (sessionId: string): Promise<string> => {
|
mutationFn: async (sessionId: string): Promise<string> => {
|
||||||
await apiPost<{ message: string }>(`/api/mission-control/sessions/${sessionId}/kill`, {
|
await apiPost<{ message: string }>(
|
||||||
|
`/api/orchestrator/api/mission-control/sessions/${sessionId}/kill`,
|
||||||
|
{
|
||||||
force: false,
|
force: false,
|
||||||
});
|
}
|
||||||
|
);
|
||||||
return sessionId;
|
return sessionId;
|
||||||
},
|
},
|
||||||
onSuccess: (): void => {
|
onSuccess: (): void => {
|
||||||
|
|||||||
@@ -112,14 +112,20 @@ describe("KillAllDialog", (): void => {
|
|||||||
await user.click(screen.getByRole("button", { name: "Kill All Agents" }));
|
await user.click(screen.getByRole("button", { name: "Kill All Agents" }));
|
||||||
|
|
||||||
await waitFor((): void => {
|
await waitFor((): void => {
|
||||||
expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/internal-1/kill", {
|
expect(mockApiPost).toHaveBeenCalledWith(
|
||||||
|
"/api/orchestrator/api/mission-control/sessions/internal-1/kill",
|
||||||
|
{
|
||||||
force: true,
|
force: true,
|
||||||
});
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockApiPost).not.toHaveBeenCalledWith("/api/mission-control/sessions/external-1/kill", {
|
expect(mockApiPost).not.toHaveBeenCalledWith(
|
||||||
|
"/api/orchestrator/api/mission-control/sessions/external-1/kill",
|
||||||
|
{
|
||||||
force: true,
|
force: true,
|
||||||
});
|
}
|
||||||
|
);
|
||||||
expect(onComplete).toHaveBeenCalledTimes(1);
|
expect(onComplete).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -141,12 +147,18 @@ describe("KillAllDialog", (): void => {
|
|||||||
await user.click(screen.getByRole("button", { name: "Kill All Agents" }));
|
await user.click(screen.getByRole("button", { name: "Kill All Agents" }));
|
||||||
|
|
||||||
await waitFor((): void => {
|
await waitFor((): void => {
|
||||||
expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/internal-2/kill", {
|
expect(mockApiPost).toHaveBeenCalledWith(
|
||||||
|
"/api/orchestrator/api/mission-control/sessions/internal-2/kill",
|
||||||
|
{
|
||||||
force: true,
|
force: true,
|
||||||
});
|
}
|
||||||
expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/external-2/kill", {
|
);
|
||||||
|
expect(mockApiPost).toHaveBeenCalledWith(
|
||||||
|
"/api/orchestrator/api/mission-control/sessions/external-2/kill",
|
||||||
|
{
|
||||||
force: true,
|
force: true,
|
||||||
});
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -96,9 +96,12 @@ export function KillAllDialog({ sessions, onComplete }: KillAllDialogProps): Rea
|
|||||||
|
|
||||||
const killRequests = targetSessions.map(async (session) => {
|
const killRequests = targetSessions.map(async (session) => {
|
||||||
try {
|
try {
|
||||||
await apiPost<{ message: string }>(`/api/mission-control/sessions/${session.id}/kill`, {
|
await apiPost<{ message: string }>(
|
||||||
|
`/api/orchestrator/api/mission-control/sessions/${session.id}/kill`,
|
||||||
|
{
|
||||||
force: true,
|
force: true,
|
||||||
});
|
}
|
||||||
|
);
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ describe("PanelControls", (): void => {
|
|||||||
|
|
||||||
await waitFor((): void => {
|
await waitFor((): void => {
|
||||||
expect(mockApiPost).toHaveBeenCalledWith(
|
expect(mockApiPost).toHaveBeenCalledWith(
|
||||||
"/api/mission-control/sessions/session%20with%20space/pause",
|
"/api/orchestrator/api/mission-control/sessions/session%20with%20space/pause",
|
||||||
undefined
|
undefined
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -114,9 +114,12 @@ describe("PanelControls", (): void => {
|
|||||||
await user.click(screen.getByRole("button", { name: "Confirm" }));
|
await user.click(screen.getByRole("button", { name: "Confirm" }));
|
||||||
|
|
||||||
await waitFor((): void => {
|
await waitFor((): void => {
|
||||||
expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/session-4/kill", {
|
expect(mockApiPost).toHaveBeenCalledWith(
|
||||||
|
"/api/orchestrator/api/mission-control/sessions/session-4/kill",
|
||||||
|
{
|
||||||
force: false,
|
force: false,
|
||||||
});
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(onStatusChange).toHaveBeenCalledWith("killed");
|
expect(onStatusChange).toHaveBeenCalledWith("killed");
|
||||||
@@ -137,9 +140,12 @@ describe("PanelControls", (): void => {
|
|||||||
await user.click(screen.getByRole("button", { name: "Confirm" }));
|
await user.click(screen.getByRole("button", { name: "Confirm" }));
|
||||||
|
|
||||||
await waitFor((): void => {
|
await waitFor((): void => {
|
||||||
expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/session-5/kill", {
|
expect(mockApiPost).toHaveBeenCalledWith(
|
||||||
|
"/api/orchestrator/api/mission-control/sessions/session-5/kill",
|
||||||
|
{
|
||||||
force: true,
|
force: true,
|
||||||
});
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(onStatusChange).toHaveBeenCalledWith("killed");
|
expect(onStatusChange).toHaveBeenCalledWith("killed");
|
||||||
|
|||||||
@@ -50,23 +50,23 @@ export function PanelControls({
|
|||||||
switch (action) {
|
switch (action) {
|
||||||
case "pause":
|
case "pause":
|
||||||
await apiPost<{ message: string }>(
|
await apiPost<{ message: string }>(
|
||||||
`/api/mission-control/sessions/${encodeURIComponent(sessionId)}/pause`
|
`/api/orchestrator/api/mission-control/sessions/${encodeURIComponent(sessionId)}/pause`
|
||||||
);
|
);
|
||||||
return { nextStatus: "paused" };
|
return { nextStatus: "paused" };
|
||||||
case "resume":
|
case "resume":
|
||||||
await apiPost<{ message: string }>(
|
await apiPost<{ message: string }>(
|
||||||
`/api/mission-control/sessions/${encodeURIComponent(sessionId)}/resume`
|
`/api/orchestrator/api/mission-control/sessions/${encodeURIComponent(sessionId)}/resume`
|
||||||
);
|
);
|
||||||
return { nextStatus: "active" };
|
return { nextStatus: "active" };
|
||||||
case "graceful-kill":
|
case "graceful-kill":
|
||||||
await apiPost<{ message: string }>(
|
await apiPost<{ message: string }>(
|
||||||
`/api/mission-control/sessions/${encodeURIComponent(sessionId)}/kill`,
|
`/api/orchestrator/api/mission-control/sessions/${encodeURIComponent(sessionId)}/kill`,
|
||||||
{ force: false }
|
{ force: false }
|
||||||
);
|
);
|
||||||
return { nextStatus: "killed" };
|
return { nextStatus: "killed" };
|
||||||
case "force-kill":
|
case "force-kill":
|
||||||
await apiPost<{ message: string }>(
|
await apiPost<{ message: string }>(
|
||||||
`/api/mission-control/sessions/${encodeURIComponent(sessionId)}/kill`,
|
`/api/orchestrator/api/mission-control/sessions/${encodeURIComponent(sessionId)}/kill`,
|
||||||
{ force: true }
|
{ force: true }
|
||||||
);
|
);
|
||||||
return { nextStatus: "killed" };
|
return { nextStatus: "killed" };
|
||||||
|
|||||||
Reference in New Issue
Block a user