mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
Compare commits
15 Commits
feat/i18n-
...
feat/condu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f87a82b092 | ||
|
|
4b2bd2e37f | ||
|
|
9813f35ff0 | ||
|
|
54c69cf48d | ||
|
|
12b957a2df | ||
|
|
5d3c988715 | ||
|
|
af97f7fc31 | ||
|
|
4d6c855258 | ||
|
|
7b951761fc | ||
|
|
b97318d73b | ||
|
|
a75295f359 | ||
|
|
00e15af622 | ||
|
|
5eb10e896d | ||
|
|
33baf62b58 | ||
|
|
d42a58141b |
15
.env.example
15
.env.example
@@ -2222,3 +2222,18 @@ QUOTA_STORE_DRIVER=sqlite # sqlite | redis
|
||||
# PROMPTQL_CREDITS_ENDPOINT=https://data.pro.ql.app/v1/graphql
|
||||
# PROMPTQL_TOKEN_REFRESH_URL=https://auth.pro.ql.app/ddn/project/token
|
||||
# PROMPTQL_POLL_TIMEOUT_MS=180000
|
||||
|
||||
# ── OmniConductor bridge (Conductor PRD RF1) ──────────────────────────────────
|
||||
# Mirrors the OmniConductor hub's tasks into the local A2A TaskManager via SSE.
|
||||
# Opt-in: the bridge only starts when CONDUCTOR_HUB_URL is set.
|
||||
# Token: emit a `spokesperson`-kind credential on the hub (POST /v1/peers, admin) —
|
||||
# server-side only, never exposed to the browser.
|
||||
# Used by: src/lib/conductor/boot.ts, src/lib/conductor/bridge.ts
|
||||
# CONDUCTOR_HUB_URL=http://127.0.0.1:7910
|
||||
# CONDUCTOR_HUB_TOKEN=
|
||||
# Faro (OmniConductor spokesperson) — chat/voice proxy for the Conductor panel.
|
||||
# Used by: src/lib/conductor/faroProxy.ts
|
||||
# CONDUCTOR_SPOKESPERSON_URL=http://127.0.0.1:7920
|
||||
# Optional dedicated credential for inbound A2A delegation (falls back to CONDUCTOR_HUB_TOKEN).
|
||||
# Used by: src/lib/conductor/hubProxy.ts
|
||||
# CONDUCTOR_ORCHESTRATOR_TOKEN=
|
||||
|
||||
1
changelog.d/features/conductor-a2a-in.md
Normal file
1
changelog.d/features/conductor-a2a-in.md
Normal file
@@ -0,0 +1 @@
|
||||
- feat(a2a): inbound delegation to the OmniConductor fleet — `POST /api/a2a/tasks` translates an external A2A task into the hub's `POST /v1/tasks` (fleet skills only, repo required, `CONDUCTOR_ORCHESTRATOR_TOKEN` with hub-token fallback); states flow back through the SSE→A2A mirror
|
||||
1
changelog.d/features/conductor-agent-card.md
Normal file
1
changelog.d/features/conductor-agent-card.md
Normal file
@@ -0,0 +1 @@
|
||||
- feat(a2a): the Agent Card (`/.well-known/agent.json`) now announces skills derived from the OmniConductor fleet (`GET /v1/runners` OASF capabilities — one skill per online CLI profile + declared fleet skills), cached ~60s and fail-open when the hub is unset/offline
|
||||
1
changelog.d/features/conductor-bridge.md
Normal file
1
changelog.d/features/conductor-bridge.md
Normal file
@@ -0,0 +1 @@
|
||||
- feat(a2a): Conductor bridge — long-lived SSE consumer that mirrors OmniConductor hub tasks into the A2A TaskManager (explicit `canceled→cancelled` mapping with tests, persisted `last_event_id` cursor in the `key_value` table, exponential-backoff reconnection; opt-in via `CONDUCTOR_HUB_URL`/`CONDUCTOR_HUB_TOKEN`)
|
||||
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
|
||||
1
changelog.d/features/conductor-voice.md
Normal file
1
changelog.d/features/conductor-voice.md
Normal file
@@ -0,0 +1 @@
|
||||
- feat(dashboard): Faro chat with voice on the Conductor panel — text via `/api/conductor/ask` (server-side proxy to the spokesperson; hub credential never reaches the browser; `pending` → Sim/Não confirmation buttons) and a guaranteed push-to-talk voice cycle (MediaRecorder → `/api/v1/audio/transcriptions` → ask → `/api/v1/audio/speech` playback), with operator-configurable STT/TTS models
|
||||
@@ -178,6 +178,9 @@ The JSON-RPC endpoint `/a2a` is the canonical A2A entry point. The REST endpoint
|
||||
| `/api/a2a/tasks/[id]` | GET | Get task by ID | management |
|
||||
| `/api/a2a/tasks/[id]/cancel` | POST | Cancel running task | management |
|
||||
| `/.well-known/agent.json` | GET | Agent Card (A2A discovery) | (public, cached 3600s) |
|
||||
| `/api/a2a/tasks` | POST | Inbound delegation to the OmniConductor fleet (Conductor PRD RF5) | Bearer vs `OMNIROUTE_API_KEY` + `a2aEnabled` |
|
||||
|
||||
**Inbound Conductor delegation (`POST /api/a2a/tasks`):** external A2A agents delegate coding work to the OmniConductor fleet through OmniRoute. Body: `{ skill: "conductor" | "conductor-cli-<profile>", messages: [{role, content}], metadata: { conductor: { repo: { url, base_ref? }, mode?, cli?, model? } } }` — only Conductor fleet skills (the ones announced on the Agent Card) are delegable; `metadata.conductor.repo.url` is required (the fleet works on git repos). The route translates to the hub's `POST /v1/tasks` using the server-side `CONDUCTOR_ORCHESTRATOR_TOKEN` (fallback `CONDUCTOR_HUB_TOKEN`) and returns `201 { conductor_task_id, state: "submitted" }`; task states flow back through the SSE→A2A mirror (RF1) and are visible via `GET /api/a2a/tasks?skill=conductor`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1232,3 +1232,16 @@ Not required for normal operation — developer tooling only.
|
||||
| Variable | Default | Source File | Description |
|
||||
| ---------------------------- | ------------ | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `OMNIROUTE_EVAL_CREDENTIALS` | `{}` (empty) | `scripts/compression-eval/index.ts` | Operator-supplied JSON credentials for the provider exercised by the offline compression-eval CLI (parsed with `JSON.parse`). Leave unset for a dry run. |
|
||||
|
||||
---
|
||||
|
||||
## OmniConductor Bridge
|
||||
|
||||
Long-lived SSE consumer that mirrors OmniConductor hub tasks into the local A2A TaskManager (`src/lib/conductor/`). Opt-in — the bridge only starts when `CONDUCTOR_HUB_URL` is set. Server-side only: the hub token must never reach the browser.
|
||||
|
||||
| Variable | Default | Source File | Description |
|
||||
| --------------------- | ---------- | ---------------------------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| `CONDUCTOR_HUB_URL` | _(empty)_ | `src/lib/conductor/boot.ts` | Base URL of the OmniConductor hub (e.g. `http://127.0.0.1:7910`). Unset = bridge disabled. |
|
||||
| `CONDUCTOR_HUB_TOKEN` | _(empty)_ | `src/lib/conductor/boot.ts` | Hub credential for the SSE feed — emit a `spokesperson`-kind peer on the hub (`POST /v1/peers`, admin). |
|
||||
| `CONDUCTOR_SPOKESPERSON_URL` | `http://127.0.0.1:7920` | `src/lib/conductor/faroProxy.ts` | Base URL of Faro (the Conductor spokesperson) for the dashboard chat/voice proxy. |
|
||||
| `CONDUCTOR_ORCHESTRATOR_TOKEN` | _(empty)_ | `src/lib/conductor/hubProxy.ts` | Hub credential used for INBOUND A2A delegation (`POST /api/a2a/tasks` → hub `POST /v1/tasks`). Emit an `orchestrator`-kind peer; falls back to `CONDUCTOR_HUB_TOKEN`. |
|
||||
|
||||
237
src/app/(dashboard)/dashboard/conductor/ConductorPageClient.tsx
Normal file
237
src/app/(dashboard)/dashboard/conductor/ConductorPageClient.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
"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";
|
||||
|
||||
import FaroChat from "./FaroChat";
|
||||
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
|
||||
<FaroChat />
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
272
src/app/(dashboard)/dashboard/conductor/FaroChat.tsx
Normal file
272
src/app/(dashboard)/dashboard/conductor/FaroChat.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Faro chat with voice (Conductor PRD RF4). Text: input → /api/conductor/ask
|
||||
* (server-side proxy — the hub credential never reaches the browser). When the
|
||||
* answer carries `pending`, Faro is asking for confirmation: the Sim/Não
|
||||
* buttons just send "sim"/"não" — the safety gate lives in Faro's engine.
|
||||
*
|
||||
* Voice (guaranteed cycle, PRD RF4): push-to-talk → MediaRecorder →
|
||||
* POST /api/v1/audio/transcriptions (multipart) → text → /ask → response →
|
||||
* POST /api/v1/audio/speech → play the returned audio blob. STT/TTS models are
|
||||
* operator-configurable (provider/model of THIS OmniRoute install), persisted
|
||||
* in localStorage.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { Badge, Card } from "@/shared/components";
|
||||
|
||||
interface ChatMessage {
|
||||
role: "user" | "faro";
|
||||
text: string;
|
||||
}
|
||||
|
||||
type VoiceState = "idle" | "listening" | "thinking" | "speaking";
|
||||
|
||||
const STT_KEY = "conductor.sttModel";
|
||||
const TTS_KEY = "conductor.ttsModel";
|
||||
|
||||
function safeGet(key: string, fallback: string): string {
|
||||
try {
|
||||
return localStorage.getItem(key) || fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
async function errorMessageOf(res: Response, fallback: string): Promise<string> {
|
||||
try {
|
||||
const data = await res.json();
|
||||
return data?.error?.message ?? fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export default function FaroChat() {
|
||||
const t = useTranslations("conductor");
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [voice, setVoice] = useState<VoiceState>("idle");
|
||||
const [speak, setSpeak] = useState(false);
|
||||
const [sttModel, setSttModel] = useState("openai/whisper-1");
|
||||
const [ttsModel, setTtsModel] = useState("openai/tts-1");
|
||||
const [err, setErr] = useState("");
|
||||
const recorderRef = useRef<MediaRecorder | null>(null);
|
||||
const logRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setSttModel(safeGet(STT_KEY, "openai/whisper-1"));
|
||||
setTtsModel(safeGet(TTS_KEY, "openai/tts-1"));
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
logRef.current?.scrollTo({ top: logRef.current.scrollHeight });
|
||||
}, [messages]);
|
||||
|
||||
const persistModels = (stt: string, tts: string) => {
|
||||
setSttModel(stt);
|
||||
setTtsModel(tts);
|
||||
try {
|
||||
localStorage.setItem(STT_KEY, stt);
|
||||
localStorage.setItem(TTS_KEY, tts);
|
||||
} catch {
|
||||
// modo privado: segue só em memória
|
||||
}
|
||||
};
|
||||
|
||||
const playAnswer = async (text: string) => {
|
||||
setVoice("speaking");
|
||||
try {
|
||||
const res = await fetch("/api/v1/audio/speech", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model: ttsModel, input: text }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
setErr(await errorMessageOf(res, t("ttsFailed")));
|
||||
return;
|
||||
}
|
||||
const url = URL.createObjectURL(await res.blob());
|
||||
const audio = new Audio(url);
|
||||
await audio.play().catch(() => undefined);
|
||||
audio.onended = () => URL.revokeObjectURL(url);
|
||||
} finally {
|
||||
setVoice("idle");
|
||||
}
|
||||
};
|
||||
|
||||
const send = async (message: string, viaVoice = false) => {
|
||||
const clean = message.trim();
|
||||
if (!clean || busy) return;
|
||||
setErr("");
|
||||
setBusy(true);
|
||||
setVoice("thinking");
|
||||
setMessages((m) => [...m, { role: "user", text: clean }]);
|
||||
setInput("");
|
||||
try {
|
||||
const res = await fetch("/api/conductor/ask", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message: clean }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
setErr(await errorMessageOf(res, t("faroOffline")));
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
setMessages((m) => [...m, { role: "faro", text: data.text }]);
|
||||
setPending(Boolean(data.pending));
|
||||
if (viaVoice && speak && data.text) await playAnswer(data.text);
|
||||
} catch {
|
||||
setErr(t("faroOffline"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setVoice((v) => (v === "thinking" ? "idle" : v));
|
||||
}
|
||||
};
|
||||
|
||||
const startRecording = async () => {
|
||||
setErr("");
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const recorder = new MediaRecorder(stream);
|
||||
const chunks: Blob[] = [];
|
||||
recorder.ondataavailable = (e) => e.data.size > 0 && chunks.push(e.data);
|
||||
recorder.onstop = async () => {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
setVoice("thinking");
|
||||
const blob = new Blob(chunks, { type: recorder.mimeType || "audio/webm" });
|
||||
const form = new FormData();
|
||||
form.append("model", sttModel);
|
||||
form.append("file", new File([blob], "faro-ptt.webm", { type: blob.type }));
|
||||
try {
|
||||
// multipart: sem header manual — o browser define o boundary
|
||||
const res = await fetch("/api/v1/audio/transcriptions", { method: "POST", body: form });
|
||||
if (!res.ok) {
|
||||
setErr(await errorMessageOf(res, t("sttFailed")));
|
||||
setVoice("idle");
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
if (data.text) await send(data.text, true);
|
||||
else setVoice("idle");
|
||||
} catch {
|
||||
setErr(t("sttFailed"));
|
||||
setVoice("idle");
|
||||
}
|
||||
};
|
||||
recorderRef.current = recorder;
|
||||
recorder.start();
|
||||
setVoice("listening");
|
||||
} catch {
|
||||
setErr(t("micDenied"));
|
||||
}
|
||||
};
|
||||
|
||||
const stopRecording = () => {
|
||||
if (recorderRef.current?.state === "recording") recorderRef.current.stop();
|
||||
recorderRef.current = null;
|
||||
};
|
||||
|
||||
const voiceLabel: Record<VoiceState, string> = {
|
||||
idle: t("voiceIdle"),
|
||||
listening: t("voiceListening"),
|
||||
thinking: t("voiceThinking"),
|
||||
speaking: t("voiceSpeaking"),
|
||||
};
|
||||
|
||||
return (
|
||||
<Card title={t("faroTitle")} subtitle={t("faroSubtitle")}>
|
||||
<div className="space-y-3">
|
||||
<div ref={logRef} className="max-h-72 overflow-y-auto space-y-2 text-sm">
|
||||
{messages.length === 0 && <p className="text-text-muted text-xs">{t("faroEmpty")}</p>}
|
||||
{messages.map((m, i) => (
|
||||
<div key={i} className={m.role === "user" ? "text-right" : "text-left"}>
|
||||
<span
|
||||
className={
|
||||
m.role === "user"
|
||||
? "inline-block rounded px-2 py-1 bg-primary/10"
|
||||
: "inline-block rounded px-2 py-1 bg-black/5 dark:bg-white/10 whitespace-pre-wrap"
|
||||
}
|
||||
>
|
||||
{m.text}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{err && <Badge variant="error">{err}</Badge>}
|
||||
{pending && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="warning" dot>{t("faroPending")}</Badge>
|
||||
<button type="button" className="text-sm underline" onClick={() => void send("sim")}>
|
||||
{t("yes")}
|
||||
</button>
|
||||
<button type="button" className="text-sm underline" onClick={() => void send("não")}>
|
||||
{t("no")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
className="flex-1 rounded border border-black/10 dark:border-white/10 bg-transparent px-2 py-1 text-sm"
|
||||
placeholder={t("faroPlaceholder")}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void send(input);
|
||||
}}
|
||||
disabled={busy}
|
||||
/>
|
||||
<button type="button" className="text-sm underline" onClick={() => void send(input)} disabled={busy}>
|
||||
{t("faroSend")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`text-sm px-2 py-1 rounded ${voice === "listening" ? "bg-red-500/20" : "bg-black/5 dark:bg-white/10"}`}
|
||||
title={t("pushToTalk")}
|
||||
aria-pressed={voice === "listening"}
|
||||
onMouseDown={() => void startRecording()}
|
||||
onMouseUp={stopRecording}
|
||||
onMouseLeave={stopRecording}
|
||||
onTouchStart={() => void startRecording()}
|
||||
onTouchEnd={stopRecording}
|
||||
>
|
||||
🎙 {voiceLabel[voice]}
|
||||
</button>
|
||||
<label className="flex items-center gap-1 text-xs text-text-muted">
|
||||
<input type="checkbox" checked={speak} onChange={(e) => setSpeak(e.target.checked)} />
|
||||
{t("speakAnswers")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<details className="text-xs text-text-muted">
|
||||
<summary>{t("voiceModels")}</summary>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<label className="flex-1">
|
||||
STT
|
||||
<input
|
||||
className="w-full rounded border border-black/10 dark:border-white/10 bg-transparent px-2 py-1"
|
||||
value={sttModel}
|
||||
onChange={(e) => persistModels(e.target.value, ttsModel)}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex-1">
|
||||
TTS
|
||||
<input
|
||||
className="w-full rounded border border-black/10 dark:border-white/10 bg-transparent px-2 py-1"
|
||||
value={ttsModel}
|
||||
onChange={(e) => persistModels(sttModel, e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
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 />;
|
||||
}
|
||||
@@ -10,6 +10,8 @@
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { getFleetSkills } from "@/lib/conductor/fleetSkills";
|
||||
|
||||
const PACKAGE_VERSION = process.env.npm_package_version || "1.8.1";
|
||||
const BASE_URL = process.env.OMNIROUTE_BASE_URL || "http://localhost:20128";
|
||||
|
||||
@@ -20,6 +22,9 @@ const BASE_URL = process.env.OMNIROUTE_BASE_URL || "http://localhost:20128";
|
||||
* capabilities as an A2A agent.
|
||||
*/
|
||||
export async function GET() {
|
||||
// Conductor PRD RF2: fleet skills from the OmniConductor hub (cached ~60s; [] when
|
||||
// the hub is unset/offline — the card stays valid without the fleet section).
|
||||
const fleetSkills = await getFleetSkills();
|
||||
const agentCard = {
|
||||
name: "OmniRoute AI Gateway",
|
||||
description:
|
||||
@@ -111,6 +116,7 @@ export async function GET() {
|
||||
tags: ["discovery", "capabilities"],
|
||||
examples: ["What can you do?", "List your skills", "Show capabilities"],
|
||||
},
|
||||
...fleetSkills,
|
||||
],
|
||||
authentication: {
|
||||
schemes: ["api-key"],
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getTaskManager, type TaskState } from "@/lib/a2a/taskManager";
|
||||
import { createConductorTask } from "@/lib/conductor/hubProxy";
|
||||
import { getSettings } from "@/lib/db/settings";
|
||||
|
||||
const VALID_TASK_STATES = new Set<TaskState>([
|
||||
"submitted",
|
||||
@@ -44,3 +48,97 @@ export async function GET(request: Request) {
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// ============ POST — delegação de entrada à frota do Conductor (PRD Conductor RF5) ============
|
||||
|
||||
const delegationSchema = z.object({
|
||||
skill: z.string().default("conductor"),
|
||||
messages: z.array(z.object({ role: z.string(), content: z.string() })).min(1),
|
||||
metadata: z
|
||||
.object({
|
||||
conductor: z
|
||||
.object({
|
||||
repo: z.object({ url: z.string().min(1), base_ref: z.string().optional() }).optional(),
|
||||
mode: z.string().optional(),
|
||||
cli: z.string().optional(),
|
||||
model: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
/** Mesma semântica de auth do JSON-RPC A2A (src/app/a2a/route.ts): Bearer vs OMNIROUTE_API_KEY; aberto se não configurada. */
|
||||
function authenticateA2A(request: Request): boolean {
|
||||
const configuredKey = process.env.OMNIROUTE_API_KEY;
|
||||
if (!configuredKey) return true;
|
||||
const token = (request.headers.get("authorization") || "").replace(/^Bearer\s+/i, "");
|
||||
return token === configuredKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Traduz uma task A2A externa em `POST /v1/tasks` do hub do OmniConductor.
|
||||
* Só skills da frota (`conductor` / `conductor-cli-<profile>` — as anunciadas no
|
||||
* Agent Card) são delegáveis; os estados voltam pelo espelho SSE→A2A (RF1).
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
if (!authenticateA2A(request)) {
|
||||
return NextResponse.json({ error: "Unauthorized: missing or invalid API key" }, { status: 401 });
|
||||
}
|
||||
const settings = await getSettings();
|
||||
if (settings.a2aEnabled !== true) {
|
||||
return NextResponse.json(
|
||||
{ error: "A2A endpoint is disabled. Enable it from the Endpoints page." },
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
const parsed = delegationSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid A2A task: provide messages[] (and metadata.conductor)" }, { status: 400 });
|
||||
}
|
||||
const { skill, messages, metadata } = parsed.data;
|
||||
if (skill !== "conductor" && !skill.startsWith("conductor-cli-")) {
|
||||
return NextResponse.json(
|
||||
{ error: "Only Conductor fleet skills are delegable here (conductor / conductor-cli-<profile>)" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const conductor = metadata?.conductor;
|
||||
if (!conductor?.repo?.url) {
|
||||
return NextResponse.json(
|
||||
{ error: "Delegation requires metadata.conductor.repo.url (the fleet works on git repos)" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const prompt = [...messages].reverse().find((m) => m.role === "user")?.content ?? messages[messages.length - 1].content;
|
||||
|
||||
const created = await createConductorTask({
|
||||
repoUrl: conductor.repo.url,
|
||||
baseRef: conductor.repo.base_ref,
|
||||
prompt,
|
||||
mode: conductor.mode,
|
||||
cli: skill.startsWith("conductor-cli-") ? skill.slice("conductor-cli-".length) : conductor.cli,
|
||||
model: conductor.model,
|
||||
});
|
||||
if (!created.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `Conductor hub refused the delegation (HTTP ${created.status})` },
|
||||
{ status: created.status }
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{
|
||||
conductor_task_id: created.task_id,
|
||||
state: "submitted",
|
||||
note: "States flow back through the SSE→A2A mirror (GET /api/a2a/tasks, skill=conductor).",
|
||||
},
|
||||
{ status: 201 }
|
||||
);
|
||||
}
|
||||
|
||||
37
src/app/api/conductor/ask/route.ts
Normal file
37
src/app/api/conductor/ask/route.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* POST /api/conductor/ask — proxy para o Faro (spokesperson do OmniConductor).
|
||||
* O /ask do Faro exige credencial do hub (server-side); o browser fala só com
|
||||
* esta rota. Resposta whitelisted {text, pending} — quando `pending` vier, a UI
|
||||
* oferece Sim/Não (a trava de confirmação é do motor do Faro; nunca contornada).
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createErrorResponse } from "@/lib/api/errorResponse";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { askFaro } from "@/lib/conductor/faroProxy";
|
||||
|
||||
const askSchema = z.object({ message: z.string().min(1).max(4000) });
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requireManagementAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = await request.json();
|
||||
} catch {
|
||||
return createErrorResponse({ status: 400, message: "Invalid JSON body" });
|
||||
}
|
||||
const parsed = askSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
return createErrorResponse({ status: 400, message: "Body must be { message: string (1-4000 chars) }" });
|
||||
}
|
||||
|
||||
const answer = await askFaro(parsed.data.message);
|
||||
if (!answer.ok) {
|
||||
return createErrorResponse({ status: 503, message: "Faro (spokesperson) is offline or refused the request" });
|
||||
}
|
||||
return NextResponse.json({ text: answer.text, pending: answer.pending });
|
||||
}
|
||||
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);
|
||||
}
|
||||
@@ -1219,7 +1219,9 @@
|
||||
"alwaysVisible": "Always visible",
|
||||
"groupSeparatorLabel": "Separator",
|
||||
"discovery": "Discovery",
|
||||
"discoverySubtitle": "Scan providers for free access"
|
||||
"discoverySubtitle": "Scan providers for free access",
|
||||
"conductor": "Conductor",
|
||||
"conductorSubtitle": "CLI-agent fleet"
|
||||
},
|
||||
"webhooks": {
|
||||
"title": "Webhooks",
|
||||
@@ -12084,5 +12086,55 @@
|
||||
"cta": "Get Kimi Code",
|
||||
"partnerLinkNote": "Partner link",
|
||||
"dismissAriaLabel": "Dismiss"
|
||||
},
|
||||
"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",
|
||||
"faroTitle": "Faro — fleet spokesperson",
|
||||
"faroSubtitle": "Chat and voice, anchored in real fleet events. Destructive commands always ask for confirmation.",
|
||||
"faroEmpty": "Ask about the fleet — e.g. \"how is the fleet?\"",
|
||||
"faroPending": "Faro is asking for confirmation",
|
||||
"faroPlaceholder": "talk to Faro…",
|
||||
"faroSend": "send",
|
||||
"faroOffline": "Faro (spokesperson) is offline",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"pushToTalk": "Hold to talk",
|
||||
"speakAnswers": "speak answers",
|
||||
"voiceModels": "Voice models (provider/model of this OmniRoute)",
|
||||
"voiceIdle": "talk",
|
||||
"voiceListening": "listening…",
|
||||
"voiceThinking": "thinking…",
|
||||
"voiceSpeaking": "speaking…",
|
||||
"sttFailed": "Transcription failed (check the STT model/provider)",
|
||||
"ttsFailed": "Speech synthesis failed (check the TTS model/provider)",
|
||||
"micDenied": "Microphone unavailable or permission denied"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1219,7 +1219,9 @@
|
||||
"alwaysVisible": "__MISSING__:Always visible",
|
||||
"groupSeparatorLabel": "__MISSING__:Separator",
|
||||
"discovery": "Descoberta",
|
||||
"discoverySubtitle": "Buscar acesso gratuito em provedores"
|
||||
"discoverySubtitle": "Buscar acesso gratuito em provedores",
|
||||
"conductor": "Conductor",
|
||||
"conductorSubtitle": "Frota de agentes CLI"
|
||||
},
|
||||
"webhooks": {
|
||||
"title": "Webhooks",
|
||||
@@ -12084,5 +12086,55 @@
|
||||
"cta": "Obter Kimi Code",
|
||||
"partnerLinkNote": "Link de parceria",
|
||||
"dismissAriaLabel": "Dispensar"
|
||||
},
|
||||
"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",
|
||||
"faroTitle": "Faro — porta-voz da frota",
|
||||
"faroSubtitle": "Chat e voz, ancorados nos eventos reais da frota. Comandos destrutivos sempre pedem confirmação.",
|
||||
"faroEmpty": "Pergunte sobre a frota — ex.: \"como está a frota?\"",
|
||||
"faroPending": "O Faro está pedindo confirmação",
|
||||
"faroPlaceholder": "fale com o Faro…",
|
||||
"faroSend": "enviar",
|
||||
"faroOffline": "Faro (porta-voz) fora do ar",
|
||||
"yes": "Sim",
|
||||
"no": "Não",
|
||||
"pushToTalk": "Segure para falar",
|
||||
"speakAnswers": "falar as respostas",
|
||||
"voiceModels": "Modelos de voz (provider/model deste OmniRoute)",
|
||||
"voiceIdle": "falar",
|
||||
"voiceListening": "ouvindo…",
|
||||
"voiceThinking": "pensando…",
|
||||
"voiceSpeaking": "falando…",
|
||||
"sttFailed": "Transcrição falhou (confira modelo/provider de STT)",
|
||||
"ttsFailed": "Síntese de voz falhou (confira modelo/provider de TTS)",
|
||||
"micDenied": "Microfone indisponível ou permissão negada"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -475,6 +475,15 @@ export async function registerNodejs(): Promise<void> {
|
||||
console.warn("[STARTUP] Auto-refresh daemon failed to start (non-fatal):", msg);
|
||||
}),
|
||||
|
||||
// Conductor bridge (PRD Conductor RF1): mirrors OmniConductor hub tasks into the
|
||||
// A2A TaskManager via the hub SSE. Opt-in — self-gated on CONDUCTOR_HUB_URL.
|
||||
import("@/lib/conductor/boot").then((m) => {
|
||||
if (m.initConductorBridge()) console.log("[STARTUP] Conductor bridge started");
|
||||
}).catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.warn("[STARTUP] Conductor bridge failed to start (non-fatal):", msg);
|
||||
}),
|
||||
|
||||
// Proactive connection-cooldown recovery (#8): re-validate connections whose
|
||||
// transient `rate_limited_until` window has elapsed OUTSIDE the request hot path,
|
||||
// so the first request after a cooldown does not pay the probe latency.
|
||||
|
||||
36
src/lib/conductor/boot.ts
Normal file
36
src/lib/conductor/boot.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Conductor bridge boot — production wiring for `createConductorBridge`.
|
||||
*
|
||||
* Opt-in: only starts when `CONDUCTOR_HUB_URL` is set. Called from
|
||||
* `instrumentation-node.ts` (same non-fatal pattern as the other daemons).
|
||||
* No graceful-shutdown registration needed: the cursor is persisted after each
|
||||
* event, so an abrupt exit loses nothing — the hub replay converges the mirror.
|
||||
*/
|
||||
|
||||
import { getTaskManager } from "@/lib/a2a/taskManager";
|
||||
import { getConductorCursor, setConductorCursor } from "@/lib/db/conductorBridge";
|
||||
|
||||
import { createConductorBridge, type ConductorBridge, type ConductorBridgeOptions } from "./bridge";
|
||||
|
||||
let bridge: ConductorBridge | null = null;
|
||||
|
||||
/** Starts the bridge once (idempotent). Returns null when CONDUCTOR_HUB_URL is unset. */
|
||||
export function initConductorBridge(overrides: Partial<ConductorBridgeOptions> = {}): ConductorBridge | null {
|
||||
const hubUrl = process.env.CONDUCTOR_HUB_URL?.trim();
|
||||
if (!hubUrl) return null;
|
||||
if (bridge) return bridge;
|
||||
bridge = createConductorBridge({
|
||||
hubUrl,
|
||||
token: process.env.CONDUCTOR_HUB_TOKEN?.trim() ?? "",
|
||||
tm: getTaskManager(),
|
||||
cursor: { get: getConductorCursor, set: setConductorCursor },
|
||||
...overrides,
|
||||
});
|
||||
bridge.start();
|
||||
return bridge;
|
||||
}
|
||||
|
||||
export function stopConductorBridge(): void {
|
||||
bridge?.stop();
|
||||
bridge = null;
|
||||
}
|
||||
250
src/lib/conductor/bridge.ts
Normal file
250
src/lib/conductor/bridge.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* Conductor Bridge — mirrors OmniConductor hub tasks into the local A2A TaskManager.
|
||||
*
|
||||
* The hub's SSE feed is the source of truth; this mirror is disposable and fully
|
||||
* rebuildable by replay (`last_event_id`). State spelling differs on purpose:
|
||||
* the Conductor follows A2A upstream with `canceled` (1 L) while OmniRoute's
|
||||
* TaskManager uses `cancelled` (2 L) — the mapping here is explicit and tested.
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import type { A2ATaskManager, TaskState } from "@/lib/a2a/taskManager";
|
||||
|
||||
export interface ConductorEvent {
|
||||
id: string;
|
||||
type: string;
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Target A2A state for a Conductor event type; null = not a task-state event (tolerated). */
|
||||
export function conductorStateFor(type: string): TaskState | null {
|
||||
switch (type) {
|
||||
case "task.created":
|
||||
return "submitted";
|
||||
case "task.scheduled":
|
||||
case "task.input_required":
|
||||
return "working";
|
||||
case "task.completed":
|
||||
return "completed";
|
||||
case "task.failed":
|
||||
return "failed";
|
||||
case "task.canceled": // Conductor/A2A upstream spelling (1 L)
|
||||
return "cancelled"; // local TaskManager spelling (2 L)
|
||||
default:
|
||||
return null; // runner.*, council.*, terminal.*, future types — forward-compat
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimal legal path from a task's current state up to the target state. */
|
||||
const LADDER: TaskState[] = ["submitted", "working", "completed"];
|
||||
|
||||
function climb(tm: A2ATaskManager, a2aId: string, target: TaskState): void {
|
||||
const task = tm.getTask(a2aId);
|
||||
if (!task) return;
|
||||
if (task.state === target) return;
|
||||
if (task.state === "completed" || task.state === "failed" || task.state === "cancelled") return; // terminal: late events ignored
|
||||
try {
|
||||
if (target === "completed") {
|
||||
// submitted → working → completed (the manager rejects skips)
|
||||
let idx = LADDER.indexOf(task.state);
|
||||
for (idx = idx + 1; idx < LADDER.length; idx++) tm.updateTask(a2aId, LADDER[idx]);
|
||||
return;
|
||||
}
|
||||
tm.updateTask(a2aId, target); // working/failed/cancelled are reachable from any non-terminal state
|
||||
} catch {
|
||||
// A transition rejected by the manager must never take the bridge down; the
|
||||
// hub replay will converge the mirror on the next connection.
|
||||
}
|
||||
}
|
||||
|
||||
function ensureMirrored(tm: A2ATaskManager, index: Map<string, string>, conductorId: string, payload: Record<string, unknown>): string {
|
||||
const known = index.get(conductorId);
|
||||
if (known && tm.getTask(known)) return known;
|
||||
const mode = typeof payload.mode === "string" ? payload.mode : "?";
|
||||
const task = tm.createTask({
|
||||
skill: "conductor",
|
||||
messages: [{ role: "user", content: `Conductor task ${conductorId} (${mode})` }],
|
||||
metadata: {
|
||||
conductor: {
|
||||
task_id: conductorId,
|
||||
mode,
|
||||
from: typeof payload.from === "string" ? payload.from : undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
index.set(conductorId, task.id);
|
||||
return task.id;
|
||||
}
|
||||
|
||||
function conductorMeta(tm: A2ATaskManager, a2aId: string): Record<string, unknown> | null {
|
||||
const task = tm.getTask(a2aId);
|
||||
if (!task) return null;
|
||||
// The manager has no public metadata mutator; direct mutation is the repo precedent
|
||||
// (tests mutate task fields) and the object lives in the same process.
|
||||
const meta = (task.metadata.conductor ??= {}) as Record<string, unknown>;
|
||||
return meta;
|
||||
}
|
||||
|
||||
/** Applies one hub event to the mirror. Never throws for event-shaped input. */
|
||||
export function applyConductorEvent(tm: A2ATaskManager, index: Map<string, string>, ev: ConductorEvent): void {
|
||||
const target = conductorStateFor(ev.type);
|
||||
if (!target) return;
|
||||
const conductorId = typeof ev.payload.task_id === "string" ? ev.payload.task_id : null;
|
||||
if (!conductorId) return;
|
||||
|
||||
const a2aId = ensureMirrored(tm, index, conductorId, ev.payload);
|
||||
const meta = conductorMeta(tm, a2aId);
|
||||
if (meta) {
|
||||
if (ev.type === "task.scheduled" && typeof ev.payload.runner_id === "string") meta.runner = ev.payload.runner_id;
|
||||
if (ev.type === "task.input_required") meta.input_required = true;
|
||||
if (ev.type === "task.completed" || ev.type === "task.failed") {
|
||||
const manifest = (ev.payload.manifest ?? {}) as Record<string, unknown>;
|
||||
if (typeof manifest.summary === "string") meta.summary = manifest.summary;
|
||||
if (typeof manifest.branch === "string") meta.branch = manifest.branch;
|
||||
if (typeof manifest.error === "string") meta.error = manifest.error;
|
||||
}
|
||||
}
|
||||
if (target !== "submitted") climb(tm, a2aId, target);
|
||||
}
|
||||
|
||||
// ============ Incremental SSE parser ============
|
||||
|
||||
export interface SseFrame {
|
||||
id?: string;
|
||||
event?: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
/** Incremental `text/event-stream` parser: feed chunks, get complete frames. Comments (`: ping`) are dropped. */
|
||||
export class SseParser {
|
||||
private buffer = "";
|
||||
|
||||
push(chunk: string): SseFrame[] {
|
||||
this.buffer += chunk;
|
||||
const frames: SseFrame[] = [];
|
||||
let cut: number;
|
||||
while ((cut = this.buffer.indexOf("\n\n")) !== -1) {
|
||||
const block = this.buffer.slice(0, cut);
|
||||
this.buffer = this.buffer.slice(cut + 2);
|
||||
const frame: SseFrame = { data: "" };
|
||||
const dataLines: string[] = [];
|
||||
for (const line of block.split("\n")) {
|
||||
if (line.startsWith(":")) continue; // comment/keepalive
|
||||
if (line.startsWith("id:")) frame.id = line.slice(3).trim();
|
||||
else if (line.startsWith("event:")) frame.event = line.slice(6).trim();
|
||||
else if (line.startsWith("data:")) dataLines.push(line.slice(5).trimStart());
|
||||
}
|
||||
if (frame.id === undefined && frame.event === undefined && dataLines.length === 0) continue; // pure comment block
|
||||
frame.data = dataLines.join("\n");
|
||||
frames.push(frame);
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Connection loop ============
|
||||
|
||||
/** Wire shape of the hub's SSE `data:` field — {ts, payload}; id/type live in the
|
||||
* SSE frame fields (verified against the live hub, 2026-07-22). Untrusted input → Zod. */
|
||||
const hubDataSchema = z.object({
|
||||
payload: z.record(z.string(), z.unknown()),
|
||||
});
|
||||
|
||||
export interface ConductorBridgeOptions {
|
||||
hubUrl: string;
|
||||
token: string;
|
||||
tm: A2ATaskManager;
|
||||
cursor: { get(): string | null; set(v: string): void };
|
||||
fetchImpl?: typeof fetch;
|
||||
log?: (msg: string) => void;
|
||||
backoffBaseMs?: number;
|
||||
}
|
||||
|
||||
export interface ConductorBridge {
|
||||
start(): void;
|
||||
stop(): void;
|
||||
state(): "connected" | "reconnecting" | "stopped";
|
||||
}
|
||||
|
||||
const BACKOFF_CAP_MS = 30_000;
|
||||
|
||||
/**
|
||||
* Long-lived consumer: connects to the hub SSE with the persisted cursor, mirrors
|
||||
* events, reconnects with exponential backoff. A hub outage never propagates —
|
||||
* errors are logged and retried; `stop()` aborts for good.
|
||||
*/
|
||||
export function createConductorBridge(opts: ConductorBridgeOptions): ConductorBridge {
|
||||
const log = opts.log ?? ((msg: string) => console.log(`[conductor-bridge] ${msg}`));
|
||||
const doFetch = opts.fetchImpl ?? fetch;
|
||||
const backoffBase = opts.backoffBaseMs ?? 1_000;
|
||||
const index = new Map<string, string>();
|
||||
let state: "connected" | "reconnecting" | "stopped" = "stopped";
|
||||
let abort: AbortController | null = null;
|
||||
let attempt = 0;
|
||||
let running = false;
|
||||
|
||||
async function readStream(res: Response): Promise<void> {
|
||||
const reader = res.body?.getReader();
|
||||
if (!reader) throw new Error("SSE response without body");
|
||||
const parser = new SseParser();
|
||||
const decoder = new TextDecoder();
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) return;
|
||||
for (const frame of parser.push(decoder.decode(value, { stream: true }))) {
|
||||
if (!frame.id || !frame.event) continue; // keepalive/partial frame — nada a espelhar
|
||||
let payload: Record<string, unknown>;
|
||||
try {
|
||||
payload = hubDataSchema.parse(JSON.parse(frame.data)).payload;
|
||||
} catch {
|
||||
log(`evento SSE malformado ignorado (id=${frame.id})`);
|
||||
continue;
|
||||
}
|
||||
applyConductorEvent(opts.tm, index, { id: frame.id, type: frame.event, payload });
|
||||
opts.cursor.set(frame.id); // persisted per event — replay covers any gap on reconnect
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loop(): Promise<void> {
|
||||
while (running) {
|
||||
abort = new AbortController();
|
||||
try {
|
||||
const since = opts.cursor.get() ?? "0";
|
||||
const res = await doFetch(`${opts.hubUrl}/v1/events?last_event_id=${encodeURIComponent(since)}`, {
|
||||
headers: { authorization: `Bearer ${opts.token}`, accept: "text/event-stream" },
|
||||
signal: abort.signal,
|
||||
});
|
||||
if (!res.ok) throw new Error(`hub respondeu HTTP ${res.status}`);
|
||||
state = "connected";
|
||||
attempt = 0;
|
||||
log(`conectado ao hub (last_event_id=${since})`);
|
||||
await readStream(res); // resolves when the hub closes the stream
|
||||
throw new Error("stream encerrado pelo hub");
|
||||
} catch (err) {
|
||||
if (!running) break;
|
||||
state = "reconnecting";
|
||||
const wait = Math.min(backoffBase * 2 ** attempt, BACKOFF_CAP_MS);
|
||||
attempt++;
|
||||
log(`conexão caiu (${err instanceof Error ? err.message : String(err)}) — reconectando em ${wait}ms`);
|
||||
await new Promise((resolve) => setTimeout(resolve, wait));
|
||||
}
|
||||
}
|
||||
state = "stopped";
|
||||
}
|
||||
|
||||
return {
|
||||
start() {
|
||||
if (running) return;
|
||||
running = true;
|
||||
void loop();
|
||||
},
|
||||
stop() {
|
||||
running = false;
|
||||
state = "stopped";
|
||||
abort?.abort();
|
||||
},
|
||||
state: () => state,
|
||||
};
|
||||
}
|
||||
46
src/lib/conductor/faroProxy.ts
Normal file
46
src/lib/conductor/faroProxy.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Server-side proxy to Faro, the OmniConductor spokesperson (Conductor PRD RF4).
|
||||
*
|
||||
* Faro's `/ask` requires a valid hub credential (Bearer) — that token lives only
|
||||
* in server env, so the browser talks to our /api/conductor/ask route, never to
|
||||
* Faro directly. The response is whitelisted to {text, pending}: `pending` set
|
||||
* means Faro is asking for confirmation (the UI offers Sim/Não); the safety gate
|
||||
* itself lives in Faro's engine and is never bypassed here.
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
const faroResponseSchema = z.object({
|
||||
text: z.string(),
|
||||
pending: z.unknown().nullish(),
|
||||
});
|
||||
|
||||
export interface FaroAnswer {
|
||||
ok: boolean;
|
||||
text: string;
|
||||
pending: unknown;
|
||||
}
|
||||
|
||||
export interface FaroProxyOptions {
|
||||
fetchImpl?: typeof fetch;
|
||||
}
|
||||
|
||||
const DEFAULT_FARO_URL = "http://127.0.0.1:7920";
|
||||
|
||||
export async function askFaro(message: string, opts: FaroProxyOptions = {}): Promise<FaroAnswer> {
|
||||
const base = process.env.CONDUCTOR_SPOKESPERSON_URL?.trim() || DEFAULT_FARO_URL;
|
||||
const token = process.env.CONDUCTOR_HUB_TOKEN?.trim() ?? "";
|
||||
try {
|
||||
const doFetch = opts.fetchImpl ?? fetch;
|
||||
const res = await doFetch(`${base}/ask`, {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
||||
body: JSON.stringify({ message }),
|
||||
});
|
||||
if (!res.ok) return { ok: false, text: "", pending: null };
|
||||
const parsed = faroResponseSchema.parse(await res.json());
|
||||
return { ok: true, text: parsed.text, pending: parsed.pending ?? null };
|
||||
} catch {
|
||||
return { ok: false, text: "", pending: null };
|
||||
}
|
||||
}
|
||||
106
src/lib/conductor/fleetSkills.ts
Normal file
106
src/lib/conductor/fleetSkills.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Fleet skills for the Agent Card (Conductor PRD RF2) — derives A2A skills from
|
||||
* the OmniConductor hub's runner registry (`GET /v1/runners`, OASF capabilities).
|
||||
*
|
||||
* Fail-open by design: any problem (env unset, hub offline, bad shape) yields
|
||||
* `[]` so the Agent Card stays valid, just without the fleet section. Results
|
||||
* are cached for ~60s to keep the card endpoint cheap.
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
export interface FleetSkill {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
/** Untrusted hub response — validate only what we read. */
|
||||
const runnersSchema = z.array(
|
||||
z.object({
|
||||
online: z.boolean().optional(),
|
||||
capabilities: z.object({
|
||||
clis: z
|
||||
.array(
|
||||
z.object({
|
||||
profile: z.string(),
|
||||
models: z.array(z.object({ id: z.string() })).optional(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
skills: z.array(z.string()).optional(),
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
const CACHE_TTL_MS = 60_000;
|
||||
let cache: { at: number; skills: FleetSkill[] } | null = null;
|
||||
|
||||
/** Test hook: resets the module cache. */
|
||||
export function clearFleetSkillsCache(): void {
|
||||
cache = null;
|
||||
}
|
||||
|
||||
export interface FleetSkillsOptions {
|
||||
fetchImpl?: typeof fetch;
|
||||
nowMs?: () => number;
|
||||
}
|
||||
|
||||
export async function getFleetSkills(opts: FleetSkillsOptions = {}): Promise<FleetSkill[]> {
|
||||
const hubUrl = process.env.CONDUCTOR_HUB_URL?.trim();
|
||||
if (!hubUrl) return [];
|
||||
const now = opts.nowMs ?? Date.now;
|
||||
if (cache && now() - cache.at < CACHE_TTL_MS) return cache.skills;
|
||||
|
||||
const doFetch = opts.fetchImpl ?? fetch;
|
||||
let skills: FleetSkill[] = [];
|
||||
try {
|
||||
const res = await doFetch(`${hubUrl}/v1/runners`, {
|
||||
headers: { authorization: `Bearer ${process.env.CONDUCTOR_HUB_TOKEN?.trim() ?? ""}` },
|
||||
});
|
||||
if (res.ok) skills = deriveSkills(runnersSchema.parse(await res.json()));
|
||||
} catch {
|
||||
skills = []; // hub offline / shape inválido: o card omite a frota, nunca quebra
|
||||
}
|
||||
cache = { at: now(), skills };
|
||||
return skills;
|
||||
}
|
||||
|
||||
function deriveSkills(runners: z.infer<typeof runnersSchema>): FleetSkill[] {
|
||||
const online = runners.filter((r) => r.online !== false);
|
||||
const byProfile = new Map<string, { count: number; models: Set<string> }>();
|
||||
const oasfSkills = new Set<string>();
|
||||
for (const r of online) {
|
||||
for (const cli of r.capabilities.clis ?? []) {
|
||||
const entry = byProfile.get(cli.profile) ?? { count: 0, models: new Set<string>() };
|
||||
entry.count++;
|
||||
for (const m of cli.models ?? []) entry.models.add(m.id);
|
||||
byProfile.set(cli.profile, entry);
|
||||
}
|
||||
for (const s of r.capabilities.skills ?? []) oasfSkills.add(s);
|
||||
}
|
||||
|
||||
const skills: FleetSkill[] = [];
|
||||
for (const [profile, info] of [...byProfile.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
||||
const models = [...info.models].slice(0, 8);
|
||||
skills.push({
|
||||
id: `conductor-cli-${profile}`,
|
||||
name: `Conductor fleet: ${profile} CLI`,
|
||||
description:
|
||||
`Delegate coding tasks to the OmniConductor fleet's ${profile} CLI ` +
|
||||
`(${info.count} runner(s) online${models.length ? `; models: ${models.join(", ")}` : ""}). ` +
|
||||
"Tasks run in disposable git worktrees; results come back as branches with graduated manifests.",
|
||||
tags: ["conductor", "fleet", "cli", profile],
|
||||
});
|
||||
}
|
||||
for (const s of [...oasfSkills].sort()) {
|
||||
skills.push({
|
||||
id: `conductor-skill-${s}`,
|
||||
name: `Conductor fleet skill: ${s}`,
|
||||
description: `OASF skill "${s}" declared by online runners of the OmniConductor fleet.`,
|
||||
tags: ["conductor", "fleet", "skill"],
|
||||
});
|
||||
}
|
||||
return skills;
|
||||
}
|
||||
222
src/lib/conductor/hubProxy.ts
Normal file
222
src/lib/conductor/hubProxy.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
|
||||
export interface DelegationInput {
|
||||
repoUrl: string;
|
||||
prompt: string;
|
||||
baseRef?: string;
|
||||
mode?: string;
|
||||
cli?: string;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delegates work to the fleet: translates an external A2A task into the hub's
|
||||
* `POST /v1/tasks` (Conductor PRD RF5). Uses the orchestrator credential when
|
||||
* set (CONDUCTOR_ORCHESTRATOR_TOKEN), falling back to the hub token. States
|
||||
* flow back through the RF1 mirror — this call only creates.
|
||||
*/
|
||||
export async function createConductorTask(
|
||||
input: DelegationInput,
|
||||
opts: HubProxyOptions = {}
|
||||
): Promise<{ ok: boolean; status: number; task_id?: string }> {
|
||||
const cfg = hubConfig();
|
||||
if (!cfg) return { ok: false, status: 503 };
|
||||
const token = process.env.CONDUCTOR_ORCHESTRATOR_TOKEN?.trim() || cfg.token;
|
||||
const body: Record<string, unknown> = {
|
||||
repo: { url: input.repoUrl, base_ref: input.baseRef?.trim() || "main" },
|
||||
spec: { prompt: input.prompt },
|
||||
mode: input.mode?.trim() || "solo",
|
||||
};
|
||||
const requirements: Record<string, string> = {};
|
||||
if (input.cli?.trim()) requirements.cli = input.cli.trim();
|
||||
if (input.model?.trim()) requirements.model = input.model.trim();
|
||||
if (Object.keys(requirements).length) body.requirements = requirements;
|
||||
try {
|
||||
const doFetch = opts.fetchImpl ?? fetch;
|
||||
const res = await doFetch(`${cfg.url}/v1/tasks`, {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) return { ok: false, status: res.status };
|
||||
const created = z.object({ id: z.string() }).parse(await res.json());
|
||||
return { ok: true, status: res.status, task_id: created.id };
|
||||
} catch {
|
||||
return { ok: false, status: 503 };
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 };
|
||||
}
|
||||
}
|
||||
35
src/lib/db/conductorBridge.ts
Normal file
35
src/lib/db/conductorBridge.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Conductor bridge persistence — SSE cursor (`last_event_id`) for the hub mirror.
|
||||
*
|
||||
* Uses the existing `key_value` table under a dedicated namespace (no migration
|
||||
* needed — same pattern as settings.ts). The cursor lets the bridge resume the
|
||||
* hub SSE from where it stopped; the hub replay covers the gap.
|
||||
*/
|
||||
|
||||
import { getDbInstance } from "./core";
|
||||
|
||||
const NAMESPACE = "conductor";
|
||||
const CURSOR_KEY = "last_event_id";
|
||||
|
||||
export function getConductorCursor(): string | null {
|
||||
const db = getDbInstance();
|
||||
const row = db
|
||||
.prepare("SELECT value FROM key_value WHERE namespace = ? AND key = ?")
|
||||
.get(NAMESPACE, CURSOR_KEY) as { value: string } | undefined;
|
||||
if (!row) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(row.value);
|
||||
return typeof parsed === "string" ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setConductorCursor(value: string): void {
|
||||
const db = getDbInstance();
|
||||
db.prepare("INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)").run(
|
||||
NAMESPACE,
|
||||
CURSOR_KEY,
|
||||
JSON.stringify(value)
|
||||
);
|
||||
}
|
||||
4
src/lib/env/runtimeEnv.ts
vendored
4
src/lib/env/runtimeEnv.ts
vendored
@@ -69,6 +69,10 @@ export const webRuntimeEnvSchema = z.object({
|
||||
OMNIROUTE_BASE_URL: optionalHttpUrl,
|
||||
BASE_URL: optionalHttpUrl,
|
||||
NEXT_PUBLIC_BASE_URL: optionalHttpUrl,
|
||||
CONDUCTOR_HUB_URL: optionalHttpUrl,
|
||||
CONDUCTOR_SPOKESPERSON_URL: optionalHttpUrl,
|
||||
CONDUCTOR_ORCHESTRATOR_TOKEN: optionalTrimmedString,
|
||||
CONDUCTOR_HUB_TOKEN: optionalTrimmedString,
|
||||
OMNIROUTE_PORT: optionalPortEnv,
|
||||
API_PORT: optionalPortEnv,
|
||||
DASHBOARD_PORT: optionalPortEnv,
|
||||
|
||||
@@ -805,3 +805,4 @@ export { markConnectionRateLimitedUntil, clearConnectionRateLimit } from "./db/p
|
||||
export * from "./db/paramFilters";
|
||||
export * from "./db/interceptionRules"; // Per-model web-search/web-fetch interception rules (#3384)
|
||||
export * from "./db/relayProbeStats"; // Relay probe latency/health stats (#6909)
|
||||
export * from "./db/conductorBridge"; // OmniConductor hub mirror — SSE cursor (PRD Conductor RF1)
|
||||
|
||||
@@ -234,6 +234,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",
|
||||
|
||||
@@ -28,6 +28,7 @@ export const HIDEABLE_SIDEBAR_ITEM_IDS = [
|
||||
"cli-agents",
|
||||
"acp-agents",
|
||||
"cloud-agents",
|
||||
"conductor",
|
||||
"agent-bridge",
|
||||
"traffic-inspector",
|
||||
"discovery",
|
||||
|
||||
112
tests/unit/conductor-a2a-post.test.ts
Normal file
112
tests/unit/conductor-a2a-post.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
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-a2a-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const settings = await import("../../src/lib/db/settings.ts");
|
||||
const tasksRoute = await import("../../src/app/api/a2a/tasks/route.ts");
|
||||
|
||||
const servers: Server[] = [];
|
||||
|
||||
async function enableA2A() {
|
||||
await settings.updateSettings({ a2aEnabled: true });
|
||||
}
|
||||
|
||||
function delegationRequest(body: unknown, bearer?: string) {
|
||||
return new Request("http://localhost/api/a2a/tasks", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(bearer ? { authorization: `Bearer ${bearer}` } : {}),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
const VALID_BODY = {
|
||||
skill: "conductor-cli-claude",
|
||||
messages: [{ role: "user", content: "adicione um README com a seção Sobre" }],
|
||||
metadata: { conductor: { repo: { url: "https://git.x/repo", base_ref: "dev" }, mode: "solo", model: "cc/claude-sonnet-5" } },
|
||||
};
|
||||
|
||||
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.OMNIROUTE_API_KEY;
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
delete process.env.CONDUCTOR_HUB_URL;
|
||||
delete process.env.OMNIROUTE_API_KEY;
|
||||
while (servers.length > 0) {
|
||||
const s = servers.pop();
|
||||
await new Promise((resolve) => s?.close(resolve));
|
||||
}
|
||||
});
|
||||
|
||||
test("A2A desabilitado → 503 (mesmo gate do JSON-RPC)", async () => {
|
||||
const res = await tasksRoute.POST(delegationRequest(VALID_BODY));
|
||||
assert.equal(res.status, 503);
|
||||
});
|
||||
|
||||
test("com OMNIROUTE_API_KEY configurada, bearer errado → 401 e bearer certo passa", async () => {
|
||||
await enableA2A();
|
||||
process.env.OMNIROUTE_API_KEY = "chave-certa";
|
||||
const denied = await tasksRoute.POST(delegationRequest(VALID_BODY, "chave-errada"));
|
||||
assert.equal(denied.status, 401);
|
||||
});
|
||||
|
||||
test("delegação válida → 201 com o task_id do hub; requirements derivados da skill do card", async () => {
|
||||
await enableA2A();
|
||||
const bodies: unknown[] = [];
|
||||
await new Promise<void>((resolve) => {
|
||||
const server = createServer((req, res) => {
|
||||
let raw = "";
|
||||
req.on("data", (c) => (raw += c));
|
||||
req.on("end", () => {
|
||||
bodies.push(JSON.parse(raw));
|
||||
res.writeHead(201, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ id: "t_delegada", status: "submitted" }));
|
||||
});
|
||||
});
|
||||
servers.push(server);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const addr = server.address();
|
||||
process.env.CONDUCTOR_HUB_URL = `http://127.0.0.1:${typeof addr === "object" && addr ? addr.port : 0}`;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
const res = await tasksRoute.POST(delegationRequest(VALID_BODY));
|
||||
assert.equal(res.status, 201);
|
||||
const out = await res.json();
|
||||
assert.equal(out.conductor_task_id, "t_delegada");
|
||||
assert.equal(out.state, "submitted");
|
||||
const sent = bodies[0] as { repo: { url: string; base_ref: string }; spec: { prompt: string }; requirements: { cli: string; model: string } };
|
||||
assert.equal(sent.repo.url, "https://git.x/repo");
|
||||
assert.equal(sent.repo.base_ref, "dev");
|
||||
assert.equal(sent.spec.prompt, "adicione um README com a seção Sobre");
|
||||
assert.equal(sent.requirements.cli, "claude", "skill conductor-cli-claude vira requirements.cli");
|
||||
assert.equal(sent.requirements.model, "cc/claude-sonnet-5");
|
||||
});
|
||||
|
||||
test("sem repo na metadata → 400 (delegação exige repo); skill não-conductor → 400", async () => {
|
||||
await enableA2A();
|
||||
const noRepo = await tasksRoute.POST(
|
||||
delegationRequest({ skill: "conductor", messages: [{ role: "user", content: "p" }] })
|
||||
);
|
||||
assert.equal(noRepo.status, 400);
|
||||
const wrongSkill = await tasksRoute.POST(
|
||||
delegationRequest({ ...VALID_BODY, skill: "smart-routing" })
|
||||
);
|
||||
assert.equal(wrongSkill.status, 400);
|
||||
});
|
||||
52
tests/unit/conductor-agent-card.test.ts
Normal file
52
tests/unit/conductor-agent-card.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createServer, type Server } from "node:http";
|
||||
|
||||
import { GET } from "../../src/app/.well-known/agent.json/route.ts";
|
||||
import { clearFleetSkillsCache } from "../../src/lib/conductor/fleetSkills.ts";
|
||||
|
||||
const servers: Server[] = [];
|
||||
|
||||
test.beforeEach(() => {
|
||||
clearFleetSkillsCache();
|
||||
delete process.env.CONDUCTOR_HUB_URL;
|
||||
delete process.env.CONDUCTOR_HUB_TOKEN;
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
delete process.env.CONDUCTOR_HUB_URL;
|
||||
while (servers.length > 0) {
|
||||
const s = servers.pop();
|
||||
await new Promise((resolve) => s?.close(resolve));
|
||||
}
|
||||
});
|
||||
|
||||
test("sem CONDUCTOR_HUB_URL o card continua válido, com as skills estáticas e zero conductor-*", async () => {
|
||||
const res = await GET();
|
||||
const card = await res.json();
|
||||
assert.equal(typeof card.name, "string");
|
||||
assert.ok(Array.isArray(card.skills) && card.skills.length >= 6, "skills estáticas presentes");
|
||||
assert.ok(card.skills.every((s: { id: string }) => !s.id.startsWith("conductor-")));
|
||||
});
|
||||
|
||||
test("com hub de pé o card anuncia as skills da frota SEM perder as estáticas", async () => {
|
||||
const server = createServer((req, res) => {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify([
|
||||
{ id: "r_1", online: true, capabilities: { name: "devbox", clis: [{ profile: "claude" }], skills: [] } },
|
||||
])
|
||||
);
|
||||
});
|
||||
servers.push(server);
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
|
||||
const addr = server.address();
|
||||
process.env.CONDUCTOR_HUB_URL = `http://127.0.0.1:${typeof addr === "object" && addr ? addr.port : 0}`;
|
||||
process.env.CONDUCTOR_HUB_TOKEN = "tok";
|
||||
|
||||
const res = await GET();
|
||||
const card = await res.json();
|
||||
const ids = card.skills.map((s: { id: string }) => s.id);
|
||||
assert.ok(ids.includes("conductor-cli-claude"), `frota anunciada (ids: ${ids.join(",")})`);
|
||||
assert.ok(ids.includes("smart-routing"), "estáticas intactas");
|
||||
});
|
||||
85
tests/unit/conductor-ask-route.test.ts
Normal file
85
tests/unit/conductor-ask-route.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
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-ask-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const askRoute = await import("../../src/app/api/conductor/ask/route.ts");
|
||||
|
||||
const servers: Server[] = [];
|
||||
|
||||
test.beforeEach(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
delete process.env.CONDUCTOR_SPOKESPERSON_URL;
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
delete process.env.CONDUCTOR_SPOKESPERSON_URL;
|
||||
while (servers.length > 0) {
|
||||
const s = servers.pop();
|
||||
await new Promise((resolve) => s?.close(resolve));
|
||||
}
|
||||
});
|
||||
|
||||
test("fonte: auth antes do proxy; token nunca manuseado na rota", () => {
|
||||
const src = fs.readFileSync(path.join(process.cwd(), "src/app/api/conductor/ask/route.ts"), "utf8");
|
||||
const authAt = src.indexOf("requireManagementAuth(");
|
||||
assert.ok(authAt > 0);
|
||||
assert.match(src, /if \(authError\) return authError;/);
|
||||
assert.ok(src.indexOf("askFaro(") > authAt, "askFaro só depois do gate");
|
||||
assert.ok(!src.includes("CONDUCTOR_HUB_TOKEN"), "token vive no faroProxy, não na rota");
|
||||
});
|
||||
|
||||
test("POST valida o body (Zod) e repassa text+pending do Faro", async () => {
|
||||
await new Promise<void>((resolve) => {
|
||||
const server = createServer((req, res) => {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ text: "frota vazia", pending: null }));
|
||||
});
|
||||
servers.push(server);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const addr = server.address();
|
||||
process.env.CONDUCTOR_SPOKESPERSON_URL = `http://127.0.0.1:${typeof addr === "object" && addr ? addr.port : 0}`;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
const ok = await askRoute.POST(
|
||||
new Request("http://localhost/api/conductor/ask", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ message: "como está a frota?" }),
|
||||
})
|
||||
);
|
||||
assert.equal(ok.status, 200);
|
||||
assert.deepEqual(await ok.json(), { text: "frota vazia", pending: null });
|
||||
|
||||
const bad = await askRoute.POST(
|
||||
new Request("http://localhost/api/conductor/ask", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ message: "" }),
|
||||
})
|
||||
);
|
||||
assert.equal(bad.status, 400, "mensagem vazia é rejeitada pelo Zod");
|
||||
});
|
||||
|
||||
test("Faro fora do ar → 503 sanitizado", async () => {
|
||||
process.env.CONDUCTOR_SPOKESPERSON_URL = "http://127.0.0.1:1";
|
||||
const res = await askRoute.POST(
|
||||
new Request("http://localhost/api/conductor/ask", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ message: "oi" }),
|
||||
})
|
||||
);
|
||||
assert.equal(res.status, 503);
|
||||
});
|
||||
35
tests/unit/conductor-bridge-boot.test.ts
Normal file
35
tests/unit/conductor-bridge-boot.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { initConductorBridge, stopConductorBridge } from "../../src/lib/conductor/boot.ts";
|
||||
|
||||
test.afterEach(() => {
|
||||
stopConductorBridge();
|
||||
delete process.env.CONDUCTOR_HUB_URL;
|
||||
delete process.env.CONDUCTOR_HUB_TOKEN;
|
||||
});
|
||||
|
||||
test("no-op without CONDUCTOR_HUB_URL (bridge is opt-in)", () => {
|
||||
delete process.env.CONDUCTOR_HUB_URL;
|
||||
assert.equal(initConductorBridge(), null);
|
||||
});
|
||||
|
||||
test("starts once with CONDUCTOR_HUB_URL set and is idempotent", () => {
|
||||
process.env.CONDUCTOR_HUB_URL = "http://127.0.0.1:1"; // porta inválida: conexão falha, mas o handle existe
|
||||
process.env.CONDUCTOR_HUB_TOKEN = "tok";
|
||||
// cursor injetado em memória: o teste não toca o SQLite real
|
||||
const first = initConductorBridge({ cursor: { get: () => null, set: () => {} } });
|
||||
assert.ok(first, "bridge iniciada");
|
||||
const second = initConductorBridge({ cursor: { get: () => null, set: () => {} } });
|
||||
assert.equal(second, first, "segunda chamada devolve a mesma instância (idempotente)");
|
||||
});
|
||||
|
||||
test("stopConductorBridge() stops and allows a fresh start", () => {
|
||||
process.env.CONDUCTOR_HUB_URL = "http://127.0.0.1:1";
|
||||
const first = initConductorBridge({ cursor: { get: () => null, set: () => {} } });
|
||||
assert.ok(first);
|
||||
stopConductorBridge();
|
||||
assert.equal(first!.state(), "stopped");
|
||||
const second = initConductorBridge({ cursor: { get: () => null, set: () => {} } });
|
||||
assert.ok(second && second !== first, "após stop, novo init cria instância nova");
|
||||
});
|
||||
142
tests/unit/conductor-bridge-loop.test.ts
Normal file
142
tests/unit/conductor-bridge-loop.test.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createServer, type Server } from "node:http";
|
||||
|
||||
import { A2ATaskManager } from "../../src/lib/a2a/taskManager.ts";
|
||||
import { createConductorBridge } from "../../src/lib/conductor/bridge.ts";
|
||||
|
||||
const managers: A2ATaskManager[] = [];
|
||||
const servers: Server[] = [];
|
||||
const bridges: { stop(): void }[] = [];
|
||||
|
||||
function createManager() {
|
||||
const manager = new A2ATaskManager(5);
|
||||
managers.push(manager);
|
||||
return manager;
|
||||
}
|
||||
|
||||
test.afterEach(async () => {
|
||||
while (bridges.length > 0) bridges.pop()?.stop();
|
||||
while (managers.length > 0) managers.pop()?.destroy();
|
||||
while (servers.length > 0) {
|
||||
const s = servers.pop();
|
||||
await new Promise((resolve) => s?.close(resolve));
|
||||
}
|
||||
});
|
||||
|
||||
// Formato REAL do SSE do hub (verificado ao vivo 2026-07-22): id/type nos campos do
|
||||
// frame; o `data:` carrega só {ts, payload}.
|
||||
const sse = (id: number, type: string, payload: Record<string, unknown>) =>
|
||||
`id: ${id}\nevent: ${type}\ndata: ${JSON.stringify({ ts: "2026-07-22T00:00:00Z", payload })}\n\n`;
|
||||
|
||||
interface FakeHub {
|
||||
url: string;
|
||||
requests: { lastEventId: string | null; auth: string | null }[];
|
||||
}
|
||||
|
||||
/** Fake hub SSE: each handler invocation pops the next script entry {events, thenClose}. */
|
||||
function fakeHub(script: { events: string[]; thenClose: boolean }[]): Promise<FakeHub> {
|
||||
const requests: FakeHub["requests"] = [];
|
||||
let call = 0;
|
||||
const server = createServer((req, res) => {
|
||||
const u = new URL(req.url ?? "/", "http://x");
|
||||
requests.push({ lastEventId: u.searchParams.get("last_event_id"), auth: req.headers.authorization ?? null });
|
||||
const step = script[Math.min(call, script.length - 1)];
|
||||
call++;
|
||||
res.writeHead(200, { "content-type": "text/event-stream" });
|
||||
for (const e of step.events) res.write(e);
|
||||
if (step.thenClose) res.end();
|
||||
// senão: conexão fica aberta (o teste encerra via bridge.stop() + server.close())
|
||||
});
|
||||
servers.push(server);
|
||||
return new Promise((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const addr = server.address();
|
||||
resolve({ url: `http://127.0.0.1:${typeof addr === "object" && addr ? addr.port : 0}`, requests });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function waitFor(cond: () => boolean, ms = 3000): Promise<void> {
|
||||
const deadline = Date.now() + ms;
|
||||
return new Promise((resolve, reject) => {
|
||||
const tick = () => {
|
||||
if (cond()) return resolve();
|
||||
if (Date.now() > deadline) return reject(new Error("waitFor timeout"));
|
||||
setTimeout(tick, 10);
|
||||
};
|
||||
tick();
|
||||
});
|
||||
}
|
||||
|
||||
test("mirrors events from the hub SSE, advancing the injected cursor (incl. canceled→cancelled)", async () => {
|
||||
const hub = await fakeHub([
|
||||
{
|
||||
events: [
|
||||
sse(1, "task.created", { task_id: "t_x", mode: "solo", from: "orch" }),
|
||||
sse(2, "task.scheduled", { task_id: "t_x", runner_id: "r_1" }),
|
||||
sse(3, "task.canceled", { task_id: "t_x" }),
|
||||
],
|
||||
thenClose: false,
|
||||
},
|
||||
]);
|
||||
const tm = createManager();
|
||||
let cursor: string | null = null;
|
||||
const bridge = createConductorBridge({
|
||||
hubUrl: hub.url,
|
||||
token: "tok-spk",
|
||||
tm,
|
||||
cursor: { get: () => cursor, set: (v) => (cursor = v) },
|
||||
backoffBaseMs: 10,
|
||||
});
|
||||
bridges.push(bridge);
|
||||
bridge.start();
|
||||
await waitFor(() => cursor === "3");
|
||||
assert.equal(hub.requests[0].lastEventId, "0", "sem cursor persistido, começa do 0 (replay total)");
|
||||
assert.equal(hub.requests[0].auth, "Bearer tok-spk");
|
||||
const tasks = tm.listTasks();
|
||||
assert.equal(tasks.length, 1);
|
||||
assert.equal(tasks[0].state, "cancelled");
|
||||
});
|
||||
|
||||
test("reconnects with backoff and resumes from the persisted cursor", async () => {
|
||||
const hub = await fakeHub([
|
||||
{ events: [sse(1, "task.created", { task_id: "t_r", mode: "solo", from: "o" })], thenClose: true },
|
||||
{ events: [sse(2, "task.completed", { task_id: "t_r", manifest: { summary: "ok" } })], thenClose: false },
|
||||
]);
|
||||
const tm = createManager();
|
||||
let cursor: string | null = null;
|
||||
const bridge = createConductorBridge({
|
||||
hubUrl: hub.url,
|
||||
token: "tok",
|
||||
tm,
|
||||
cursor: { get: () => cursor, set: (v) => (cursor = v) },
|
||||
backoffBaseMs: 10,
|
||||
});
|
||||
bridges.push(bridge);
|
||||
bridge.start();
|
||||
await waitFor(() => hub.requests.length >= 2 && cursor === "2");
|
||||
assert.equal(hub.requests[1].lastEventId, "1", "reconexão retoma do cursor persistido");
|
||||
assert.equal(tm.listTasks()[0].state, "completed");
|
||||
});
|
||||
|
||||
test("stop() aborts and never reconnects", async () => {
|
||||
const hub = await fakeHub([{ events: [sse(1, "task.created", { task_id: "t_s", mode: "solo", from: "o" })], thenClose: true }]);
|
||||
const tm = createManager();
|
||||
let cursor: string | null = null;
|
||||
const bridge = createConductorBridge({
|
||||
hubUrl: hub.url,
|
||||
token: "tok",
|
||||
tm,
|
||||
cursor: { get: () => cursor, set: (v) => (cursor = v) },
|
||||
backoffBaseMs: 10,
|
||||
});
|
||||
bridges.push(bridge);
|
||||
bridge.start();
|
||||
await waitFor(() => cursor === "1");
|
||||
bridge.stop();
|
||||
const before = hub.requests.length;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
assert.equal(hub.requests.length, before, "nenhuma reconexão após stop()");
|
||||
assert.equal(bridge.state(), "stopped");
|
||||
});
|
||||
106
tests/unit/conductor-bridge-mapping.test.ts
Normal file
106
tests/unit/conductor-bridge-mapping.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { A2ATaskManager } from "../../src/lib/a2a/taskManager.ts";
|
||||
import { applyConductorEvent, conductorStateFor } from "../../src/lib/conductor/bridge.ts";
|
||||
|
||||
const managers: A2ATaskManager[] = [];
|
||||
|
||||
function createManager(ttlMinutes = 5) {
|
||||
const manager = new A2ATaskManager(ttlMinutes);
|
||||
managers.push(manager);
|
||||
return manager;
|
||||
}
|
||||
|
||||
test.afterEach(() => {
|
||||
while (managers.length > 0) {
|
||||
managers.pop()?.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
const ev = (id: number, type: string, payload: Record<string, unknown>) => ({
|
||||
id: String(id),
|
||||
type,
|
||||
payload,
|
||||
});
|
||||
|
||||
function mirrored(tm: A2ATaskManager, index: Map<string, string>, conductorId: string) {
|
||||
const a2aId = index.get(conductorId);
|
||||
assert.ok(a2aId, `task ${conductorId} indexada`);
|
||||
const task = tm.getTask(a2aId!);
|
||||
assert.ok(task, `task A2A ${a2aId} existe`);
|
||||
return task!;
|
||||
}
|
||||
|
||||
test("task.created mirrors as submitted with conductor metadata", () => {
|
||||
const tm = createManager();
|
||||
const index = new Map<string, string>();
|
||||
applyConductorEvent(tm, index, ev(1, "task.created", { task_id: "t_abc", mode: "solo", from: "orchestrator" }));
|
||||
const task = mirrored(tm, index, "t_abc");
|
||||
assert.equal(task.state, "submitted");
|
||||
assert.equal(task.skill, "conductor");
|
||||
const meta = task.metadata.conductor as Record<string, unknown>;
|
||||
assert.equal(meta.task_id, "t_abc");
|
||||
assert.equal(meta.mode, "solo");
|
||||
});
|
||||
|
||||
test("created → scheduled → completed climbs the transition ladder without throwing", () => {
|
||||
const tm = createManager();
|
||||
const index = new Map<string, string>();
|
||||
applyConductorEvent(tm, index, ev(1, "task.created", { task_id: "t_ok", mode: "solo", from: "x" }));
|
||||
applyConductorEvent(tm, index, ev(2, "task.scheduled", { task_id: "t_ok", runner_id: "r_1" }));
|
||||
applyConductorEvent(tm, index, ev(3, "task.completed", { task_id: "t_ok", manifest: { summary: "feito", branch: "task/t_ok" } }));
|
||||
const task = mirrored(tm, index, "t_ok");
|
||||
assert.equal(task.state, "completed");
|
||||
const meta = task.metadata.conductor as Record<string, unknown>;
|
||||
assert.equal(meta.runner, "r_1");
|
||||
assert.equal(meta.summary, "feito");
|
||||
assert.equal(meta.branch, "task/t_ok");
|
||||
});
|
||||
|
||||
test("task.canceled (Conductor, 1 L) maps to cancelled (A2A local, 2 L)", () => {
|
||||
const tm = createManager();
|
||||
const index = new Map<string, string>();
|
||||
assert.equal(conductorStateFor("task.canceled"), "cancelled");
|
||||
applyConductorEvent(tm, index, ev(1, "task.created", { task_id: "t_cx", mode: "solo", from: "x" }));
|
||||
applyConductorEvent(tm, index, ev(2, "task.canceled", { task_id: "t_cx", by: "operator" }));
|
||||
const task = mirrored(tm, index, "t_cx");
|
||||
assert.equal(task.state, "cancelled");
|
||||
});
|
||||
|
||||
test("unknown event types are tolerated and mirror nothing (forward-compat)", () => {
|
||||
const tm = createManager();
|
||||
const index = new Map<string, string>();
|
||||
applyConductorEvent(tm, index, ev(1, "runner.registered", { runner_id: "r_1", capabilities: {} }));
|
||||
applyConductorEvent(tm, index, ev(2, "council.fanout", { task_id: "t_c", candidate_task_ids: [] }));
|
||||
applyConductorEvent(tm, index, ev(3, "some.future.event", {}));
|
||||
assert.equal(index.size, 0);
|
||||
assert.equal(tm.listTasks().length, 0);
|
||||
});
|
||||
|
||||
test("late-join: terminal event for a never-seen task creates it and lands terminal", () => {
|
||||
const tm = createManager();
|
||||
const index = new Map<string, string>();
|
||||
applyConductorEvent(tm, index, ev(9, "task.completed", { task_id: "t_late", manifest: { summary: "s", branch: null } }));
|
||||
const task = mirrored(tm, index, "t_late");
|
||||
assert.equal(task.state, "completed");
|
||||
});
|
||||
|
||||
test("events after a terminal state are ignored without throwing", () => {
|
||||
const tm = createManager();
|
||||
const index = new Map<string, string>();
|
||||
applyConductorEvent(tm, index, ev(1, "task.created", { task_id: "t_t", mode: "solo", from: "x" }));
|
||||
applyConductorEvent(tm, index, ev(2, "task.canceled", { task_id: "t_t" }));
|
||||
assert.doesNotThrow(() => applyConductorEvent(tm, index, ev(3, "task.completed", { task_id: "t_t", manifest: {} })));
|
||||
assert.equal(mirrored(tm, index, "t_t").state, "cancelled");
|
||||
});
|
||||
|
||||
test("task.input_required stays working with input_required flag (A2A has no such state)", () => {
|
||||
const tm = createManager();
|
||||
const index = new Map<string, string>();
|
||||
applyConductorEvent(tm, index, ev(1, "task.created", { task_id: "t_i", mode: "solo", from: "x" }));
|
||||
applyConductorEvent(tm, index, ev(2, "task.input_required", { task_id: "t_i", question: "qual branch?" }));
|
||||
const task = mirrored(tm, index, "t_i");
|
||||
assert.equal(task.state, "working");
|
||||
assert.equal((task.metadata.conductor as Record<string, unknown>).input_required, true);
|
||||
});
|
||||
43
tests/unit/conductor-bridge-sse.test.ts
Normal file
43
tests/unit/conductor-bridge-sse.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { SseParser } from "../../src/lib/conductor/bridge.ts";
|
||||
|
||||
test("whole event in a single chunk", () => {
|
||||
const p = new SseParser();
|
||||
const out = p.push('id: 7\nevent: task.created\ndata: {"task_id":"t_1"}\n\n');
|
||||
assert.equal(out.length, 1);
|
||||
assert.deepEqual(out[0], { id: "7", event: "task.created", data: '{"task_id":"t_1"}' });
|
||||
});
|
||||
|
||||
test("event fragmented across three chunks (cut mid-data)", () => {
|
||||
const p = new SseParser();
|
||||
assert.equal(p.push("id: 8\nevent: task.sch").length, 0);
|
||||
assert.equal(p.push('eduled\ndata: {"task_id"').length, 0);
|
||||
const out = p.push(':"t_2"}\n\n');
|
||||
assert.equal(out.length, 1);
|
||||
assert.deepEqual(out[0], { id: "8", event: "task.scheduled", data: '{"task_id":"t_2"}' });
|
||||
});
|
||||
|
||||
test("two events in one chunk", () => {
|
||||
const p = new SseParser();
|
||||
const out = p.push("id: 1\nevent: a\ndata: x\n\nid: 2\nevent: b\ndata: y\n\n");
|
||||
assert.equal(out.length, 2);
|
||||
assert.equal(out[0].id, "1");
|
||||
assert.equal(out[1].data, "y");
|
||||
});
|
||||
|
||||
test("comment pings are ignored", () => {
|
||||
const p = new SseParser();
|
||||
assert.equal(p.push(": ping\n\n").length, 0);
|
||||
const out = p.push(": ping\n\nid: 3\nevent: c\ndata: z\n\n");
|
||||
assert.equal(out.length, 1);
|
||||
assert.equal(out[0].id, "3");
|
||||
});
|
||||
|
||||
test("multi-line data joins with newline (SSE spec)", () => {
|
||||
const p = new SseParser();
|
||||
const out = p.push("event: m\ndata: line1\ndata: line2\n\n");
|
||||
assert.equal(out.length, 1);
|
||||
assert.equal(out[0].data, "line1\nline2");
|
||||
});
|
||||
71
tests/unit/conductor-delegate.test.ts
Normal file
71
tests/unit/conductor-delegate.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { createConductorTask } from "../../src/lib/conductor/hubProxy.ts";
|
||||
|
||||
function fakeHub(body: unknown, status = 201) {
|
||||
const calls: { url: string; method: string; auth: string | null; body: unknown }[] = [];
|
||||
const impl = (async (url: string | URL | Request, init?: RequestInit) => {
|
||||
calls.push({
|
||||
url: String(url),
|
||||
method: init?.method ?? "GET",
|
||||
auth: (init?.headers as Record<string, string> | undefined)?.authorization ?? null,
|
||||
body: JSON.parse(String(init?.body ?? "{}")),
|
||||
});
|
||||
return new Response(JSON.stringify(body), { status });
|
||||
}) as typeof fetch;
|
||||
return { impl, calls };
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
process.env.CONDUCTOR_HUB_URL = "http://hub.test:7910";
|
||||
process.env.CONDUCTOR_HUB_TOKEN = "tok-hub";
|
||||
delete process.env.CONDUCTOR_ORCHESTRATOR_TOKEN;
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
delete process.env.CONDUCTOR_HUB_URL;
|
||||
delete process.env.CONDUCTOR_HUB_TOKEN;
|
||||
delete process.env.CONDUCTOR_ORCHESTRATOR_TOKEN;
|
||||
});
|
||||
|
||||
test("traduz a delegação A2A no POST /v1/tasks do hub (shape exato do contrato)", async () => {
|
||||
const { impl, calls } = fakeHub({ id: "t_novo", status: "submitted" });
|
||||
const r = await createConductorTask(
|
||||
{ repoUrl: "https://git.x/repo", baseRef: "dev", prompt: "adicione um README", mode: "council-3", cli: "claude", model: "cc/claude-sonnet-5" },
|
||||
{ fetchImpl: impl }
|
||||
);
|
||||
assert.deepEqual(r, { ok: true, status: 201, task_id: "t_novo" });
|
||||
assert.equal(calls[0].url, "http://hub.test:7910/v1/tasks");
|
||||
assert.equal(calls[0].method, "POST");
|
||||
assert.deepEqual(calls[0].body, {
|
||||
repo: { url: "https://git.x/repo", base_ref: "dev" },
|
||||
spec: { prompt: "adicione um README" },
|
||||
mode: "council-3",
|
||||
requirements: { cli: "claude", model: "cc/claude-sonnet-5" },
|
||||
});
|
||||
});
|
||||
|
||||
test("defaults: base_ref main, mode solo, sem requirements quando cli/model ausentes", async () => {
|
||||
const { impl, calls } = fakeHub({ id: "t_d", status: "submitted" });
|
||||
await createConductorTask({ repoUrl: "https://git.x/r", prompt: "p" }, { fetchImpl: impl });
|
||||
assert.deepEqual(calls[0].body, { repo: { url: "https://git.x/r", base_ref: "main" }, spec: { prompt: "p" }, mode: "solo" });
|
||||
});
|
||||
|
||||
test("credencial: prefere CONDUCTOR_ORCHESTRATOR_TOKEN; fallback é o token do hub", async () => {
|
||||
const a = fakeHub({ id: "t_1" });
|
||||
await createConductorTask({ repoUrl: "https://x/r", prompt: "p" }, { fetchImpl: a.impl });
|
||||
assert.equal(a.calls[0].auth, "Bearer tok-hub");
|
||||
process.env.CONDUCTOR_ORCHESTRATOR_TOKEN = "tok-orch";
|
||||
const b = fakeHub({ id: "t_2" });
|
||||
await createConductorTask({ repoUrl: "https://x/r", prompt: "p" }, { fetchImpl: b.impl });
|
||||
assert.equal(b.calls[0].auth, "Bearer tok-orch");
|
||||
});
|
||||
|
||||
test("recusa do hub → {ok:false, status} sem lançar nem vazar corpo; env ausente → 503", async () => {
|
||||
const { impl } = fakeHub({ error: "segredo do hub" }, 422);
|
||||
const r = await createConductorTask({ repoUrl: "https://x/r", prompt: "p" }, { fetchImpl: impl });
|
||||
assert.deepEqual(r, { ok: false, status: 422 });
|
||||
delete process.env.CONDUCTOR_HUB_URL;
|
||||
assert.deepEqual(await createConductorTask({ repoUrl: "https://x/r", prompt: "p" }, {}), { ok: false, status: 503 });
|
||||
});
|
||||
39
tests/unit/conductor-faro-chat.test.ts
Normal file
39
tests/unit/conductor-faro-chat.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
// Invariantes de segurança/UX do chat com voz (componentes React: vitest-ui advisory + dashboard-typecheck).
|
||||
const CHAT = "src/app/(dashboard)/dashboard/conductor/FaroChat.tsx";
|
||||
|
||||
function src(): string {
|
||||
return fs.readFileSync(path.join(process.cwd(), CHAT), "utf8");
|
||||
}
|
||||
|
||||
test("client fala SÓ com o OmniRoute: /api/conductor/ask + /api/v1/audio/* (nunca Faro/hub direto)", () => {
|
||||
const s = src();
|
||||
assert.match(s, /"use client"/);
|
||||
assert.match(s, /\/api\/conductor\/ask/);
|
||||
assert.match(s, /\/api\/v1\/audio\/transcriptions/);
|
||||
assert.match(s, /\/api\/v1\/audio\/speech/);
|
||||
assert.ok(!s.includes(":7920"), "endereço do Faro nunca no client");
|
||||
assert.ok(!s.includes("CONDUCTOR_"), "nenhuma env do Conductor no client");
|
||||
});
|
||||
|
||||
test("pending do Faro → botões Sim/Não que enviam 'sim'/'não' (trava de confirmação é do motor do Faro)", () => {
|
||||
const s = src();
|
||||
assert.match(s, /pending/);
|
||||
assert.match(s, /"sim"/);
|
||||
assert.match(s, /"não"/);
|
||||
});
|
||||
|
||||
test("voz: push-to-talk com MediaRecorder/getUserMedia; STT multipart sem Content-Type manual; TTS via Blob com revoke", () => {
|
||||
const s = src();
|
||||
assert.match(s, /navigator\.mediaDevices\.getUserMedia/);
|
||||
assert.match(s, /MediaRecorder/);
|
||||
assert.match(s, /FormData\(\)/);
|
||||
assert.ok(!/audio\/transcriptions[\s\S]{0,300}content-type/i.test(s), "multipart deixa o browser definir o boundary");
|
||||
assert.match(s, /URL\.createObjectURL/);
|
||||
assert.match(s, /URL\.revokeObjectURL/);
|
||||
assert.match(s, /useTranslations\("conductor"\)/);
|
||||
});
|
||||
61
tests/unit/conductor-faro-proxy.test.ts
Normal file
61
tests/unit/conductor-faro-proxy.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { askFaro } from "../../src/lib/conductor/faroProxy.ts";
|
||||
|
||||
function fakeFaro(body: unknown, status = 200) {
|
||||
const calls: { url: string; auth: string | null; body: unknown }[] = [];
|
||||
const impl = (async (url: string | URL | Request, init?: RequestInit) => {
|
||||
calls.push({
|
||||
url: String(url),
|
||||
auth: (init?.headers as Record<string, string> | undefined)?.authorization ?? null,
|
||||
body: JSON.parse(String(init?.body ?? "{}")),
|
||||
});
|
||||
return new Response(JSON.stringify(body), { status });
|
||||
}) as typeof fetch;
|
||||
return { impl, calls };
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
process.env.CONDUCTOR_SPOKESPERSON_URL = "http://faro.test:7920";
|
||||
process.env.CONDUCTOR_HUB_TOKEN = "tok-hub";
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
delete process.env.CONDUCTOR_SPOKESPERSON_URL;
|
||||
delete process.env.CONDUCTOR_HUB_TOKEN;
|
||||
});
|
||||
|
||||
test("repassa a mensagem ao /ask com o token server-side e devolve text+pending", async () => {
|
||||
const { impl, calls } = fakeFaro({ text: "frota ok", pending: { kind: "cancel_task" }, extra: "NÃO passa" });
|
||||
const r = await askFaro("como está a frota?", { fetchImpl: impl });
|
||||
assert.deepEqual(r, { ok: true, text: "frota ok", pending: { kind: "cancel_task" } });
|
||||
assert.equal(calls[0].url, "http://faro.test:7920/ask");
|
||||
assert.equal(calls[0].auth, "Bearer tok-hub");
|
||||
assert.deepEqual(calls[0].body, { message: "como está a frota?" });
|
||||
});
|
||||
|
||||
test("pending null passa como null (sem confirmação pendente)", async () => {
|
||||
const { impl } = fakeFaro({ text: "oi", pending: null });
|
||||
const r = await askFaro("oi", { fetchImpl: impl });
|
||||
assert.deepEqual(r, { ok: true, text: "oi", pending: null });
|
||||
});
|
||||
|
||||
test("Faro fora do ar / erro HTTP → degradado {ok:false} sem lançar nem vazar corpo", async () => {
|
||||
const failing = (async () => {
|
||||
throw new Error("ECONNREFUSED");
|
||||
}) as unknown as typeof fetch;
|
||||
const down = await askFaro("oi", { fetchImpl: failing });
|
||||
assert.equal(down.ok, false);
|
||||
const { impl } = fakeFaro({ error: "segredo interno" }, 401);
|
||||
const denied = await askFaro("oi", { fetchImpl: impl });
|
||||
assert.equal(denied.ok, false);
|
||||
assert.ok(!JSON.stringify(denied).includes("segredo interno"));
|
||||
});
|
||||
|
||||
test("URL default do Faro é loopback :7920 quando a env não está setada", async () => {
|
||||
delete process.env.CONDUCTOR_SPOKESPERSON_URL;
|
||||
const { impl, calls } = fakeFaro({ text: "x", pending: null });
|
||||
await askFaro("oi", { fetchImpl: impl });
|
||||
assert.equal(calls[0].url, "http://127.0.0.1:7920/ask");
|
||||
});
|
||||
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 });
|
||||
});
|
||||
95
tests/unit/conductor-fleet-skills.test.ts
Normal file
95
tests/unit/conductor-fleet-skills.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { getFleetSkills, clearFleetSkillsCache } from "../../src/lib/conductor/fleetSkills.ts";
|
||||
|
||||
const RUNNERS = [
|
||||
{
|
||||
id: "r_1",
|
||||
online: true,
|
||||
capabilities: {
|
||||
name: "devbox",
|
||||
clis: [
|
||||
{ profile: "claude", models: [{ id: "claude-sonnet-5", cost: 3, capability: 4 }] },
|
||||
{ profile: "codex" },
|
||||
],
|
||||
skills: ["deploy"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "r_2",
|
||||
online: true,
|
||||
capabilities: { name: "vm02", clis: [{ profile: "claude" }], skills: [] },
|
||||
},
|
||||
{
|
||||
id: "r_3",
|
||||
online: false, // offline: fora do anúncio
|
||||
capabilities: { name: "morta", clis: [{ profile: "gemini" }], skills: ["secret"] },
|
||||
},
|
||||
];
|
||||
|
||||
function fakeFetch(body: unknown, status = 200) {
|
||||
const calls: string[] = [];
|
||||
const impl = (async (url: string | URL | Request) => {
|
||||
calls.push(String(url));
|
||||
return new Response(JSON.stringify(body), { status });
|
||||
}) as typeof fetch;
|
||||
return { impl, calls };
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
clearFleetSkillsCache();
|
||||
process.env.CONDUCTOR_HUB_URL = "http://hub.test:7910";
|
||||
process.env.CONDUCTOR_HUB_TOKEN = "tok";
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
delete process.env.CONDUCTOR_HUB_URL;
|
||||
delete process.env.CONDUCTOR_HUB_TOKEN;
|
||||
});
|
||||
|
||||
test("derives one skill per unique online CLI profile + one per declared OASF skill", async () => {
|
||||
const { impl, calls } = fakeFetch(RUNNERS);
|
||||
const skills = await getFleetSkills({ fetchImpl: impl });
|
||||
const ids = skills.map((s) => s.id).sort();
|
||||
assert.deepEqual(ids, ["conductor-cli-claude", "conductor-cli-codex", "conductor-skill-deploy"]);
|
||||
assert.ok(calls[0].includes("/v1/runners"));
|
||||
const claude = skills.find((s) => s.id === "conductor-cli-claude")!;
|
||||
assert.match(claude.description, /2 runner/);
|
||||
assert.match(claude.description, /claude-sonnet-5/);
|
||||
assert.ok(claude.tags.includes("conductor"));
|
||||
// runner offline não anuncia nada (gemini/secret ausentes)
|
||||
assert.ok(!ids.some((i) => i.includes("gemini") || i.includes("secret")));
|
||||
});
|
||||
|
||||
test("caches for the TTL and refetches after it expires (injectable clock)", async () => {
|
||||
const { impl, calls } = fakeFetch(RUNNERS);
|
||||
let now = 1_000_000;
|
||||
await getFleetSkills({ fetchImpl: impl, nowMs: () => now });
|
||||
await getFleetSkills({ fetchImpl: impl, nowMs: () => now + 30_000 });
|
||||
assert.equal(calls.length, 1, "dentro do TTL: sem refetch");
|
||||
now += 61_000;
|
||||
await getFleetSkills({ fetchImpl: impl, nowMs: () => now });
|
||||
assert.equal(calls.length, 2, "TTL vencido: refetch");
|
||||
});
|
||||
|
||||
test("hub offline/erro → [] (o card omite a seção, nunca quebra)", async () => {
|
||||
const failing = (async () => {
|
||||
throw new Error("ECONNREFUSED");
|
||||
}) as unknown as typeof fetch;
|
||||
assert.deepEqual(await getFleetSkills({ fetchImpl: failing }), []);
|
||||
const { impl } = fakeFetch({ error: "x" }, 503);
|
||||
assert.deepEqual(await getFleetSkills({ fetchImpl: impl }), []);
|
||||
});
|
||||
|
||||
test("sem CONDUCTOR_HUB_URL → [] sem nem tentar fetch", async () => {
|
||||
delete process.env.CONDUCTOR_HUB_URL;
|
||||
const { impl, calls } = fakeFetch(RUNNERS);
|
||||
assert.deepEqual(await getFleetSkills({ fetchImpl: impl }), []);
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
test("shape inválido do hub → [] (input não confiável)", async () => {
|
||||
const { impl } = fakeFetch({ nao: "é array" });
|
||||
assert.deepEqual(await getFleetSkills({ fetchImpl: impl }), []);
|
||||
});
|
||||
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)");
|
||||
});
|
||||
}
|
||||
@@ -62,6 +62,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