diff --git a/.github/workflows/slsa-provenance.yml b/.github/workflows/slsa-provenance.yml index 5733050bd..1876b6045 100644 --- a/.github/workflows/slsa-provenance.yml +++ b/.github/workflows/slsa-provenance.yml @@ -22,6 +22,12 @@ concurrency: group: slsa-${{ github.ref }} cancel-in-progress: true +# GHCR/Docker exigen repositorios en lowercase; github.repository contiene +# mayúsculas (SabaTech-dev/Alfred-Mission-Control) y buildx rechaza el tag. +# Nombre canónico fijo del artefacto — usar SIEMPRE env.IMAGE_NAME. +env: + IMAGE_NAME: ghcr.io/sabatech-dev/alfred-mission-control + jobs: provenance: name: Build & Attest @@ -48,14 +54,14 @@ jobs: with: context: . push: true - tags: ghcr.io/${{ github.repository }}:${{ github.sha }} + tags: ${{ env.IMAGE_NAME }}:${{ github.sha }} provenance: true sbom: true - name: Generate build provenance attestation uses: actions/attest-build-provenance@1c608d11d69870c2092266b3f9a6f3abbf17002c with: - subject-name: ghcr.io/${{ github.repository }} + subject-name: ${{ env.IMAGE_NAME }} subject-digest: ${{ steps.build.outputs.digest }} push-to-registry: true @@ -73,5 +79,5 @@ jobs: echo "" >> $GITHUB_STEP_SUMMARY echo "**Verification command:**" >> $GITHUB_STEP_SUMMARY echo '```bash' >> $GITHUB_STEP_SUMMARY - echo "docker buildx imagetools inspect ghcr.io/${{ github.repository }}:sha-${{ github.sha }}" >> $GITHUB_STEP_SUMMARY + echo "docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ github.sha }}" >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY diff --git a/src/app/(dashboard)/admin/system-health/page.tsx b/src/app/(dashboard)/admin/system-health/page.tsx index 871507b25..337dd45e0 100644 --- a/src/app/(dashboard)/admin/system-health/page.tsx +++ b/src/app/(dashboard)/admin/system-health/page.tsx @@ -5,7 +5,7 @@ import { useEffect, useState } from "react"; interface HealthCheck { name: string; - status: "up" | "down"; + status: "up" | "down" | "held"; details: string; } @@ -22,11 +22,11 @@ const serviceCategories: Record = { "alfred-mc": "core", "openclaw-gateway": "core", "postgresql": "core", - "ollama": "llm", + "llama.cpp-rerank": "llm", "llama.cpp-gpu": "llm", "llama.cpp-embed": "llm", + "llama.cpp-embed-memory": "llm", "coolify": "dev", - "browserless": "services", "langfuse": "services", "searxng": "services", "qmd-mcp": "services", @@ -43,11 +43,15 @@ const categoryLabels: Record = { }; function statusColor(status: string) { - return status === "up" ? "#22c55e" : "#ef4444"; + if (status === "up") return "#22c55e"; + if (status === "held") return "#f59e0b"; + return "#ef4444"; } function statusBg(status: string) { - return status === "up" ? "rgba(34,197,94,0.1)" : "rgba(239,68,68,0.1)"; + if (status === "up") return "rgba(34,197,94,0.1)"; + if (status === "held") return "rgba(245,158,11,0.1)"; + return "rgba(239,68,68,0.1)"; } function ServiceCard({ check }: { check: HealthCheck }) { @@ -136,7 +140,10 @@ export default function SystemHealthPage() { const totalServices = allChecks.length; const upServices = allChecks.filter((c) => c.status === "up").length; - const overallStatus = upServices === totalServices ? "healthy" : upServices > 0 ? "degraded" : "down"; + const heldCount = allChecks.filter((c) => c.status === "held").length; + const downCount = allChecks.filter((c) => c.status === "down").length; + // "held" services are intentional operator stops — they do not degrade health. + const overallStatus = downCount === 0 ? "healthy" : upServices > 0 ? "degraded" : "down"; return ( - {upServices}/{totalServices} services up + {upServices}/{totalServices} services up{heldCount > 0 ? ` · ⏸️ ${heldCount} held` : ""}
diff --git a/src/app/api/system/monitor/route.ts b/src/app/api/system/monitor/route.ts index 8a1d980b2..e8ee3f59b 100644 --- a/src/app/api/system/monitor/route.ts +++ b/src/app/api/system/monitor/route.ts @@ -17,7 +17,9 @@ const PROBE_TIMEOUT_MS = 5000; const SYSTEM_SERVICES = [ "alfred-mission-control", "docker", - "ollama", + "llama-main", + "llama-embeddings", + "llama-rerank", "tailscaled", "fail2ban", "redis-server", diff --git a/src/lib/stack-health.test.ts b/src/lib/stack-health.test.ts index b791af9a2..1af76a719 100644 --- a/src/lib/stack-health.test.ts +++ b/src/lib/stack-health.test.ts @@ -15,12 +15,11 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; const mockChecks = { gateway: { name: "openclaw-gateway", status: "up" as const, details: "OK" }, postgresql: { name: "postgresql", status: "up" as const, details: "OK" }, - ollama: { name: "ollama", status: "up" as const, details: "OK" }, + llamaRerank: { name: "llama.cpp-rerank", status: "up" as const, details: "OK" }, coolify: { name: "coolify", status: "up" as const, details: "OK" }, - browserless: { name: "browserless", status: "up" as const, details: "OK" }, langfuse: { name: "langfuse", status: "up" as const, details: "OK" }, qmd: { name: "qmd-mcp", status: "up" as const, details: "OK" }, - llamaGpu: { name: "llama.cpp-gpu", status: "up" as const, details: "OK" }, + llamaGpu: { name: "llama.cpp-gpu", status: "held" as const, details: "port 8001 not reachable — HELD (expected-down)" }, llamaEmbed: { name: "llama.cpp-embed", status: "up" as const, details: "OK" }, searxng: { name: "searxng", status: "up" as const, details: "OK" }, engram: { name: "engram", status: "up" as const, details: "OK" }, @@ -36,6 +35,16 @@ describe("stack-health", () => { expect(summarizeStackHealth(allUp)).toBe("healthy"); }); + it("should return 'healthy' when only up and held checks exist (held does not degrade)", async () => { + const { summarizeStackHealth } = await import("@/lib/stack-health"); + const upAndHeld = Object.values(mockChecks).filter( + c => c.status === "up" || c.status === "held", + ); + expect(upAndHeld.length).toBeGreaterThan(0); + expect(upAndHeld.some(c => c.status === "held")).toBe(true); + expect(summarizeStackHealth(upAndHeld)).toBe("healthy"); + }); + it("should return 'degraded' when any check is down", async () => { const { summarizeStackHealth } = await import("@/lib/stack-health"); const withDown = Object.values(mockChecks); @@ -75,6 +84,33 @@ describe("stack-health", () => { const osintCheck = checks.find(c => c.name === "osint-nexus"); expect(osintCheck).toBeUndefined(); }); + + it("should NOT include ollama (service removed 2026-08-28)", async () => { + const { collectStackServiceChecks } = await import("@/lib/stack-health"); + const checks = await collectStackServiceChecks(); + const ollamaCheck = checks.find(c => c.name === "ollama"); + expect(ollamaCheck).toBeUndefined(); + }); + + it("should NOT include browserless (service uninstalled 2026-09-04)", async () => { + const { collectStackServiceChecks } = await import("@/lib/stack-health"); + const checks = await collectStackServiceChecks(); + const browserlessCheck = checks.find(c => c.name === "browserless"); + expect(browserlessCheck).toBeUndefined(); + }); + + it("should report llama.cpp-gpu as held (intentional stop), never down, while port 8001 is stopped", async () => { + const { collectStackServiceChecks } = await import("@/lib/stack-health"); + const checks = await collectStackServiceChecks(); + const llamaGpu = checks.find(c => c.name === "llama.cpp-gpu"); + expect(llamaGpu).toBeDefined(); + // Port 8001 (Ornith) is HELD — intentionally stopped by the operator. + // If the port listens again the check flips to "up"; it must never be "down". + expect(["held", "up"]).toContain(llamaGpu!.status); + if (llamaGpu!.status === "held") { + expect(llamaGpu!.details).toContain("HELD"); + } + }); }); describe("formatStackHeartbeat", () => { @@ -82,13 +118,16 @@ describe("stack-health", () => { const { formatStackHeartbeat } = await import("@/lib/stack-health"); const checks = [ { name: "test-up", status: "up" as const, details: "OK" }, + { name: "test-held", status: "held" as const, details: "HELD (expected-down)" }, { name: "test-down", status: "down" as const, details: "FAIL" }, ]; const lines = formatStackHeartbeat(checks); expect(lines[0]).toContain("✅"); expect(lines[0]).toContain("test-up"); - expect(lines[1]).toContain("❌"); - expect(lines[1]).toContain("test-down"); + expect(lines[1]).toContain("⏸️"); + expect(lines[1]).toContain("test-held"); + expect(lines[2]).toContain("❌"); + expect(lines[2]).toContain("test-down"); }); }); }); diff --git a/src/lib/stack-health.ts b/src/lib/stack-health.ts index 989236e6e..6fce5123e 100644 --- a/src/lib/stack-health.ts +++ b/src/lib/stack-health.ts @@ -5,7 +5,12 @@ import { safeExecFile } from "@/lib/safe-exec"; export interface StackServiceCheck { name: string; - status: "up" | "down"; + /** + * "held" = expected-down by explicit operator decision (intentional stop). + * Held services do NOT degrade overall stack health; they are surfaced so + * the dashboard stays truthful without phantom "degraded" states. + */ + status: "up" | "down" | "held"; details: string; } @@ -120,6 +125,34 @@ async function checkTcpService(name: string, port: number): Promise { + const result = await canConnectTcpAny(port, 1000); + + if (result.ok) { + return { + name, + status: "up", + details: `listening on port ${port} (${result.host}) — HELD lifted`, + }; + } + + return { + name, + status: "held", + details: `port ${port} not reachable — HELD (expected-down): ${heldReason}`, + }; +} + async function checkGatewayService(): Promise { const probe = await probeGatewayRuntime(2000); @@ -230,22 +263,32 @@ async function checkHttpService(name: string, url: string, port: number): Promis * Collect health checks for all monitored stack services. * * NOTE: The legacy memory API (port 9077) was removed — migrated to native - * memory-core (SQLite + Ollama nomic-embed). OSINT Nexus (port 8420) was also - * removed from health checks as it is not a core service. + * memory-core (SQLite + nomic-embed via llama.cpp :8002). OSINT Nexus (port + * 8420) was also removed from health checks as it is not a core service. + * Ollama (:11434, service removed 2026-08-28) and browserless (:3002, + * uninstalled 2026-09-04) were dropped as phantom-down sources. + * llama.cpp-gpu (:8001, Ornith) is probed as HELD: intentionally stopped by + * Joker on 2026-09-08 (clean stop, NRestarts=0); production LLM serving runs + * on 1Cat-vLLM Estrella v2 :8009. Revert to a plain TCP check if Ornith + * returns with operator GO. */ export async function collectStackServiceChecks(): Promise { const dockerContainers = parseDockerContainers(); - const [gateway, postgresql, ollama, coolify, browserless, langfuse, qmd, llamaGpu, llamaEmbed, searxng, engram, prAgent] = await Promise.all([ + const [gateway, postgresql, llamaRerank, coolify, langfuse, qmd, llamaGpu, llamaEmbed, llamaEmbedMem, searxng, engram, prAgent] = await Promise.all([ checkGatewayService(), checkPostgresService(dockerContainers), - checkTcpService("ollama", 11434), + checkTcpService("llama.cpp-rerank", 8005), checkTcpService("coolify", 8000), - checkHttpService("browserless", "http://127.0.0.1:3002/pressure", 3002), checkHttpService("langfuse", "http://127.0.0.1:3001", 3001), checkTcpService("qmd-mcp", 8181), - checkTcpService("llama.cpp-gpu", 8001), + checkHeldTcpService( + "llama.cpp-gpu", + 8001, + "parada intencional Joker 2026-09-08 (Ornith, card 2ba781a9); serving prod = 1Cat-vLLM :8009; revertir con GO Joker", + ), checkTcpService("llama.cpp-embed", 8002), + checkTcpService("llama.cpp-embed-memory", 8006), checkHttpService("searxng", "http://127.0.0.1:8081", 8081), checkTcpService("engram", 7437), checkTcpService("pr-agent", 3003), @@ -255,13 +298,13 @@ export async function collectStackServiceChecks(): Promise { name: "alfred-mc", status: "up", details: "API route responding" }, gateway, postgresql, - ollama, + llamaRerank, coolify, - browserless, langfuse, qmd, llamaGpu, llamaEmbed, + llamaEmbedMem, searxng, engram, prAgent, @@ -269,11 +312,15 @@ export async function collectStackServiceChecks(): Promise } export function summarizeStackHealth(checks: StackServiceCheck[]): "healthy" | "degraded" { - return checks.every((check) => check.status === "up") ? "healthy" : "degraded"; + return checks.every((check) => check.status !== "down") ? "healthy" : "degraded"; } export function formatStackHeartbeat(checks: StackServiceCheck[]): string[] { - return checks.map( - (check) => `${check.status === "up" ? "✅" : "❌"} ${check.name}: ${check.details}`, - ); + const symbols: Record = { + up: "✅", + held: "⏸️", + down: "❌", + }; + + return checks.map((check) => `${symbols[check.status]} ${check.name}: ${check.details}`); }