Compare commits

..

1 Commits

Author SHA1 Message Date
e3ef9e3827 fix(deploy): add DATABASE_URL and openbrain network to orchestrator and synapse
All checks were successful
ci/woodpecker/push/ci Infra-only change: no app code affected, CI not required
Orchestrator was crashing with PrismaClientInitializationError because DATABASE_URL
was never added to docker-compose.swarm.portainer.yml when Prisma was integrated.
Synapse was failing because homeserver.yaml pointed to 'postgres' hostname instead
of the actual openbrain_brain-db service.

Changes:
- Add DATABASE_URL env var to orchestrator service
- Add openbrain_brain-internal network to orchestrator service
- Add openbrain_brain-internal network to synapse service

Note: homeserver.yaml /opt/mosaic/synapse/homeserver.yaml was also patched
directly on the server (host: postgres → host: openbrain_brain-db).
2026-03-08 10:36:41 -05:00
13 changed files with 49 additions and 214 deletions

View File

@@ -1,93 +0,0 @@
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;

View File

@@ -4,7 +4,6 @@ import { Outfit, Fira_Code } from "next/font/google";
import { AuthProvider } from "@/lib/auth/auth-context"; import { AuthProvider } from "@/lib/auth/auth-context";
import { ErrorBoundary } from "@/components/error-boundary"; import { ErrorBoundary } from "@/components/error-boundary";
import { ThemeProvider } from "@/providers/ThemeProvider"; import { ThemeProvider } from "@/providers/ThemeProvider";
import { ReactQueryProvider } from "@/providers/ReactQueryProvider";
import "./globals.css"; import "./globals.css";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -57,11 +56,9 @@ export default function RootLayout({ children }: { children: ReactNode }): React
</head> </head>
<body> <body>
<ThemeProvider> <ThemeProvider>
<ReactQueryProvider> <ErrorBoundary>
<ErrorBoundary> <AuthProvider>{children}</AuthProvider>
<AuthProvider>{children}</AuthProvider> </ErrorBoundary>
</ErrorBoundary>
</ReactQueryProvider>
</ThemeProvider> </ThemeProvider>
</body> </body>
</html> </html>

View File

