mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-12 10:12:11 +03:00
feat(dashboard): Conductor panel — fleet, tasks and cancel over server-side proxy (PRD RF3) (#8221)
* feat(a2a): conductor bridge core — event mapping with canceled->cancelled
* feat(a2a): incremental SSE parser for conductor bridge
* feat(a2a): conductor bridge connection loop with persisted cursor and backoff
* feat(a2a): start conductor bridge at boot behind CONDUCTOR_HUB_URL
* fix(a2a): conductor bridge parses the hub's real SSE wire format (id/type in frame, data={ts,payload})
* docs(reference): document CONDUCTOR_HUB_URL/TOKEN in ENVIRONMENT.md (env-doc-sync gate)
* feat(a2a): fleet skills derived from the Conductor hub for the agent card
* feat(a2a): agent card announces Conductor fleet skills
* feat(dashboard): server-side hub proxy for the Conductor panel (whitelisted shapes, fail-open)
* feat(dashboard): /api/conductor proxy routes (fleet, task detail, cancel) behind management auth
* feat(dashboard): Conductor panel — fleet live view, task detail and cancel over /api/conductor proxy
---------
Co-authored-by: backryun <bakryun0718@proton.me>
This commit is contained in:
committed by
GitHub
parent
0b158209ee
commit
3db785dc41
1
changelog.d/features/conductor-panel.md
Normal file
1
changelog.d/features/conductor-panel.md
Normal file
@@ -0,0 +1 @@
|
||||
- feat(dashboard): "Conductor" panel — OmniConductor fleet (runners + task queue) live via server-side proxy routes (`/api/conductor/*`, management auth, hub token never reaches the browser), task detail with manifest/council and cancel-with-confirmation; sidebar entry under Tools
|
||||
233
src/app/(dashboard)/dashboard/conductor/ConductorPageClient.tsx
Normal file
233
src/app/(dashboard)/dashboard/conductor/ConductorPageClient.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Conductor panel (PRD Conductor RF3): fleet + task queue live view over the
|
||||
* /api/conductor proxy routes. The browser never talks to the hub — everything
|
||||
* goes through the server-side proxy (hub token stays in server env).
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { Badge, Card, ConfirmModal, DataTable, EmptyState, Modal } from "@/shared/components";
|
||||
|
||||
interface FleetRunner {
|
||||
id: string;
|
||||
name: string;
|
||||
clis: string[];
|
||||
online: boolean;
|
||||
draining: boolean;
|
||||
}
|
||||
|
||||
interface FleetTask {
|
||||
id: string;
|
||||
status: string;
|
||||
mode: string;
|
||||
repo: string | null;
|
||||
runner: string | null;
|
||||
summary: string | null;
|
||||
branch: string | null;
|
||||
error: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
interface FleetSnapshot {
|
||||
offline: boolean;
|
||||
runners: FleetRunner[];
|
||||
tasks: FleetTask[];
|
||||
}
|
||||
|
||||
interface TaskDetail extends FleetTask {
|
||||
prompt: string | null;
|
||||
base_ref: string | null;
|
||||
council: { candidate_task_ids?: string[] } | null;
|
||||
}
|
||||
|
||||
const REFRESH_MS = 5000;
|
||||
const TERMINAL = new Set(["completed", "failed", "canceled"]);
|
||||
|
||||
function statusVariant(status: string): "success" | "error" | "warning" | "info" | "default" {
|
||||
if (status === "completed") return "success";
|
||||
if (status === "failed") return "error";
|
||||
if (status === "canceled" || status === "input_required") return "warning";
|
||||
if (status === "working") return "info";
|
||||
return "default";
|
||||
}
|
||||
|
||||
export default function ConductorPageClient() {
|
||||
const t = useTranslations("conductor");
|
||||
const [snapshot, setSnapshot] = useState<FleetSnapshot | null>(null);
|
||||
const [detail, setDetail] = useState<TaskDetail | null>(null);
|
||||
const [cancelTarget, setCancelTarget] = useState<string | null>(null);
|
||||
const [canceling, setCanceling] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/conductor/fleet");
|
||||
if (res.ok) setSnapshot(await res.json());
|
||||
} catch {
|
||||
// rede local instável: mantém o último snapshot; o banner offline vem do servidor
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
const timer = setInterval(() => void load(), REFRESH_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, [load]);
|
||||
|
||||
const openDetail = async (taskId: string) => {
|
||||
setErr("");
|
||||
try {
|
||||
const res = await fetch(`/api/conductor/tasks/${encodeURIComponent(taskId)}`);
|
||||
if (res.ok) setDetail(await res.json());
|
||||
else setErr(`${t("error")}: HTTP ${res.status}`);
|
||||
} catch {
|
||||
setErr(t("error"));
|
||||
}
|
||||
};
|
||||
|
||||
const confirmCancel = async () => {
|
||||
if (!cancelTarget) return;
|
||||
setCanceling(true);
|
||||
setErr("");
|
||||
try {
|
||||
const res = await fetch(`/api/conductor/tasks/${encodeURIComponent(cancelTarget)}/cancel`, { method: "POST" });
|
||||
if (!res.ok) setErr(`${t("cancelFailed")} (HTTP ${res.status})`);
|
||||
else {
|
||||
setDetail(null);
|
||||
await load();
|
||||
}
|
||||
} catch {
|
||||
setErr(t("cancelFailed"));
|
||||
} finally {
|
||||
setCanceling(false);
|
||||
setCancelTarget(null);
|
||||
}
|
||||
};
|
||||
|
||||
const runners = snapshot?.runners ?? [];
|
||||
const tasks = snapshot?.tasks ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">{t("title")}</h1>
|
||||
<p className="text-sm text-text-muted">{t("subtitle")}</p>
|
||||
</div>
|
||||
|
||||
{err && <Badge variant="error">{err}</Badge>}
|
||||
|
||||
{snapshot?.offline ? (
|
||||
<Card>
|
||||
<EmptyState icon="cloud_off" title={t("hubOffline")} />
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<Card title={t("runners")}>
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: "name", label: t("colName") },
|
||||
{ key: "clis", label: t("colClis") },
|
||||
{ key: "status", label: t("colStatus") },
|
||||
]}
|
||||
data={runners.map((r) => ({ ...r, id: r.id }))}
|
||||
emptyMessage={t("noRunners")}
|
||||
loading={snapshot === null}
|
||||
renderCell={(row, column) => {
|
||||
const r = row as unknown as FleetRunner;
|
||||
if (column.key === "name") return <span className="font-medium">{r.name}</span>;
|
||||
if (column.key === "clis") return r.clis.join(" / ");
|
||||
if (r.draining) return <Badge variant="warning" dot>{t("draining")}</Badge>;
|
||||
return r.online ? (
|
||||
<Badge variant="success" dot>{t("online")}</Badge>
|
||||
) : (
|
||||
<Badge variant="error" dot>{t("offline")}</Badge>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title={t("tasks")}>
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: "id", label: t("colTask") },
|
||||
{ key: "status", label: t("colStatus") },
|
||||
{ key: "mode", label: t("colMode") },
|
||||
{ key: "runner", label: t("colRunner") },
|
||||
{ key: "summary", label: t("colSummary"), maxWidth: "28rem" },
|
||||
]}
|
||||
data={tasks.map((task) => ({ ...task, id: task.id }))}
|
||||
emptyMessage={t("noTasks")}
|
||||
loading={snapshot === null}
|
||||
onRowClick={(row) => void openDetail(String(row.id))}
|
||||
renderCell={(row, column) => {
|
||||
const task = row as unknown as FleetTask;
|
||||
if (column.key === "status") return <Badge variant={statusVariant(task.status)} dot>{task.status}</Badge>;
|
||||
if (column.key === "id") return <code className="text-xs">{task.id}</code>;
|
||||
if (column.key === "summary") return task.summary ?? task.error ?? "—";
|
||||
return (task as unknown as Record<string, unknown>)[column.key]?.toString() ?? "—";
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Modal isOpen={detail !== null} onClose={() => setDetail(null)} title={t("detailTitle")} size="lg">
|
||||
{detail && (
|
||||
<div className="space-y-4 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-xs">{detail.id}</code>
|
||||
<Badge variant={statusVariant(detail.status)} dot>{detail.status}</Badge>
|
||||
<Badge>{detail.mode}</Badge>
|
||||
{detail.runner && <Badge variant="info">{detail.runner}</Badge>}
|
||||
</div>
|
||||
{detail.prompt && (
|
||||
<div>
|
||||
<div className="font-medium">{t("prompt")}</div>
|
||||
<pre className="whitespace-pre-wrap text-xs bg-black/5 dark:bg-white/5 rounded p-2">{detail.prompt}</pre>
|
||||
</div>
|
||||
)}
|
||||
{detail.summary && <p>{detail.summary}</p>}
|
||||
{detail.error && <Badge variant="error">{detail.error}</Badge>}
|
||||
{detail.branch && (
|
||||
<div>
|
||||
<div className="font-medium">{t("branch")}</div>
|
||||
<code className="text-xs">{detail.branch}</code>
|
||||
<p className="text-xs text-text-muted">{t("fetchHint", { branch: detail.branch })}</p>
|
||||
</div>
|
||||
)}
|
||||
{detail.mode.startsWith("council") && detail.council?.candidate_task_ids && (
|
||||
<div>
|
||||
<div className="font-medium">{t("council")}</div>
|
||||
<p className="text-xs">
|
||||
{t("candidates")}: {detail.council.candidate_task_ids.join(", ")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!TERMINAL.has(detail.status) && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-red-600 dark:text-red-400 underline"
|
||||
onClick={() => setCancelTarget(detail.id)}
|
||||
>
|
||||
{t("cancel")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={cancelTarget !== null}
|
||||
onClose={() => setCancelTarget(null)}
|
||||
onConfirm={confirmCancel}
|
||||
title={t("cancelConfirmTitle")}
|
||||
message={t("cancelConfirmMessage")}
|
||||
confirmText={t("cancel")}
|
||||
loading={canceling}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
src/app/(dashboard)/dashboard/conductor/page.tsx
Normal file
10
src/app/(dashboard)/dashboard/conductor/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import ConductorPageClient from "./ConductorPageClient";
|
||||
|
||||
export const metadata = {
|
||||
title: "Conductor — OmniRoute",
|
||||
description: "OmniConductor CLI-agent fleet: runners, task queue and councils, live.",
|
||||
};
|
||||
|
||||
export default function ConductorPage() {
|
||||
return <ConductorPageClient />;
|
||||
}
|
||||
17
src/app/api/conductor/fleet/route.ts
Normal file
17
src/app/api/conductor/fleet/route.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* GET /api/conductor/fleet — fleet snapshot for the Conductor dashboard panel
|
||||
* (PRD Conductor RF3). Server-side proxy: the hub token lives only in env; the
|
||||
* response is the whitelisted shape from hubProxy (degraded {offline:true} when
|
||||
* the hub is unset/offline — never a 5xx for that).
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { getFleetSnapshot } from "@/lib/conductor/hubProxy";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const authError = await requireManagementAuth(request);
|
||||
if (authError) return authError;
|
||||
return NextResponse.json(await getFleetSnapshot());
|
||||
}
|
||||
26
src/app/api/conductor/tasks/[id]/cancel/route.ts
Normal file
26
src/app/api/conductor/tasks/[id]/cancel/route.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* POST /api/conductor/tasks/[id]/cancel — cancela a task no hub do Conductor.
|
||||
* Ação destrutiva: auth de gerência + confirmação na UI (ConfirmModal). A
|
||||
* recusa do hub volta só como status + mensagem sanitizada (nunca o corpo
|
||||
* upstream — Hard Rule #12).
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { createErrorResponse } from "@/lib/api/errorResponse";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { cancelConductorTask } from "@/lib/conductor/hubProxy";
|
||||
|
||||
export async function POST(request: Request, ctx: { params: Promise<{ id: string }> }) {
|
||||
const authError = await requireManagementAuth(request);
|
||||
if (authError) return authError;
|
||||
const { id } = await ctx.params;
|
||||
const result = await cancelConductorTask(id);
|
||||
if (!result.ok) {
|
||||
return createErrorResponse({
|
||||
status: result.status,
|
||||
message: `Conductor hub refused the cancellation (HTTP ${result.status})`,
|
||||
});
|
||||
}
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
22
src/app/api/conductor/tasks/[id]/route.ts
Normal file
22
src/app/api/conductor/tasks/[id]/route.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* GET /api/conductor/tasks/[id] — whitelisted task detail (manifest, prompt,
|
||||
* council funnel) from the Conductor hub. 404 sanitizado quando o hub não
|
||||
* conhece a task — o corpo do hub nunca é repassado.
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { createErrorResponse } from "@/lib/api/errorResponse";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { getConductorTaskDetail } from "@/lib/conductor/hubProxy";
|
||||
|
||||
export async function GET(request: Request, ctx: { params: Promise<{ id: string }> }) {
|
||||
const authError = await requireManagementAuth(request);
|
||||
if (authError) return authError;
|
||||
const { id } = await ctx.params;
|
||||
const detail = await getConductorTaskDetail(id);
|
||||
if (!detail) {
|
||||
return createErrorResponse({ status: 404, message: "Conductor task not found (or hub offline)" });
|
||||
}
|
||||
return NextResponse.json(detail);
|
||||
}
|
||||
@@ -1275,6 +1275,8 @@
|
||||
"groupSeparatorLabel": "Separator",
|
||||
"discovery": "Discovery",
|
||||
"discoverySubtitle": "Scan providers for free access",
|
||||
"conductor": "Conductor",
|
||||
"conductorSubtitle": "CLI-agent fleet",
|
||||
"resilienceConnections": "Connection Resilience",
|
||||
"resilienceConnectionsSubtitle": "Cooldown, breaker, lockout state",
|
||||
"settingsModalityBridge": "Modality Bridge",
|
||||
@@ -13521,5 +13523,36 @@
|
||||
"relayDescription": "Serverless relay proxy endpoints for your AI infrastructure",
|
||||
"trafficInspectorTitle": "Traffic Inspector — OmniRoute",
|
||||
"trafficInspectorDescription": "Monitor LLM calls + debug any application's HTTPS traffic"
|
||||
},
|
||||
"conductor": {
|
||||
"title": "Conductor — CLI-agent fleet",
|
||||
"subtitle": "OmniConductor hub: runners, task queue and councils, live",
|
||||
"runners": "Runners",
|
||||
"tasks": "Tasks",
|
||||
"colName": "Name",
|
||||
"colClis": "CLIs",
|
||||
"colStatus": "Status",
|
||||
"colTask": "Task",
|
||||
"colMode": "Mode",
|
||||
"colRunner": "Runner",
|
||||
"colSummary": "Summary",
|
||||
"online": "online",
|
||||
"offline": "offline",
|
||||
"draining": "draining",
|
||||
"hubOffline": "Conductor hub offline or not configured (CONDUCTOR_HUB_URL) — showing nothing rather than stale data.",
|
||||
"noRunners": "No runners registered",
|
||||
"noTasks": "No tasks yet",
|
||||
"detailTitle": "Task detail",
|
||||
"prompt": "Prompt",
|
||||
"branch": "Branch",
|
||||
"fetchHint": "Fetch the result with: git fetch origin {branch}",
|
||||
"council": "Council",
|
||||
"candidates": "Candidates",
|
||||
"cancel": "Cancel task",
|
||||
"cancelConfirmTitle": "Cancel this task?",
|
||||
"cancelConfirmMessage": "The hub will abort the execution on the runner. This cannot be undone.",
|
||||
"cancelFailed": "The hub refused the cancellation",
|
||||
"close": "Close",
|
||||
"error": "Error"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1263,6 +1263,8 @@
|
||||
"groupSeparatorLabel": "Separador",
|
||||
"discovery": "Descoberta",
|
||||
"discoverySubtitle": "Buscar acesso gratuito em provedores",
|
||||
"conductor": "Conductor",
|
||||
"conductorSubtitle": "Frota de agentes CLI",
|
||||
"resilienceConnections": "Resiliência de Conexão",
|
||||
"resilienceConnectionsSubtitle": "Cooldown, disjuntor, estado de bloqueio",
|
||||
"settingsModalityBridge": "Ponte de Modalidade",
|
||||
@@ -13506,6 +13508,37 @@
|
||||
"relayDescription": "__MISSING__:Serverless relay proxy endpoints for your AI infrastructure",
|
||||
"trafficInspectorTitle": "__MISSING__:Traffic Inspector — OmniRoute",
|
||||
"trafficInspectorDescription": "__MISSING__:Monitor LLM calls + debug any application's HTTPS traffic"
|
||||
},
|
||||
"conductor": {
|
||||
"title": "Conductor — frota de agentes CLI",
|
||||
"subtitle": "Hub OmniConductor: runners, fila de tasks e councils, ao vivo",
|
||||
"runners": "Runners",
|
||||
"tasks": "Tasks",
|
||||
"colName": "Nome",
|
||||
"colClis": "CLIs",
|
||||
"colStatus": "Status",
|
||||
"colTask": "Task",
|
||||
"colMode": "Modo",
|
||||
"colRunner": "Runner",
|
||||
"colSummary": "Resumo",
|
||||
"online": "online",
|
||||
"offline": "offline",
|
||||
"draining": "drenando",
|
||||
"hubOffline": "Hub do Conductor fora do ar ou não configurado (CONDUCTOR_HUB_URL) — melhor nada do que dado velho.",
|
||||
"noRunners": "Nenhum runner registrado",
|
||||
"noTasks": "Nenhuma task ainda",
|
||||
"detailTitle": "Detalhe da task",
|
||||
"prompt": "Prompt",
|
||||
"branch": "Branch",
|
||||
"fetchHint": "Busque o resultado com: git fetch origin {branch}",
|
||||
"council": "Council",
|
||||
"candidates": "Candidatos",
|
||||
"cancel": "Cancelar task",
|
||||
"cancelConfirmTitle": "Cancelar esta task?",
|
||||
"cancelConfirmMessage": "O hub aborta a execução no runner. Não dá para desfazer.",
|
||||
"cancelFailed": "O hub recusou o cancelamento",
|
||||
"close": "Fechar",
|
||||
"error": "Erro"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
176
src/lib/conductor/hubProxy.ts
Normal file
176
src/lib/conductor/hubProxy.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Server-side proxy to the OmniConductor hub (Conductor PRD RF3).
|
||||
*
|
||||
* The browser NEVER talks to the hub: these helpers run only in API routes,
|
||||
* authenticate with the server-side env token, and return WHITELISTED shapes —
|
||||
* runner/hub tokens can never leak to the client. Fail-open: hub unset/offline
|
||||
* yields a degraded snapshot ({offline: true}) instead of an error.
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
// ============ Whitelisted client-facing shapes ============
|
||||
|
||||
export interface FleetRunner {
|
||||
id: string;
|
||||
name: string;
|
||||
clis: string[];
|
||||
online: boolean;
|
||||
draining: boolean;
|
||||
}
|
||||
|
||||
export interface FleetTask {
|
||||
id: string;
|
||||
status: string;
|
||||
mode: string;
|
||||
repo: string | null;
|
||||
runner: string | null;
|
||||
summary: string | null;
|
||||
branch: string | null;
|
||||
error: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface FleetSnapshot {
|
||||
offline: boolean;
|
||||
runners: FleetRunner[];
|
||||
tasks: FleetTask[];
|
||||
}
|
||||
|
||||
export interface ConductorTaskDetail extends FleetTask {
|
||||
prompt: string | null;
|
||||
base_ref: string | null;
|
||||
tests: unknown;
|
||||
council: unknown;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
// ============ Untrusted hub shapes (parse only what we read) ============
|
||||
|
||||
const hubRunnerSchema = z.object({
|
||||
id: z.string(),
|
||||
online: z.boolean().optional(),
|
||||
draining: z.boolean().optional(),
|
||||
capabilities: z.object({
|
||||
name: z.string().optional(),
|
||||
clis: z.array(z.object({ profile: z.string() })).optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
const hubTaskSchema = z.object({
|
||||
id: z.string(),
|
||||
status: z.string(),
|
||||
mode: z.string().optional(),
|
||||
repo: z.object({ url: z.string().optional(), base_ref: z.string().optional() }).nullish(),
|
||||
spec: z.object({ prompt: z.string().optional() }).nullish(),
|
||||
assigned_runner: z.string().nullish(),
|
||||
manifest: z
|
||||
.object({
|
||||
summary: z.string().nullish(),
|
||||
branch: z.string().nullish(),
|
||||
error: z.string().nullish(),
|
||||
tests: z.unknown().optional(),
|
||||
})
|
||||
.nullish(),
|
||||
council: z.unknown().optional(),
|
||||
created_at: z.string().optional(),
|
||||
updated_at: z.string().optional(),
|
||||
});
|
||||
|
||||
export interface HubProxyOptions {
|
||||
fetchImpl?: typeof fetch;
|
||||
}
|
||||
|
||||
function hubConfig(): { url: string; token: string } | null {
|
||||
const url = process.env.CONDUCTOR_HUB_URL?.trim();
|
||||
if (!url) return null;
|
||||
return { url, token: process.env.CONDUCTOR_HUB_TOKEN?.trim() ?? "" };
|
||||
}
|
||||
|
||||
async function hubGet(path: string, opts: HubProxyOptions): Promise<unknown | null> {
|
||||
const cfg = hubConfig();
|
||||
if (!cfg) return null;
|
||||
const doFetch = opts.fetchImpl ?? fetch;
|
||||
const res = await doFetch(`${cfg.url}${path}`, {
|
||||
headers: { authorization: `Bearer ${cfg.token}` },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function toFleetTask(t: z.infer<typeof hubTaskSchema>): FleetTask {
|
||||
return {
|
||||
id: t.id,
|
||||
status: t.status,
|
||||
mode: t.mode ?? "solo",
|
||||
repo: t.repo?.url ?? null,
|
||||
runner: t.assigned_runner ?? null,
|
||||
summary: t.manifest?.summary ?? null,
|
||||
branch: t.manifest?.branch ?? null,
|
||||
error: t.manifest?.error ?? null,
|
||||
updated_at: t.updated_at ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Fleet snapshot for the dashboard panel. Degraded ({offline: true}) on any failure. */
|
||||
export async function getFleetSnapshot(opts: HubProxyOptions = {}): Promise<FleetSnapshot> {
|
||||
try {
|
||||
const [rawRunners, rawTasks] = await Promise.all([
|
||||
hubGet("/v1/runners", opts),
|
||||
hubGet("/v1/tasks", opts),
|
||||
]);
|
||||
if (rawRunners === null || rawTasks === null) return { offline: true, runners: [], tasks: [] };
|
||||
const runners = z.array(hubRunnerSchema).parse(rawRunners).map((r) => ({
|
||||
id: r.id,
|
||||
name: r.capabilities.name ?? "?",
|
||||
clis: (r.capabilities.clis ?? []).map((c) => c.profile),
|
||||
online: r.online !== false,
|
||||
draining: r.draining === true,
|
||||
}));
|
||||
const tasks = z.array(hubTaskSchema).parse(rawTasks).map(toFleetTask);
|
||||
return { offline: false, runners, tasks };
|
||||
} catch {
|
||||
return { offline: true, runners: [], tasks: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/** Full whitelisted detail of one task (manifest, prompt, council funnel data). */
|
||||
export async function getConductorTaskDetail(
|
||||
taskId: string,
|
||||
opts: HubProxyOptions = {}
|
||||
): Promise<ConductorTaskDetail | null> {
|
||||
try {
|
||||
const raw = await hubGet(`/v1/tasks/${encodeURIComponent(taskId)}`, opts);
|
||||
if (raw === null) return null;
|
||||
const t = hubTaskSchema.parse(raw);
|
||||
return {
|
||||
...toFleetTask(t),
|
||||
prompt: t.spec?.prompt ?? null,
|
||||
base_ref: t.repo?.base_ref ?? null,
|
||||
tests: t.manifest?.tests ?? null,
|
||||
council: t.council ?? null,
|
||||
created_at: t.created_at ?? null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Cancels a task on the hub. Returns the hub's verdict without leaking its body on error. */
|
||||
export async function cancelConductorTask(
|
||||
taskId: string,
|
||||
opts: HubProxyOptions = {}
|
||||
): Promise<{ ok: boolean; status: number }> {
|
||||
const cfg = hubConfig();
|
||||
if (!cfg) return { ok: false, status: 503 };
|
||||
try {
|
||||
const doFetch = opts.fetchImpl ?? fetch;
|
||||
const res = await doFetch(`${cfg.url}/v1/tasks/${encodeURIComponent(taskId)}/cancel`, {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${cfg.token}` },
|
||||
});
|
||||
return { ok: res.ok, status: res.status };
|
||||
} catch {
|
||||
return { ok: false, status: 503 };
|
||||
}
|
||||
}
|
||||
@@ -243,6 +243,15 @@ const TOOLS_GROUP: SidebarItemGroup = {
|
||||
subtitleKey: "cloudAgentsSubtitle",
|
||||
icon: "cloud",
|
||||
},
|
||||
{
|
||||
id: "conductor",
|
||||
href: "/dashboard/conductor",
|
||||
i18nKey: "conductor",
|
||||
subtitleKey: "conductorSubtitle",
|
||||
icon: "account_tree",
|
||||
labelFallback: "Conductor",
|
||||
subtitleFallback: "CLI-agent fleet",
|
||||
},
|
||||
{
|
||||
id: "agent-bridge",
|
||||
href: "/dashboard/tools/agent-bridge",
|
||||
|
||||
@@ -29,6 +29,7 @@ export const HIDEABLE_SIDEBAR_ITEM_IDS = [
|
||||
"cli-agents",
|
||||
"acp-agents",
|
||||
"cloud-agents",
|
||||
"conductor",
|
||||
"agent-bridge",
|
||||
"traffic-inspector",
|
||||
"discovery",
|
||||
|
||||
103
tests/unit/conductor-fleet-route.test.ts
Normal file
103
tests/unit/conductor-fleet-route.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createServer, type Server } from "node:http";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-conductor-route-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const fleetRoute = await import("../../src/app/api/conductor/fleet/route.ts");
|
||||
const detailRoute = await import("../../src/app/api/conductor/tasks/[id]/route.ts");
|
||||
const cancelRoute = await import("../../src/app/api/conductor/tasks/[id]/cancel/route.ts");
|
||||
|
||||
const servers: Server[] = [];
|
||||
|
||||
function fakeHub(routes: Record<string, { status: number; body: unknown }>): Promise<string> {
|
||||
const server = createServer((req, res) => {
|
||||
const hit = Object.entries(routes).find(([p]) => (req.url ?? "").startsWith(p));
|
||||
res.writeHead(hit ? hit[1].status : 404, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify(hit ? hit[1].body : { error: "hub: segredo interno que NÃO pode vazar" }));
|
||||
});
|
||||
servers.push(server);
|
||||
return new Promise((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const addr = server.address();
|
||||
resolve(`http://127.0.0.1:${typeof addr === "object" && addr ? addr.port : 0}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
delete process.env.CONDUCTOR_HUB_URL;
|
||||
delete process.env.CONDUCTOR_HUB_TOKEN;
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
delete process.env.CONDUCTOR_HUB_URL;
|
||||
while (servers.length > 0) {
|
||||
const s = servers.pop();
|
||||
await new Promise((resolve) => s?.close(resolve));
|
||||
}
|
||||
});
|
||||
|
||||
test("GET /api/conductor/fleet devolve snapshot whitelisted; sem hub → degradado 200", async () => {
|
||||
process.env.CONDUCTOR_HUB_URL = await fakeHub({
|
||||
"/v1/runners": {
|
||||
status: 200,
|
||||
body: [{ id: "r_1", token: "VAZOU?", online: true, capabilities: { name: "devbox", clis: [{ profile: "claude" }] } }],
|
||||
},
|
||||
"/v1/tasks": {
|
||||
status: 200,
|
||||
body: [{ id: "t_1", status: "working", mode: "solo", repo: { url: "https://x/r" }, assigned_runner: "r_1" }],
|
||||
},
|
||||
});
|
||||
process.env.CONDUCTOR_HUB_TOKEN = "tok";
|
||||
const res = await fleetRoute.GET(new Request("http://localhost/api/conductor/fleet"));
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.equal(body.offline, false);
|
||||
assert.equal(body.runners[0].name, "devbox");
|
||||
assert.equal(body.tasks[0].status, "working");
|
||||
assert.ok(!JSON.stringify(body).includes("VAZOU?"), "token de runner não vaza pela rota");
|
||||
|
||||
delete process.env.CONDUCTOR_HUB_URL; // sem hub: degradado, nunca 500
|
||||
const down = await fleetRoute.GET(new Request("http://localhost/api/conductor/fleet"));
|
||||
assert.equal(down.status, 200);
|
||||
assert.equal((await down.json()).offline, true);
|
||||
});
|
||||
|
||||
test("GET /api/conductor/tasks/[id] → 404 sanitizado quando o hub não conhece a task", async () => {
|
||||
process.env.CONDUCTOR_HUB_URL = await fakeHub({});
|
||||
const res = await detailRoute.GET(new Request("http://localhost/api/conductor/tasks/t_x"), {
|
||||
params: Promise.resolve({ id: "t_x" }),
|
||||
});
|
||||
assert.equal(res.status, 404);
|
||||
const text = await res.text();
|
||||
assert.ok(!text.includes("segredo interno"), "corpo do hub NUNCA repassado");
|
||||
});
|
||||
|
||||
test("POST cancel repassa recusa do hub com status, sem corpo upstream", async () => {
|
||||
process.env.CONDUCTOR_HUB_URL = await fakeHub({
|
||||
"/v1/tasks/t_done/cancel": { status: 409, body: { error: "segredo interno que NÃO pode vazar" } },
|
||||
"/v1/tasks/t_ok/cancel": { status: 200, body: { ok: true } },
|
||||
});
|
||||
const denied = await cancelRoute.POST(new Request("http://localhost/x", { method: "POST" }), {
|
||||
params: Promise.resolve({ id: "t_done" }),
|
||||
});
|
||||
assert.equal(denied.status, 409);
|
||||
assert.ok(!(await denied.text()).includes("segredo interno"));
|
||||
|
||||
const ok = await cancelRoute.POST(new Request("http://localhost/x", { method: "POST" }), {
|
||||
params: Promise.resolve({ id: "t_ok" }),
|
||||
});
|
||||
assert.equal(ok.status, 200);
|
||||
assert.deepEqual(await ok.json(), { ok: true });
|
||||
});
|
||||
140
tests/unit/conductor-hub-proxy.test.ts
Normal file
140
tests/unit/conductor-hub-proxy.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
getFleetSnapshot,
|
||||
getConductorTaskDetail,
|
||||
cancelConductorTask,
|
||||
} from "../../src/lib/conductor/hubProxy.ts";
|
||||
|
||||
const RUNNERS = [
|
||||
{
|
||||
id: "r_1",
|
||||
token: "NUNCA-VAZAR",
|
||||
online: true,
|
||||
draining: false,
|
||||
capabilities: { name: "devbox", clis: [{ profile: "claude" }, { profile: "codex" }], skills: [] },
|
||||
},
|
||||
];
|
||||
|
||||
const TASKS = [
|
||||
{
|
||||
id: "t_1",
|
||||
status: "completed",
|
||||
mode: "solo",
|
||||
from: "orchestrator",
|
||||
repo: { url: "https://git.x/repo", base_ref: "main" },
|
||||
spec: { prompt: "faz algo" },
|
||||
assigned_runner: "r_1",
|
||||
manifest: { summary: "feito", branch: "task/t_1", error: null },
|
||||
council: null,
|
||||
created_at: "2026-07-22T00:00:00Z",
|
||||
updated_at: "2026-07-22T00:01:00Z",
|
||||
},
|
||||
{
|
||||
id: "t_2",
|
||||
status: "working",
|
||||
mode: "council-3",
|
||||
from: "orchestrator",
|
||||
repo: { url: "https://git.x/repo", base_ref: "main" },
|
||||
spec: { prompt: "outra" },
|
||||
assigned_runner: null,
|
||||
manifest: null,
|
||||
council: { candidate_task_ids: ["t_2a", "t_2b"] },
|
||||
created_at: "2026-07-22T00:02:00Z",
|
||||
updated_at: "2026-07-22T00:02:30Z",
|
||||
},
|
||||
];
|
||||
|
||||
function fakeHub(routes: Record<string, { status: number; body: unknown }>) {
|
||||
const calls: { url: string; method: string; auth: string | null }[] = [];
|
||||
const impl = (async (url: string | URL | Request, init?: RequestInit) => {
|
||||
const u = String(url);
|
||||
calls.push({
|
||||
url: u,
|
||||
method: init?.method ?? "GET",
|
||||
auth: (init?.headers as Record<string, string> | undefined)?.authorization ?? null,
|
||||
});
|
||||
const hit = Object.entries(routes).find(([path]) => u.includes(path));
|
||||
if (!hit) return new Response("{}", { status: 404 });
|
||||
return new Response(JSON.stringify(hit[1].body), { status: hit[1].status });
|
||||
}) as typeof fetch;
|
||||
return { impl, calls };
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
process.env.CONDUCTOR_HUB_URL = "http://hub.test:7910";
|
||||
process.env.CONDUCTOR_HUB_TOKEN = "tok-secreto";
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
delete process.env.CONDUCTOR_HUB_URL;
|
||||
delete process.env.CONDUCTOR_HUB_TOKEN;
|
||||
});
|
||||
|
||||
test("snapshot: runners e tasks sanitizados (whitelist — token do runner NUNCA passa)", async () => {
|
||||
const { impl, calls } = fakeHub({
|
||||
"/v1/runners": { status: 200, body: RUNNERS },
|
||||
"/v1/tasks": { status: 200, body: TASKS },
|
||||
});
|
||||
const snap = await getFleetSnapshot({ fetchImpl: impl });
|
||||
assert.equal(snap.offline, false);
|
||||
assert.deepEqual(snap.runners, [
|
||||
{ id: "r_1", name: "devbox", clis: ["claude", "codex"], online: true, draining: false },
|
||||
]);
|
||||
assert.equal(snap.tasks.length, 2);
|
||||
assert.deepEqual(snap.tasks[0], {
|
||||
id: "t_1",
|
||||
status: "completed",
|
||||
mode: "solo",
|
||||
repo: "https://git.x/repo",
|
||||
runner: "r_1",
|
||||
summary: "feito",
|
||||
branch: "task/t_1",
|
||||
error: null,
|
||||
updated_at: "2026-07-22T00:01:00Z",
|
||||
});
|
||||
assert.ok(!JSON.stringify(snap).includes("NUNCA-VAZAR"), "token de runner não vaza");
|
||||
assert.ok(!JSON.stringify(snap).includes("tok-secreto"), "token do hub não vaza");
|
||||
assert.equal(calls.every((c) => c.auth === "Bearer tok-secreto"), true, "proxy autentica no hub");
|
||||
});
|
||||
|
||||
test("snapshot: hub fora do ar → degradado {offline:true} sem lançar", async () => {
|
||||
const failing = (async () => {
|
||||
throw new Error("ECONNREFUSED");
|
||||
}) as unknown as typeof fetch;
|
||||
const snap = await getFleetSnapshot({ fetchImpl: failing });
|
||||
assert.deepEqual(snap, { offline: true, runners: [], tasks: [] });
|
||||
});
|
||||
|
||||
test("snapshot: env ausente → degradado sem fetch", async () => {
|
||||
delete process.env.CONDUCTOR_HUB_URL;
|
||||
const { impl, calls } = fakeHub({});
|
||||
const snap = await getFleetSnapshot({ fetchImpl: impl });
|
||||
assert.equal(snap.offline, true);
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
test("detalhe: manifest e council passam; spec.prompt vem; campos fora da whitelist não", async () => {
|
||||
const { impl } = fakeHub({ "/v1/tasks/t_2": { status: 200, body: TASKS[1] } });
|
||||
const detail = await getConductorTaskDetail("t_2", { fetchImpl: impl });
|
||||
assert.ok(detail);
|
||||
assert.equal(detail!.id, "t_2");
|
||||
assert.equal(detail!.prompt, "outra");
|
||||
assert.deepEqual(detail!.council, { candidate_task_ids: ["t_2a", "t_2b"] });
|
||||
assert.equal(detail!.base_ref, "main");
|
||||
});
|
||||
|
||||
test("detalhe: 404 do hub → null", async () => {
|
||||
const { impl } = fakeHub({});
|
||||
assert.equal(await getConductorTaskDetail("t_x", { fetchImpl: impl }), null);
|
||||
});
|
||||
|
||||
test("cancelar: POST no hub e repassa o status", async () => {
|
||||
const { impl, calls } = fakeHub({ "/v1/tasks/t_1/cancel": { status: 200, body: { ok: true } } });
|
||||
const r = await cancelConductorTask("t_1", { fetchImpl: impl });
|
||||
assert.deepEqual(r, { ok: true, status: 200 });
|
||||
assert.equal(calls[0].method, "POST");
|
||||
const miss = await cancelConductorTask("t_zzz", { fetchImpl: fakeHub({}).impl });
|
||||
assert.deepEqual(miss, { ok: false, status: 404 });
|
||||
});
|
||||
35
tests/unit/conductor-panel-client.test.ts
Normal file
35
tests/unit/conductor-panel-client.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
// Componentes React são cobertos pelo vitest-ui (advisory) + dashboard-typecheck;
|
||||
// este source-scan trava os invariantes de segurança/UX que revisão nenhuma pode perder.
|
||||
const CLIENT = "src/app/(dashboard)/dashboard/conductor/ConductorPageClient.tsx";
|
||||
const PAGE = "src/app/(dashboard)/dashboard/conductor/page.tsx";
|
||||
|
||||
test("client: poll com setInterval + clearInterval; fala SÓ com /api/conductor (nunca com o hub)", () => {
|
||||
const src = fs.readFileSync(path.join(process.cwd(), CLIENT), "utf8");
|
||||
assert.match(src, /"use client"/);
|
||||
assert.match(src, /setInterval\(/);
|
||||
assert.match(src, /clearInterval\(/);
|
||||
assert.match(src, /\/api\/conductor\/fleet/);
|
||||
assert.ok(!src.includes("CONDUCTOR_HUB"), "nenhuma env do hub no client");
|
||||
assert.ok(!src.includes(":7910"), "nenhum endereço de hub hardcoded no client");
|
||||
});
|
||||
|
||||
test("client: cancelar é destrutivo → ConfirmModal antes do POST", () => {
|
||||
const src = fs.readFileSync(path.join(process.cwd(), CLIENT), "utf8");
|
||||
assert.match(src, /ConfirmModal/);
|
||||
const confirmAt = src.indexOf("<ConfirmModal");
|
||||
const cancelPost = src.indexOf("/cancel");
|
||||
assert.ok(confirmAt > 0 && cancelPost > 0, "ConfirmModal e POST cancel presentes");
|
||||
assert.match(src, /useTranslations\("conductor"\)/, "strings via i18n, namespace conductor");
|
||||
});
|
||||
|
||||
test("page: wrapper fino de servidor com metadata (padrão relay)", () => {
|
||||
const src = fs.readFileSync(path.join(process.cwd(), PAGE), "utf8");
|
||||
assert.match(src, /export const metadata/);
|
||||
assert.match(src, /ConductorPageClient/);
|
||||
assert.ok(!src.includes('"use client"'), "page.tsx é server component fino");
|
||||
});
|
||||
23
tests/unit/conductor-routes-auth.test.ts
Normal file
23
tests/unit/conductor-routes-auth.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
// Padrão budget-route-auth: prova pelo fonte que o gate de auth vem ANTES de qualquer uso do proxy.
|
||||
const ROUTES = [
|
||||
"src/app/api/conductor/fleet/route.ts",
|
||||
"src/app/api/conductor/tasks/[id]/route.ts",
|
||||
"src/app/api/conductor/tasks/[id]/cancel/route.ts",
|
||||
];
|
||||
|
||||
for (const route of ROUTES) {
|
||||
test(`${route}: requireManagementAuth antes do proxy ao hub`, () => {
|
||||
const src = fs.readFileSync(path.join(process.cwd(), route), "utf8");
|
||||
const authAt = src.indexOf("requireManagementAuth(");
|
||||
assert.ok(authAt > 0, "handler chama requireManagementAuth");
|
||||
assert.match(src, /if \(authError\) return authError;/, "curto-circuito no erro de auth");
|
||||
const proxyAt = src.search(/getFleetSnapshot\(|getConductorTaskDetail\(|cancelConductorTask\(/);
|
||||
assert.ok(proxyAt > authAt, "proxy ao hub só depois do gate de auth");
|
||||
assert.ok(!src.includes("CONDUCTOR_HUB_TOKEN"), "token nunca manuseado na rota (vive no hubProxy)");
|
||||
});
|
||||
}
|
||||
@@ -65,6 +65,7 @@ test("primary sidebar items place limits after cache", () => {
|
||||
"cli-agents",
|
||||
"acp-agents",
|
||||
"cloud-agents",
|
||||
"conductor",
|
||||
"agent-bridge",
|
||||
"traffic-inspector",
|
||||
"discovery",
|
||||
|
||||
Reference in New Issue
Block a user