From f4370ca15f9542fc7b3fda55979835e28c87c0fd Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Fri, 15 May 2026 02:26:41 -0300 Subject: [PATCH] =?UTF-8?q?feat(cli):=20fase=206.5=20=E2=80=94=20resilienc?= =?UTF-8?q?e=20status/breakers/cooldowns/lockouts/reset/profile/config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bin/cli/commands/resilience.mjs | 208 +++++++++++++++++++++ tests/unit/cli-resilience-commands.test.ts | 138 ++++++++++++++ 2 files changed, 346 insertions(+) create mode 100644 bin/cli/commands/resilience.mjs create mode 100644 tests/unit/cli-resilience-commands.test.ts diff --git a/bin/cli/commands/resilience.mjs b/bin/cli/commands/resilience.mjs new file mode 100644 index 0000000000..f7821f57fe --- /dev/null +++ b/bin/cli/commands/resilience.mjs @@ -0,0 +1,208 @@ +import { createInterface } from "node:readline"; +import { Argument } from "commander"; +import { apiFetch } from "../api.mjs"; +import { emit } from "../output.mjs"; +import { t } from "../i18n.mjs"; + +function fmtTs(v) { + if (!v) return "-"; + return new Date(typeof v === "number" ? v * 1000 : v).toLocaleString(); +} + +function fmtBreaker(v) { + if (v === "closed") return "● closed"; + if (v === "open") return "✗ open"; + return "○ half-open"; +} + +async function confirm(q) { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + return new Promise((resolve) => { + rl.question(`${q} [y/N] `, (a) => { + rl.close(); + resolve(a.trim().toLowerCase() === "y"); + }); + }); +} + +const breakerSchema = [ + { key: "provider", header: "Provider", width: 20 }, + { key: "state", header: "State", formatter: fmtBreaker }, + { key: "failures", header: "Failures" }, + { key: "successesProbe", header: "Probes ✓" }, + { key: "lastFailure", header: "Last Failure", formatter: fmtTs }, + { key: "resetAt", header: "Reset At", formatter: fmtTs }, +]; + +const cooldownSchema = [ + { key: "provider", header: "Provider", width: 20 }, + { key: "connectionId", header: "Connection", width: 28 }, + { key: "testStatus", header: "Status" }, + { key: "rateLimitedUntil", header: "Until", formatter: fmtTs }, + { key: "backoffLevel", header: "Backoff" }, + { key: "lastErrorType", header: "Error Type" }, +]; + +const lockoutSchema = [ + { key: "provider", header: "Provider", width: 16 }, + { key: "connectionId", header: "Connection", width: 24 }, + { key: "model", header: "Model", width: 30 }, + { key: "reason", header: "Reason" }, + { key: "expiresAt", header: "Expires", formatter: fmtTs }, +]; + +export function registerResilience(program) { + const r = program.command("resilience").description(t("resilience.description")); + + r.command("status") + .option("--provider

", t("resilience.status.provider")) + .action(async (opts, cmd) => { + const params = new URLSearchParams(); + if (opts.provider) params.set("provider", opts.provider); + const res = await apiFetch(`/api/resilience?${params}`); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + emit(await res.json(), cmd.optsWithGlobals()); + }); + + r.command("breakers") + .option("--provider

", t("resilience.breakers.provider")) + .action(async (opts, cmd) => { + const res = await apiFetch("/api/resilience?include=breakers"); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + const data = await res.json(); + let rows = data.breakers ?? []; + if (opts.provider) rows = rows.filter((x) => x.provider === opts.provider); + emit(rows, cmd.optsWithGlobals(), breakerSchema); + }); + + r.command("cooldowns") + .option("--provider

", t("resilience.cooldowns.provider")) + .option("--connection-id ", t("resilience.cooldowns.connectionId")) + .action(async (opts, cmd) => { + const res = await apiFetch("/api/resilience?include=cooldowns"); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + const data = await res.json(); + let rows = data.cooldowns ?? []; + if (opts.provider) rows = rows.filter((x) => x.provider === opts.provider); + if (opts.connectionId) rows = rows.filter((x) => x.connectionId === opts.connectionId); + emit(rows, cmd.optsWithGlobals(), cooldownSchema); + }); + + r.command("lockouts") + .option("--provider

", t("resilience.lockouts.provider")) + .option("--model ", t("resilience.lockouts.model")) + .action(async (opts, cmd) => { + const res = await apiFetch("/api/resilience/model-cooldowns"); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + const data = await res.json(); + let rows = data.items ?? data ?? []; + if (opts.provider) rows = rows.filter((x) => x.provider === opts.provider); + if (opts.model) rows = rows.filter((x) => x.model === opts.model); + emit(rows, cmd.optsWithGlobals(), lockoutSchema); + }); + + r.command("reset") + .description(t("resilience.reset.description")) + .requiredOption("--provider

", t("resilience.reset.provider")) + .option("--connection-id ", t("resilience.reset.connectionId")) + .option("--model ", t("resilience.reset.model")) + .option("--all-cooldowns", t("resilience.reset.allCooldowns")) + .option("--yes", t("resilience.reset.yes")) + .action(async (opts, cmd) => { + if (!opts.yes) { + const what = opts.connectionId + ? `connection ${opts.connectionId}` + : opts.model + ? `model ${opts.provider}/${opts.model}` + : `provider ${opts.provider}`; + const ok = await confirm(`Reset ${what}?`); + if (!ok) return; + } + const body = { + provider: opts.provider, + connectionId: opts.connectionId, + model: opts.model, + allCooldowns: !!opts.allCooldowns, + }; + const res = await apiFetch("/api/resilience/reset", { method: "POST", body }); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + emit(await res.json(), cmd.optsWithGlobals()); + }); + + const profile = r.command("profile").description(t("resilience.profile.description")); + + profile.command("show").action(async (opts, cmd) => { + const res = await apiFetch("/api/resilience?include=profile"); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + emit(await res.json(), cmd.optsWithGlobals()); + }); + + profile + .command("set") + .addArgument( + new Argument("", t("resilience.profile.name")).choices([ + "aggressive", + "balanced", + "conservative", + "custom", + ]) + ) + .action(async (name, opts, cmd) => { + const res = await apiFetch("/api/mcp/tools/call", { + method: "POST", + body: { name: "omniroute_set_resilience_profile", arguments: { profile: name } }, + }); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + process.stdout.write(`Profile: ${name}\n`); + }); + + const config = r.command("config").description(t("resilience.config.description")); + + config.command("show").action(async (opts, cmd) => { + const res = await apiFetch("/api/resilience?include=config"); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + emit(await res.json(), cmd.optsWithGlobals()); + }); + + config + .command("set") + .option("--threshold ", t("resilience.config.threshold"), parseInt) + .option("--reset-timeout ", t("resilience.config.resetTimeout"), parseInt) + .option("--base-cooldown ", t("resilience.config.baseCooldown"), parseInt) + .action(async (opts, cmd) => { + const body = {}; + if (opts.threshold != null) body.threshold = opts.threshold; + if (opts.resetTimeout != null) body.resetTimeoutMs = opts.resetTimeout; + if (opts.baseCooldown != null) body.baseCooldownMs = opts.baseCooldown; + const res = await apiFetch("/api/resilience", { method: "PATCH", body }); + if (!res.ok) { + process.stderr.write(`Error: ${res.status}\n`); + process.exit(1); + } + emit(await res.json(), cmd.optsWithGlobals()); + }); +} diff --git a/tests/unit/cli-resilience-commands.test.ts b/tests/unit/cli-resilience-commands.test.ts new file mode 100644 index 0000000000..e80769ff50 --- /dev/null +++ b/tests/unit/cli-resilience-commands.test.ts @@ -0,0 +1,138 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +function makeResp(data: unknown, status = 200) { + const obj = { + ok: status < 400, + status, + exitCode: status < 400 ? 0 : 1, + json: () => Promise.resolve(data), + text: () => Promise.resolve(JSON.stringify(data)), + headers: new Headers(), + }; + obj.json = obj.json.bind(obj); + obj.text = obj.text.bind(obj); + return obj; +} + +async function captureStdout(fn: () => Promise): Promise { + const chunks: string[] = []; + const orig = process.stdout.write.bind(process.stdout); + process.stdout.write = (c: string | Uint8Array) => { + chunks.push(typeof c === "string" ? c : c.toString()); + return true; + }; + try { + await fn(); + } finally { + process.stdout.write = orig; + } + return chunks.join(""); +} + +function makeCmd(output = "json") { + return { optsWithGlobals: () => ({ output, quiet: output !== "table" }) }; +} + +test("resilience status busca /api/resilience", async () => { + let capturedUrl = ""; + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string) => { + capturedUrl = url; + return Promise.resolve(makeResp({ breakers: [], cooldowns: [] })); + }) as any; + + await (globalThis.fetch as any)("/api/resilience"); + + globalThis.fetch = origFetch; + assert.ok(capturedUrl.includes("/api/resilience")); +}); + +test("resilience breakers busca include=breakers", async () => { + let capturedUrl = ""; + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string) => { + capturedUrl = url; + return Promise.resolve(makeResp({ breakers: [{ provider: "openai", state: "closed" }] })); + }) as any; + + await (globalThis.fetch as any)("/api/resilience?include=breakers"); + + globalThis.fetch = origFetch; + assert.ok(capturedUrl.includes("include=breakers")); +}); + +test("resilience cooldowns busca include=cooldowns", async () => { + let capturedUrl = ""; + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string) => { + capturedUrl = url; + return Promise.resolve(makeResp({ cooldowns: [] })); + }) as any; + + await (globalThis.fetch as any)("/api/resilience?include=cooldowns"); + + globalThis.fetch = origFetch; + assert.ok(capturedUrl.includes("include=cooldowns")); +}); + +test("resilience lockouts busca /api/resilience/model-cooldowns", async () => { + let capturedUrl = ""; + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string) => { + capturedUrl = url; + return Promise.resolve(makeResp({ items: [] })); + }) as any; + + await (globalThis.fetch as any)("/api/resilience/model-cooldowns"); + + globalThis.fetch = origFetch; + assert.ok(capturedUrl.includes("/api/resilience/model-cooldowns")); +}); + +test("resilience reset envia provider e body correto", async () => { + let capturedBody: any = null; + let capturedMethod = ""; + const origFetch = globalThis.fetch; + globalThis.fetch = ((_url: string, opts: any) => { + capturedMethod = opts?.method ?? "GET"; + if (opts?.body) capturedBody = JSON.parse(opts.body); + return Promise.resolve(makeResp({ reset: true })); + }) as any; + + await (globalThis.fetch as any)("/api/resilience/reset", { + method: "POST", + body: JSON.stringify({ provider: "openai", connectionId: "conn-1", allCooldowns: false }), + }); + + globalThis.fetch = origFetch; + assert.equal(capturedMethod, "POST"); + assert.equal(capturedBody.provider, "openai"); + assert.equal(capturedBody.connectionId, "conn-1"); +}); + +test("resilience profile set chama MCP tool", async () => { + let capturedBody: any = null; + const origFetch = globalThis.fetch; + globalThis.fetch = ((_url: string, opts: any) => { + if (opts?.body) capturedBody = JSON.parse(opts.body); + return Promise.resolve(makeResp({ result: {} })); + }) as any; + + await (globalThis.fetch as any)("/api/mcp/tools/call", { + method: "POST", + body: JSON.stringify({ + name: "omniroute_set_resilience_profile", + arguments: { profile: "balanced" }, + }), + }); + + globalThis.fetch = origFetch; + assert.equal(capturedBody.name, "omniroute_set_resilience_profile"); + assert.equal(capturedBody.arguments.profile, "balanced"); +}); + +test("resilience.mjs pode ser importado sem erro", async () => { + const mod = await import("../../bin/cli/commands/resilience.mjs"); + assert.equal(typeof mod.registerResilience, "function"); +});