From 3db785dc413f95ccc923fd4de545730bdbbded84 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Wed, 12 Aug 2026 01:53:14 -0300 Subject: [PATCH] =?UTF-8?q?feat(dashboard):=20Conductor=20panel=20?= =?UTF-8?q?=E2=80=94=20fleet,=20tasks=20and=20cancel=20over=20server-side?= =?UTF-8?q?=20proxy=20(PRD=20RF3)=20(#8221)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- changelog.d/features/conductor-panel.md | 1 + .../conductor/ConductorPageClient.tsx | 233 ++++++++++++++++++ .../(dashboard)/dashboard/conductor/page.tsx | 10 + src/app/api/conductor/fleet/route.ts | 17 ++ .../api/conductor/tasks/[id]/cancel/route.ts | 26 ++ src/app/api/conductor/tasks/[id]/route.ts | 22 ++ src/i18n/messages/en.json | 33 +++ src/i18n/messages/pt-BR.json | 33 +++ src/lib/conductor/hubProxy.ts | 176 +++++++++++++ .../constants/sidebarVisibility/sections.ts | 9 + .../constants/sidebarVisibility/types.ts | 1 + tests/unit/conductor-fleet-route.test.ts | 103 ++++++++ tests/unit/conductor-hub-proxy.test.ts | 140 +++++++++++ tests/unit/conductor-panel-client.test.ts | 35 +++ tests/unit/conductor-routes-auth.test.ts | 23 ++ tests/unit/sidebar-visibility.test.ts | 1 + 16 files changed, 863 insertions(+) create mode 100644 changelog.d/features/conductor-panel.md create mode 100644 src/app/(dashboard)/dashboard/conductor/ConductorPageClient.tsx create mode 100644 src/app/(dashboard)/dashboard/conductor/page.tsx create mode 100644 src/app/api/conductor/fleet/route.ts create mode 100644 src/app/api/conductor/tasks/[id]/cancel/route.ts create mode 100644 src/app/api/conductor/tasks/[id]/route.ts create mode 100644 src/lib/conductor/hubProxy.ts create mode 100644 tests/unit/conductor-fleet-route.test.ts create mode 100644 tests/unit/conductor-hub-proxy.test.ts create mode 100644 tests/unit/conductor-panel-client.test.ts create mode 100644 tests/unit/conductor-routes-auth.test.ts diff --git a/changelog.d/features/conductor-panel.md b/changelog.d/features/conductor-panel.md new file mode 100644 index 0000000000..ca6366a7cf --- /dev/null +++ b/changelog.d/features/conductor-panel.md @@ -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 diff --git a/src/app/(dashboard)/dashboard/conductor/ConductorPageClient.tsx b/src/app/(dashboard)/dashboard/conductor/ConductorPageClient.tsx new file mode 100644 index 0000000000..1bf1899bb6 --- /dev/null +++ b/src/app/(dashboard)/dashboard/conductor/ConductorPageClient.tsx @@ -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(null); + const [detail, setDetail] = useState(null); + const [cancelTarget, setCancelTarget] = useState(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 ( +
+
+

{t("title")}

+

{t("subtitle")}

+
+ + {err && {err}} + + {snapshot?.offline ? ( + + + + ) : ( + <> + + ({ ...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 {r.name}; + if (column.key === "clis") return r.clis.join(" / "); + if (r.draining) return {t("draining")}; + return r.online ? ( + {t("online")} + ) : ( + {t("offline")} + ); + }} + /> + + + + ({ ...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 {task.status}; + if (column.key === "id") return {task.id}; + if (column.key === "summary") return task.summary ?? task.error ?? "—"; + return (task as unknown as Record)[column.key]?.toString() ?? "—"; + }} + /> + + + )} + + setDetail(null)} title={t("detailTitle")} size="lg"> + {detail && ( +
+
+ {detail.id} + {detail.status} + {detail.mode} + {detail.runner && {detail.runner}} +
+ {detail.prompt && ( +
+
{t("prompt")}
+
{detail.prompt}
+
+ )} + {detail.summary &&

{detail.summary}

} + {detail.error && {detail.error}} + {detail.branch && ( +
+
{t("branch")}
+ {detail.branch} +

{t("fetchHint", { branch: detail.branch })}

+
+ )} + {detail.mode.startsWith("council") && detail.council?.candidate_task_ids && ( +
+
{t("council")}
+

+ {t("candidates")}: {detail.council.candidate_task_ids.join(", ")} +

+
+ )} + {!TERMINAL.has(detail.status) && ( + + )} +
+ )} +
+ + setCancelTarget(null)} + onConfirm={confirmCancel} + title={t("cancelConfirmTitle")} + message={t("cancelConfirmMessage")} + confirmText={t("cancel")} + loading={canceling} + /> +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/conductor/page.tsx b/src/app/(dashboard)/dashboard/conductor/page.tsx new file mode 100644 index 0000000000..ab28f3d8d9 --- /dev/null +++ b/src/app/(dashboard)/dashboard/conductor/page.tsx @@ -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 ; +} diff --git a/src/app/api/conductor/fleet/route.ts b/src/app/api/conductor/fleet/route.ts new file mode 100644 index 0000000000..d79abd5ad6 --- /dev/null +++ b/src/app/api/conductor/fleet/route.ts @@ -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()); +} diff --git a/src/app/api/conductor/tasks/[id]/cancel/route.ts b/src/app/api/conductor/tasks/[id]/cancel/route.ts new file mode 100644 index 0000000000..5440a7ebf4 --- /dev/null +++ b/src/app/api/conductor/tasks/[id]/cancel/route.ts @@ -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 }); +} diff --git a/src/app/api/conductor/tasks/[id]/route.ts b/src/app/api/conductor/tasks/[id]/route.ts new file mode 100644 index 0000000000..379b78989f --- /dev/null +++ b/src/app/api/conductor/tasks/[id]/route.ts @@ -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); +} diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 58a9874c27..47bb050a3b 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -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" } } diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index b3759a302f..52820e2ee3 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -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" } } diff --git a/src/lib/conductor/hubProxy.ts b/src/lib/conductor/hubProxy.ts new file mode 100644 index 0000000000..c8f3217ed5 --- /dev/null +++ b/src/lib/conductor/hubProxy.ts @@ -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 { + 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): 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 { + 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 { + 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 }; + } +} diff --git a/src/shared/constants/sidebarVisibility/sections.ts b/src/shared/constants/sidebarVisibility/sections.ts index 34488bf941..9056104dbd 100644 --- a/src/shared/constants/sidebarVisibility/sections.ts +++ b/src/shared/constants/sidebarVisibility/sections.ts @@ -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", diff --git a/src/shared/constants/sidebarVisibility/types.ts b/src/shared/constants/sidebarVisibility/types.ts index 4649e1c915..21fcf67984 100644 --- a/src/shared/constants/sidebarVisibility/types.ts +++ b/src/shared/constants/sidebarVisibility/types.ts @@ -29,6 +29,7 @@ export const HIDEABLE_SIDEBAR_ITEM_IDS = [ "cli-agents", "acp-agents", "cloud-agents", + "conductor", "agent-bridge", "traffic-inspector", "discovery", diff --git a/tests/unit/conductor-fleet-route.test.ts b/tests/unit/conductor-fleet-route.test.ts new file mode 100644 index 0000000000..4fb4b88a2b --- /dev/null +++ b/tests/unit/conductor-fleet-route.test.ts @@ -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): Promise { + 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 }); +}); diff --git a/tests/unit/conductor-hub-proxy.test.ts b/tests/unit/conductor-hub-proxy.test.ts new file mode 100644 index 0000000000..3822ebfedb --- /dev/null +++ b/tests/unit/conductor-hub-proxy.test.ts @@ -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) { + 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 | 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 }); +}); diff --git a/tests/unit/conductor-panel-client.test.ts b/tests/unit/conductor-panel-client.test.ts new file mode 100644 index 0000000000..ddc382cf2a --- /dev/null +++ b/tests/unit/conductor-panel-client.test.ts @@ -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(" 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"); +}); diff --git a/tests/unit/conductor-routes-auth.test.ts b/tests/unit/conductor-routes-auth.test.ts new file mode 100644 index 0000000000..8c35f1f5a6 --- /dev/null +++ b/tests/unit/conductor-routes-auth.test.ts @@ -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)"); + }); +} diff --git a/tests/unit/sidebar-visibility.test.ts b/tests/unit/sidebar-visibility.test.ts index 61e5b01f8e..49332f9568 100644 --- a/tests/unit/sidebar-visibility.test.ts +++ b/tests/unit/sidebar-visibility.test.ts @@ -65,6 +65,7 @@ test("primary sidebar items place limits after cache", () => { "cli-agents", "acp-agents", "cloud-agents", + "conductor", "agent-bridge", "traffic-inspector", "discovery",