Merge develop into main — branch consolidation #432

Merged
jason.woltje merged 47 commits from merge/develop-to-main into main 2026-02-21 20:56:41 +00:00
5 changed files with 120 additions and 25 deletions
Showing only changes of commit 9d9a01f5f7 - Show all commits

View File

@@ -0,0 +1,43 @@
import { 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
);
}
export async function GET(): 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 }
);
}
try {
const response = await fetch(`${getOrchestratorUrl()}/health/ready`, {
method: "GET",
headers: {
"Content-Type": "application/json",
"X-API-Key": orchestratorApiKey,
},
cache: "no-store",
});
const text = await response.text();
return new NextResponse(text, {
status: response.status,
headers: {
"Content-Type": response.headers.get("Content-Type") ?? "application/json",
},
});
} catch {
return NextResponse.json({ error: "Unable to reach orchestrator." }, { status: 502 });
}
}

View File

@@ -32,6 +32,7 @@ export function OrchestratorEventsWidget({
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [streamConnected, setStreamConnected] = useState(false);
const [backendReady, setBackendReady] = useState<boolean | null>(null);
const loadRecentEvents = useCallback(async (): Promise<void> => {
try {
@@ -39,8 +40,15 @@ export function OrchestratorEventsWidget({
if (!response.ok) {
throw new Error(`Unable to load events: HTTP ${String(response.status)}`);
}
const payload = (await response.json()) as RecentEventsResponse;
setEvents(payload.events);
const payload = (await response.json()) as unknown;
const events =
payload &&
typeof payload === "object" &&
"events" in payload &&
Array.isArray(payload.events)
? (payload.events as RecentEventsResponse["events"])
: [];
setEvents(events);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Unable to load events.");
@@ -49,8 +57,18 @@ export function OrchestratorEventsWidget({
}
}, []);
const loadHealth = useCallback(async (): Promise<void> => {
try {
const response = await fetch("/api/orchestrator/health");
setBackendReady(response.ok);
} catch {
setBackendReady(false);
}
}, []);
useEffect(() => {
void loadRecentEvents();
void loadHealth();
const eventSource =
typeof EventSource !== "undefined" ? new EventSource("/api/orchestrator/events") : null;
@@ -60,6 +78,7 @@ export function OrchestratorEventsWidget({
};
eventSource.onmessage = (): void => {
void loadRecentEvents();
void loadHealth();
};
eventSource.onerror = (): void => {
setStreamConnected(false);
@@ -68,13 +87,14 @@ export function OrchestratorEventsWidget({
const interval = setInterval(() => {
void loadRecentEvents();
void loadHealth();
}, 15000);
return (): void => {
clearInterval(interval);
eventSource?.close();
};
}, [loadRecentEvents]);
}, [loadHealth, loadRecentEvents]);
const matrixSignals = useMemo(
() => events.filter((event) => isMatrixSignal(event)).length,
@@ -102,13 +122,24 @@ export function OrchestratorEventsWidget({
return (
<div className="flex flex-col h-full space-y-3">
<div className="flex items-center justify-between text-xs">
<div className="flex items-center gap-1 text-gray-600 dark:text-gray-300">
<div className="flex items-center gap-2 text-gray-600 dark:text-gray-300">
{streamConnected ? (
<Wifi className="w-3 h-3 text-green-500" />
) : (
<WifiOff className="w-3 h-3 text-gray-400" />
)}
<span>{streamConnected ? "Live stream connected" : "Polling mode"}</span>
<span
className={`rounded px-1.5 py-0.5 ${
backendReady === true
? "bg-green-100 text-green-700 dark:bg-green-950 dark:text-green-300"
: backendReady === false
? "bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-300"
: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-300"
}`}
>
{backendReady === true ? "ready" : backendReady === false ? "degraded" : "unknown"}
</span>
</div>
<div className="flex items-center gap-1 rounded bg-blue-50 dark:bg-blue-950 px-2 py-1 text-blue-700 dark:text-blue-300">
<DatabaseZap className="w-3 h-3" />

View File

@@ -147,8 +147,15 @@ export function TaskProgressWidget({ id: _id, config: _config }: WidgetProps): R
try {
const res = await fetch("/api/orchestrator/events/recent?limit=5");
if (!res.ok) throw new Error(`HTTP ${String(res.status)}`);
const data = (await res.json()) as { events: RecentOrchestratorEvent[] };
setRecentEvents(data.events);
const payload = (await res.json()) as unknown;
const events =
payload &&
typeof payload === "object" &&
"events" in payload &&
Array.isArray(payload.events)
? (payload.events as RecentOrchestratorEvent[])
: [];
setRecentEvents(events);
} catch {
// Optional enhancement path; do not fail widget if recent-events endpoint is unavailable.
}

View File

@@ -24,25 +24,37 @@ describe("OrchestratorEventsWidget", () => {
});
it("renders events and matrix signal count", async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
events: [
{
type: "task.completed",
timestamp: "2026-02-17T16:40:00.000Z",
taskId: "TASK-1",
data: { channelId: "room-123" },
},
{
type: "agent.running",
timestamp: "2026-02-17T16:41:00.000Z",
taskId: "TASK-2",
agentId: "agent-abc12345",
},
],
}),
mockFetch.mockImplementation((input: RequestInfo | URL) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("/api/orchestrator/health")) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ status: "ok" }),
} as unknown as Response);
}
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
events: [
{
type: "task.completed",
timestamp: "2026-02-17T16:40:00.000Z",
taskId: "TASK-1",
data: { channelId: "room-123" },
},
{
type: "agent.running",
timestamp: "2026-02-17T16:41:00.000Z",
taskId: "TASK-2",
agentId: "agent-abc12345",
},
],
}),
} as unknown as Response);
});
render(<OrchestratorEventsWidget id="orchestrator-events-1" config={{}} />);
@@ -51,6 +63,7 @@ describe("OrchestratorEventsWidget", () => {
expect(screen.getByText("task.completed")).toBeInTheDocument();
expect(screen.getByText("agent.running")).toBeInTheDocument();
expect(screen.getByText(/Matrix signals: 1/)).toBeInTheDocument();
expect(screen.getByText("ready")).toBeInTheDocument();
});
});

View File

@@ -387,3 +387,4 @@
| ORCH-OBS-008 | done | Integrate new widget into HUD/WidgetRegistry and extend widget regression coverage | #411 | web | feature/mosaic-stack-finalization | ORCH-OBS-007 | | orch | 2026-02-17T17:03Z | 2026-02-17T17:08Z | 10K | 7K |
| ORCH-OBS-009 | done | Seed default/reset local HUD layout with orchestration widgets so visibility works out-of-box | #411 | web | feature/mosaic-stack-finalization | ORCH-OBS-008 | | orch | 2026-02-17T17:10Z | 2026-02-17T17:14Z | 8K | 6K |
| ORCH-OBS-010 | done | Enrich `TaskProgressWidget` with latest recent-event context from `/api/orchestrator/events/recent` | #411 | web | feature/mosaic-stack-finalization | ORCH-OBS-009 | | orch | 2026-02-17T17:15Z | 2026-02-17T17:20Z | 8K | 6K |
| ORCH-OBS-011 | done | Add orchestrator health proxy and readiness badge (`ready/degraded`) in events widget | #411 | web | feature/mosaic-stack-finalization | ORCH-OBS-010 | | orch | 2026-02-17T17:22Z | 2026-02-17T17:27Z | 8K | 6K |