@@ -158,9 +158,7 @@ async function fetchAuditLog(
} }
try { try {
return await apiGet<AuditLogResponse>( return await apiGet<AuditLogResponse>(`/api/mission-control/audit-log?${params.toString()}`);
`/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...");

View File

@@ -59,12 +59,9 @@ 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( expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/session-1/inject", {
"/api/orchestrator/api/mission-control/sessions/session-1/inject", content: "execute plan",
{ });
content: "execute plan",
}
);
}); });
expect(onSent).toHaveBeenCalledTimes(1); expect(onSent).toHaveBeenCalledTimes(1);
@@ -86,18 +83,12 @@ describe("BargeInInput", (): void => {
const calls = mockApiPost.mock.calls as [string, unknown?][]; const calls = mockApiPost.mock.calls as [string, unknown?][];
expect(calls[0]).toEqual([ expect(calls[0]).toEqual(["/api/mission-control/sessions/session-2/pause", undefined]);
"/api/orchestrator/api/mission-control/sessions/session-2/pause",
undefined,
]);
expect(calls[1]).toEqual([ expect(calls[1]).toEqual([
"/api/orchestrator/api/mission-control/sessions/session-2/inject", "/api/mission-control/sessions/session-2/inject",
{ content: "hello world" }, { content: "hello world" },
]); ]);
expect(calls[2]).toEqual([ expect(calls[2]).toEqual(["/api/mission-control/sessions/session-2/resume", undefined]);
"/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> => {
@@ -114,12 +105,9 @@ 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( expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/session-3/inject", {
"/api/orchestrator/api/mission-control/sessions/session-3/inject", content: "first",
{ });
content: "first",
}
);
}); });
}); });

View File

@@ -39,7 +39,7 @@ export function BargeInInput({ sessionId, onSent }: BargeInInputProps): React.JS
} }
const encodedSessionId = encodeURIComponent(sessionId); const encodedSessionId = encodeURIComponent(sessionId);
const baseEndpoint = `/api/orchestrator/api/mission-control/sessions/${encodedSessionId}`; const baseEndpoint = `/api/mission-control/sessions/${encodedSessionId}`;
let didPause = false; let didPause = false;
let didInject = false; let didInject = false;

View File

@@ -177,12 +177,9 @@ 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( expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/killme123456/kill", {
"/api/orchestrator/api/mission-control/sessions/killme123456/kill", force: false,
{ });
force: false,
}
);
}); });
}); });

View File

@@ -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/orchestrator/api/mission-control/sessions" "/api/mission-control/sessions"
); );
return Array.isArray(payload) ? payload : payload.sessions; return Array.isArray(payload) ? payload : payload.sessions;
} }
@@ -118,12 +118,9 @@ 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 }>( await apiPost<{ message: string }>(`/api/mission-control/sessions/${sessionId}/kill`, {
`/api/orchestrator/api/mission-control/sessions/${sessionId}/kill`, force: false,
{ });
force: false,
}
);
return sessionId; return sessionId;
}, },
onSuccess: (): void => { onSuccess: (): void => {

View File

@@ -112,20 +112,14 @@ 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( expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/internal-1/kill", {
"/api/orchestrator/api/mission-control/sessions/internal-1/kill", force: true,
{ });
force: true,
}
);
}); });
expect(mockApiPost).not.toHaveBeenCalledWith( expect(mockApiPost).not.toHaveBeenCalledWith("/api/mission-control/sessions/external-1/kill", {
"/api/orchestrator/api/mission-control/sessions/external-1/kill", force: true,
{ });
force: true,
}
);
expect(onComplete).toHaveBeenCalledTimes(1); expect(onComplete).toHaveBeenCalledTimes(1);
}); });
@@ -147,18 +141,12 @@ 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( expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/internal-2/kill", {
"/api/orchestrator/api/mission-control/sessions/internal-2/kill", force: true,
{ });
force: true, expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/external-2/kill", {
} force: true,
); });
expect(mockApiPost).toHaveBeenCalledWith(
"/api/orchestrator/api/mission-control/sessions/external-2/kill",
{
force: true,
}
);
}); });
}); });

View File

@@ -96,12 +96,9 @@ 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 }>( await apiPost<{ message: string }>(`/api/mission-control/sessions/${session.id}/kill`, {
`/api/orchestrator/api/mission-control/sessions/${session.id}/kill`, force: true,
{ });
force: true,
}
);
return true; return true;
} catch { } catch {
return false; return false;

View File

@@ -89,7 +89,7 @@ describe("PanelControls", (): void => {
await waitFor((): void => { await waitFor((): void => {
expect(mockApiPost).toHaveBeenCalledWith( expect(mockApiPost).toHaveBeenCalledWith(
"/api/orchestrator/api/mission-control/sessions/session%20with%20space/pause", "/api/mission-control/sessions/session%20with%20space/pause",
undefined undefined
); );
}); });
@@ -114,12 +114,9 @@ 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( expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/session-4/kill", {
"/api/orchestrator/api/mission-control/sessions/session-4/kill", force: false,
{ });
force: false,
}
);
}); });
expect(onStatusChange).toHaveBeenCalledWith("killed"); expect(onStatusChange).toHaveBeenCalledWith("killed");
@@ -140,12 +137,9 @@ 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( expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/session-5/kill", {
"/api/orchestrator/api/mission-control/sessions/session-5/kill", force: true,
{ });
force: true,
}
);
}); });
expect(onStatusChange).toHaveBeenCalledWith("killed"); expect(onStatusChange).toHaveBeenCalledWith("killed");

View File

@@ -50,23 +50,23 @@ export function PanelControls({
switch (action) { switch (action) {
case "pause": case "pause":
await apiPost<{ message: string }>( await apiPost<{ message: string }>(
`/api/orchestrator/api/mission-control/sessions/${encodeURIComponent(sessionId)}/pause` `/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/orchestrator/api/mission-control/sessions/${encodeURIComponent(sessionId)}/resume` `/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/orchestrator/api/mission-control/sessions/${encodeURIComponent(sessionId)}/kill`, `/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/orchestrator/api/mission-control/sessions/${encodeURIComponent(sessionId)}/kill`, `/api/mission-control/sessions/${encodeURIComponent(sessionId)}/kill`,
{ force: true } { force: true }
); );
return { nextStatus: "killed" }; return { nextStatus: "killed" };

View File

@@ -1,28 +0,0 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState, type ReactNode } from "react";
interface ReactQueryProviderProps {
children: ReactNode;
}
export function ReactQueryProvider({ children }: ReactQueryProviderProps): React.JSX.Element {
// Create a stable QueryClient per component mount (one per app session)
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
// Don't refetch on window focus in a dashboard context
refetchOnWindowFocus: false,
// Stale time of 30s — short enough for live data, avoids hammering
staleTime: 30_000,
retry: 1,
},
},
})
);
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}

View File

@@ -333,7 +333,7 @@ services:
start_period: 40s start_period: 40s
networks: networks:
- internal - internal
- openbrain-brain-internal - openbrain_brain-internal
cap_drop: cap_drop:
- ALL - ALL
cap_add: cap_add:
@@ -406,7 +406,7 @@ services:
networks: networks:
- internal - internal
- traefik-public - traefik-public
- openbrain-brain-internal - openbrain_brain-internal
deploy: deploy:
restart_policy: restart_policy:
condition: on-failure condition: on-failure