diff --git a/.agent/rules/graphify.md b/.agent/rules/graphify.md new file mode 100644 index 0000000..f11ec0f --- /dev/null +++ b/.agent/rules/graphify.md @@ -0,0 +1,9 @@ +## graphify + +This project has a graphify knowledge graph at graphify-out/. + +Rules: +- Before answering architecture or codebase questions, read graphify-out/GRAPH_REPORT.md for god nodes and community structure +- If graphify-out/wiki/index.md exists, navigate it instead of reading raw files +- If the graphify MCP server is active, utilize tools like `query_graph`, `get_node`, and `shortest_path` for precise architecture navigation instead of falling back to `grep` +- After modifying code files in this session, run `python3 -c "from graphify.watch import _rebuild_code; from pathlib import Path; _rebuild_code(Path('.'))"` to keep the graph current diff --git a/.agent/workflows/graphify.md b/.agent/workflows/graphify.md new file mode 100644 index 0000000..6d870d1 --- /dev/null +++ b/.agent/workflows/graphify.md @@ -0,0 +1,8 @@ +# Workflow: graphify +**Command:** /graphify +**Description:** Turn any folder of files into a navigable knowledge graph + +## Steps +Follow the graphify skill installed at ~/.agent/skills/graphify/SKILL.md to run the full pipeline. + +If no path argument is given, use `.` (current directory). diff --git a/.cursor/rules/graphify.mdc b/.cursor/rules/graphify.mdc new file mode 100644 index 0000000..64f6281 --- /dev/null +++ b/.cursor/rules/graphify.mdc @@ -0,0 +1,10 @@ +--- +description: graphify knowledge graph context +alwaysApply: true +--- + +This project has a graphify knowledge graph at graphify-out/. + +- Before answering architecture or codebase questions, read graphify-out/GRAPH_REPORT.md for god nodes and community structure +- If graphify-out/wiki/index.md exists, navigate it instead of reading raw files +- After modifying code files in this session, run `python3 -c "from graphify.watch import _rebuild_code; from pathlib import Path; _rebuild_code(Path('.'))"` to keep the graph current diff --git a/.opencode/plugins/graphify.js b/.opencode/plugins/graphify.js new file mode 100644 index 0000000..ae478e7 --- /dev/null +++ b/.opencode/plugins/graphify.js @@ -0,0 +1,22 @@ +// graphify OpenCode plugin +// Injects a knowledge graph reminder before bash tool calls when the graph exists. +import { existsSync } from "fs"; +import { join } from "path"; + +export const GraphifyPlugin = async ({ directory }) => { + let reminded = false; + + return { + "tool.execute.before": async (input, output) => { + if (reminded) return; + if (!existsSync(join(directory, "graphify-out", "graph.json"))) return; + + if (input.tool === "bash") { + output.args.command = + 'echo "[graphify] Knowledge graph available. Read graphify-out/GRAPH_REPORT.md for god nodes and architecture context before searching files." && ' + + output.args.command; + reminded = true; + } + }, + }; +}; diff --git a/Dockerfile b/Dockerfile index 163e0dd..058a738 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,11 +19,11 @@ ENV PYTHONPATH="/app" ENV PYTHONUNBUFFERED=1 # Expose port -EXPOSE 8000 +EXPOSE 7860 -# Health check -HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ - CMD curl -f http://localhost:8000/health || exit 1 +# Health check - generous timeouts for Hugging Face Spaces +HEALTHCHECK --interval=30s --timeout=10s --start-period=120s --retries=10 \ + CMD curl -f http://localhost:7860/health || exit 1 # Run server (the app.py lifespan prints the full banner) -CMD ["python", "-m", "uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "8000"] +CMD ["python", "-m", "uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "7860"] diff --git a/README.md b/README.md index 77c4b68..39db512 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,17 @@ +--- +title: Mission Control +emoji: "โ˜๏ธ" +colorFrom: blue +colorTo: indigo +sdk: docker +tags: + - finops + - openenv + - fastapi + - observability + - dashboard +--- + # ๐Ÿ›ก๏ธ MissionCtrl โ€” AI Oversight Fleet Environment > *Every LLM agent fleet will hallucinate. MissionCtrl trains the overseer to catch them.* @@ -204,7 +218,7 @@ docker build -t missionctrl . docker run -p 8000:8000 --name missionctrl missionctrl # 3. Run the baseline agent (in another terminal) -docker exec -it missionctrl python inference.py +docker exec -it missionctrl python client.py # 4. Watch the dashboard open http://localhost:8000/dashboard @@ -222,8 +236,8 @@ python -m uvicorn server.app:app --host 0.0.0.0 --port 8000 # Configure API keys cp .env.example .env # fill in your LLM provider keys -# Run inference -python inference.py +# Run inference (OpenEnv canonical entrypoint) +python client.py # Run tests pytest tests/ -v @@ -301,7 +315,8 @@ missionctrl/ โ”œโ”€โ”€ openenv.yaml # OpenEnv manifest โ”œโ”€โ”€ pyproject.toml # Python project config โ”œโ”€โ”€ Dockerfile # Single-container deployment -โ”œโ”€โ”€ inference.py # Baseline LLM agent + cross-episode memory +โ”œโ”€โ”€ client.py # OpenEnv-required baseline evaluator entrypoint +โ”œโ”€โ”€ inference.py # Backward-compatible wrapper to client.main() โ”œโ”€โ”€ .env.example # API key template โ”œโ”€โ”€ server/ โ”‚ โ”œโ”€โ”€ app.py # FastAPI server (6 endpoints + dashboard) @@ -343,15 +358,53 @@ missionctrl/ | Method | Endpoint | Description | |--------|----------|-------------| | `GET` | `/` | Status heartbeat | -| `GET` | `/health` | Readiness check (`{"status": "ok"}`) | +| `GET` | `/health` | Readiness check (`{"healthy": true, "env": "missionctrl"}`) | | `POST` | `/reset` | `{"task_id": "easy"}` โ†’ Reset environment for tier | | `POST` | `/step` | `{"action": "FLAG(task_01, \"evidence\")"}` โ†’ Execute action | -| `GET` | `/state` | Current observation + live hallucination stats | +| `GET` | `/state` | Runtime-aware observation payload + build/container metadata | +| `GET` | `/logs` | Structured logs summary (status/path counters + recent requests) | | `GET` | `/history` | Full action/reward timeline (JSON array) | | `GET` | `/dashboard` | Live visualization UI | --- +## HF Spaces Health and Logs + +The Space now exposes two `200 OK` observability endpoints intended for build/runtime diagnostics: + +- `GET /state` returns: + - `status` + - `build` metadata (`container_id`, `build_id`, `git_sha`, `started_at`) + - current environment `observation` +- `GET /logs` returns: + - `status` + - `build` metadata + - aggregate `totals`, `statuses`, and `paths` + - recent request `entries` with `method`, `path`, `status_code`, and `duration_ms` + +Quick check: + +```bash +python scripts.py +``` + +--- + +## OpenEnv Required Files + +OpenEnv validation expects a root-level `client.py`. This repository now provides: + +- `client.py` as the canonical OpenEnv evaluator script +- `inference.py` as a compatibility wrapper for legacy commands + +Preferred command: + +```bash +python client.py +``` + +--- + ## ๐Ÿงช Testing ```bash diff --git a/client.py b/client.py new file mode 100644 index 0000000..b975d3a --- /dev/null +++ b/client.py @@ -0,0 +1,154 @@ +"""Client library for MissionCtrl environment. + +Provides HTTP client functions for interacting with the MissionCtrl environment API. +Contains environment payload structures and example scenarios. +""" + +import os +from typing import Any, Dict, Optional + +import httpx +from dotenv import load_dotenv + +# --------------------------------------------------------------------------- +# Load .env file automatically +# --------------------------------------------------------------------------- +load_dotenv() + +# --------------------------------------------------------------------------- +# Environment configuration +# --------------------------------------------------------------------------- +ENV_BASE_URL: str = os.environ.get("ENV_BASE_URL", "http://localhost:7860") + +# Known agent types in the environment +KNOWN_AGENTS = ( + "PlannerAgent", + "ResearchAgent", + "CoderAgent", + "TesterAgent", + "CommAgent", +) + +# Available task tiers +TASKS = ["easy", "medium", "hard", "special"] + +# Default max steps per episode +MAX_STEPS = 5 + + +# --------------------------------------------------------------------------- +# HTTP Client +# --------------------------------------------------------------------------- +http = httpx.Client(timeout=60.0) + + +# --------------------------------------------------------------------------- +# Environment API Functions +# --------------------------------------------------------------------------- +def reset_env(task_id: str, seed: Optional[int] = None) -> Dict[str, Any]: + """Reset the environment for a specific task. + + Args: + task_id: The task tier to run (easy, medium, hard, special) + seed: Optional random seed for reproducibility + + Returns: + Dictionary containing the initial observation + """ + payload = {"task_id": task_id} + if seed is not None: + payload["seed"] = seed + + resp = http.post(f"{ENV_BASE_URL}/reset", json=payload) + resp.raise_for_status() + return resp.json() + + +def step_env(action: str) -> Dict[str, Any]: + """Execute one action in the environment. + + Args: + action: The action string to execute (e.g., "APPROVE(task_1)") + + Returns: + Dictionary containing the new observation, reward, done flag, and info + """ + resp = http.post(f"{ENV_BASE_URL}/step", json={"action": action}) + resp.raise_for_status() + return resp.json() + + +def get_state() -> Dict[str, Any]: + """Get the current environment state (read-only). + + Returns: + Dictionary containing the current observation + """ + resp = http.get(f"{ENV_BASE_URL}/state") + resp.raise_for_status() + return resp.json() + + +def get_history() -> list: + """Get the action history for the current episode. + + Returns: + List of past actions and their results + """ + resp = http.get(f"{ENV_BASE_URL}/history") + resp.raise_for_status() + return resp.json() + + +def record_result(tier: str, score: float, steps: int, history: list, + score_breakdown: Optional[Dict] = None, + hallucination_stats: Optional[Dict] = None) -> Dict[str, str]: + """Push a completed episode result to the dashboard. + + Args: + tier: Task tier (easy, medium, hard, special) + score: Final score for the episode + steps: Number of steps taken + history: Action history for the episode + score_breakdown: Optional detailed score breakdown + hallucination_stats: Optional hallucination detection statistics + + Returns: + Confirmation response + """ + payload = { + "tier": tier, + "score": score, + "steps": steps, + "history": history, + "score_breakdown": score_breakdown or {}, + "hallucination_stats": hallucination_stats or {}, + } + resp = http.post(f"{ENV_BASE_URL}/record", json=payload) + resp.raise_for_status() + return resp.json() + + +# --------------------------------------------------------------------------- +# Example Usage +# --------------------------------------------------------------------------- +def example_basic_usage(): + """Example showing basic environment interaction.""" + # Reset environment for easy task + result = reset_env("easy") + obs = result["observation"] + print(f"Started task with {len(obs['tasks'])} tasks") + + # Take a step + step_result = step_env("NOOP") + print(f"Reward: {step_result['reward']}, Done: {step_result['done']}") + + # Get current state + state = get_state() + print(f"Current step: {state['time_step']}") + + +if __name__ == "__main__": + # For backward compatibility, delegate to inference script + from inference import main + main() diff --git a/inference.py b/inference.py index 07c0d92..962d625 100644 --- a/inference.py +++ b/inference.py @@ -46,7 +46,7 @@ MODEL_NAME: str = os.environ.get("MODEL_NAME", "openai/gpt-oss-120b") HF_TOKEN: str = os.environ.get("HF_TOKEN", "") -ENV_BASE_URL: str = os.environ.get("ENV_BASE_URL", "http://localhost:8000") +ENV_BASE_URL: str = os.environ.get("ENV_BASE_URL", "http://localhost:7860") MAX_STEPS: int = int(os.environ.get("MAX_STEPS", "5")) TASKS: List[str] = ["easy", "medium", "hard", "special"] @@ -100,7 +100,6 @@ class PromptTooLargeError(RuntimeError): """Raised when provider rejects a request as permanently oversized.""" - def _append_bounded_unique(bucket: List[str], value: str, limit: int) -> None: value = value.strip() if not value: diff --git a/models.py b/models.py new file mode 100644 index 0000000..6c90096 --- /dev/null +++ b/models.py @@ -0,0 +1,57 @@ +"""Shared API schemas for HF Space observability endpoints.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Dict, List + +from pydantic import BaseModel, Field + + +class BuildMetadata(BaseModel): + service: str = "missionctrl" + version: str = "1.0.0" + container_id: str = "unknown" + build_id: str = "unknown" + git_sha: str = "unknown" + runtime: str = "huggingface-space" + started_at: datetime + + +class HeartbeatResponse(BaseModel): + status: str = "ok" + service: str = "missionctrl" + version: str = "1.0.0" + container_id: str = "unknown" + build_id: str = "unknown" + git_sha: str = "unknown" + runtime: str = "huggingface-space" + host: str = "0.0.0.0" + port: int = 8000 + uptime_seconds: float = 0.0 + timestamp_utc: datetime + details: Dict[str, Any] = Field(default_factory=dict) + + +class RequestLogEntry(BaseModel): + timestamp: datetime + method: str + path: str + status_code: int + duration_ms: float + container_id: str = "unknown" + + +class StateResponse(BaseModel): + status: str = "ok" + build: BuildMetadata + observation: Dict[str, object] + + +class LogsSummaryResponse(BaseModel): + status: str = "ok" + build: BuildMetadata + totals: Dict[str, int] = Field(default_factory=dict) + statuses: Dict[str, int] = Field(default_factory=dict) + paths: Dict[str, int] = Field(default_factory=dict) + entries: List[RequestLogEntry] = Field(default_factory=list) diff --git a/opencode.json b/opencode.json new file mode 100644 index 0000000..ddb4d7e --- /dev/null +++ b/opencode.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://opencode.ai/config.json", + "plugin": [ + ".opencode/plugins/graphify.js" + ] +} \ No newline at end of file diff --git a/scripts.py b/scripts.py new file mode 100644 index 0000000..fa92b76 --- /dev/null +++ b/scripts.py @@ -0,0 +1,49 @@ +"""Operational helpers for Hugging Face Space validation.""" + +from __future__ import annotations + +import json +import os +from typing import Any, Dict + +import httpx + + +def load_runtime_metadata() -> Dict[str, str]: + """Normalize runtime metadata from local/HF environments.""" + return { + "service": "missionctrl", + "space_id": os.getenv("SPACE_ID", "unknown"), + "container_id": os.getenv("HOSTNAME", "unknown"), + "build_id": os.getenv("BUILD_ID", os.getenv("SPACE_ID", "unknown")), + "git_sha": os.getenv("GIT_SHA", os.getenv("HF_SPACE_COMMIT_SHA", "unknown")), + } + + +def smoke_check(base_url: str | None = None, timeout_s: float = 10.0) -> Dict[str, Any]: + """Validate that health-critical endpoints respond with HTTP 200.""" + root = (base_url or os.getenv("ENV_BASE_URL", "http://127.0.0.1:8000")).rstrip("/") + endpoints = ["/", "/health", "/state", "/logs"] + results: Dict[str, Any] = {"base_url": root, "ok": True, "checks": []} + + with httpx.Client(timeout=timeout_s) as client: + for endpoint in endpoints: + url = f"{root}{endpoint}" + response = client.get(url) + is_ok = response.status_code == 200 + results["checks"].append({ + "endpoint": endpoint, + "status_code": response.status_code, + "ok": is_ok, + }) + if not is_ok: + results["ok"] = False + return results + + +if __name__ == "__main__": + report = { + "runtime": load_runtime_metadata(), + "smoke_check": smoke_check(), + } + print(json.dumps(report, indent=2)) diff --git a/server/app.py b/server/app.py index 71a4e6d..966cee7 100644 --- a/server/app.py +++ b/server/app.py @@ -6,11 +6,15 @@ import logging import os +import socket import sys +import time from contextlib import asynccontextmanager +from collections import Counter, deque +from datetime import datetime, timezone from typing import Any, Dict, List, Optional -from fastapi import FastAPI, HTTPException +from fastapi import FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import HTMLResponse, JSONResponse, Response from pydantic import BaseModel @@ -23,6 +27,7 @@ sys.path.insert(0, _parent) from server.environment import MissionCtrlEnvironment +from models import BuildMetadata, HeartbeatResponse, LogsSummaryResponse, RequestLogEntry # --------------------------------------------------------------------------- # Logging โ€” suppress noisy poll endpoints @@ -44,10 +49,29 @@ def filter(self, record: logging.LogRecord) -> bool: logging.getLogger("uvicorn.access").addFilter(_PollFilter()) # --------------------------------------------------------------------------- -# Singleton environment +# Singleton environment (lazy-loaded for faster startup) # --------------------------------------------------------------------------- -_env = MissionCtrlEnvironment() +_env: Optional[MissionCtrlEnvironment] = None _completed_results: List[Dict[str, Any]] = [] # Accumulated episode results across tiers +_MAX_LOG_ENTRIES = int(os.getenv("LOG_BUFFER_SIZE", "250")) +_request_logs: deque[RequestLogEntry] = deque(maxlen=_MAX_LOG_ENTRIES) +_build_metadata = BuildMetadata( + container_id=os.getenv("HOSTNAME", "unknown"), + build_id=os.getenv("SPACE_ID", os.getenv("BUILD_ID", "unknown")), + git_sha=os.getenv("GIT_SHA", os.getenv("HF_SPACE_COMMIT_SHA", "unknown")), + started_at=datetime.now(timezone.utc), +) +_started_at_monotonic = time.monotonic() +_APP_PORT = int(os.getenv("PORT", "7860")) +_APP_HOST = os.getenv("HOST", "0.0.0.0") + + +def _get_env() -> MissionCtrlEnvironment: + """Lazy-load the environment on first access.""" + global _env + if _env is None: + _env = MissionCtrlEnvironment() + return _env # --------------------------------------------------------------------------- # Request / Response models @@ -100,6 +124,16 @@ async def lifespan(_: FastAPI): """ print(banner) log.info("Server started โ€” using persistent singleton environment") + _request_logs.append( + RequestLogEntry( + timestamp=datetime.now(timezone.utc), + method="SYSTEM", + path="/startup", + status_code=200, + duration_ms=0.0, + container_id=_build_metadata.container_id, + ) + ) yield @@ -118,18 +152,53 @@ async def lifespan(_: FastAPI): allow_headers=["*"], ) +@app.middleware("http") +async def request_logger(request: Request, call_next): + started = time.perf_counter() + response = await call_next(request) + duration_ms = round((time.perf_counter() - started) * 1000, 2) + _request_logs.append( + RequestLogEntry( + timestamp=datetime.now(timezone.utc), + method=request.method, + path=request.url.path, + status_code=response.status_code, + duration_ms=duration_ms, + container_id=_build_metadata.container_id, + ) + ) + return response + + +def _heartbeat_payload(details: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + payload = HeartbeatResponse( + container_id=_build_metadata.container_id, + build_id=_build_metadata.build_id, + git_sha=_build_metadata.git_sha, + runtime=_build_metadata.runtime, + host=_APP_HOST, + port=_APP_PORT, + uptime_seconds=round(time.monotonic() - _started_at_monotonic, 3), + timestamp_utc=datetime.now(timezone.utc), + details=details or {}, + ) + return payload.model_dump(mode="json") + # --------------------------------------------------------------------------- # GET / # --------------------------------------------------------------------------- @app.get("/") async def root() -> Dict[str, Any]: - """Root endpoint โ€” heartbeat for OpenEnv platform probes.""" + """Root endpoint โ€” simple success response for Hugging Face Spaces.""" + log.debug("Root endpoint accessed") return { "status": "ok", "name": "missionctrl", - "version": "1.0.0", - "endpoints": ["/health", "/reset", "/step", "/state", "/dashboard", "/history"], + "endpoints": ["/health", "/reset", "/step", "/state", "/dashboard", "/history", "/web", "/ports"], + "heartbeat": _heartbeat_payload(), + "log_summary": {"entries": len(_request_logs), "errors": sum(1 for e in _request_logs if e.status_code >= 400)}, + "uptime_seconds": round(time.monotonic() - _started_at_monotonic, 3), } @@ -138,7 +207,32 @@ async def root() -> Dict[str, Any]: # --------------------------------------------------------------------------- @app.get("/health") async def health() -> Dict[str, Any]: - return {"healthy": True, "env": "missionctrl"} + """Simple health check for Hugging Face Spaces - returns instantly.""" + log.debug("Health check accessed") + return { + "healthy": True, + **_heartbeat_payload(), + } + + +@app.get("/web") +async def web_info() -> Dict[str, Any]: + return { + **_heartbeat_payload({ + "dashboard": "/dashboard", + "logs": "/logs", + }), + } + + +@app.get("/ports") +async def ports() -> Dict[str, Any]: + return _heartbeat_payload({ + "role": "port_info", + "configured_port": _APP_PORT, + "host_binding": _APP_HOST, + "known_open_ports": [_APP_PORT], + }) # --------------------------------------------------------------------------- @@ -153,7 +247,7 @@ async def reset(req: Optional[ResetRequest] = None) -> Dict[str, Any]: raise HTTPException(status_code=422, detail=f"task_id must be one of {sorted(valid)}") # Results are now pushed explicitly via POST /result from inference.py - result = _env.reset(task_id=req.task_id, seed=req.seed) + result = _get_env().reset(task_id=req.task_id, seed=req.seed) log.info("Reset โ†’ task=%s seed=%s", req.task_id, req.seed) return result @@ -163,7 +257,7 @@ async def reset(req: Optional[ResetRequest] = None) -> Dict[str, Any]: # --------------------------------------------------------------------------- @app.post("/step") async def step(req: StepRequestBody) -> Dict[str, Any]: - result = _env.step(req.action) + result = _get_env().step(req.action) log.info( "Step %d | action=%s | reward=%+.2f done=%s", result["observation"]["time_step"], @@ -179,8 +273,35 @@ async def step(req: StepRequestBody) -> Dict[str, Any]: # --------------------------------------------------------------------------- @app.get("/state") async def state() -> Dict[str, Any]: - """Return current observation snapshot for dashboard.""" - return _env.engine.get_state() + """Return current observation snapshot with runtime metadata.""" + observation = _get_env().engine.get_state() + return { + **_heartbeat_payload({"role": "state"}), + "build": _build_metadata.model_dump(mode="json"), + **observation, + } + + +# --------------------------------------------------------------------------- +# GET /logs +# --------------------------------------------------------------------------- +@app.get("/logs") +async def logs() -> LogsSummaryResponse: + entries = list(_request_logs) + status_counter = Counter(str(entry.status_code) for entry in entries) + path_counter = Counter(entry.path for entry in entries) + totals = { + "entries": len(entries), + "unique_paths": len(path_counter), + "errors": sum(1 for e in entries if e.status_code >= 400), + } + return LogsSummaryResponse( + build=_build_metadata, + totals=totals, + statuses=dict(status_counter), + paths=dict(path_counter), + entries=entries[-50:], + ) # --------------------------------------------------------------------------- @@ -188,7 +309,7 @@ async def state() -> Dict[str, Any]: # --------------------------------------------------------------------------- @app.get("/history") async def history() -> List[Dict[str, Any]]: - return list(_env.action_history) + return list(_get_env().action_history) # --------------------------------------------------------------------------- @@ -228,6 +349,15 @@ async def dashboard() -> HTMLResponse: return HTMLResponse(content=f.read()) +@app.get("/dashboard/ping") +async def dashboard_ping() -> Dict[str, Any]: + return _heartbeat_payload({ + "role": "dashboard_ping", + "dashboard_path": "/dashboard", + "ready": True, + }) + + # --------------------------------------------------------------------------- # Favicon & Apple Touch Icon โ€” suppress 404 noise # --------------------------------------------------------------------------- diff --git a/tests/test_api.py b/tests/test_api.py index 7cc1a06..be2f23b 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -28,6 +28,12 @@ def test_root_has_name(self): data = client.get("/").json() assert data["name"] == "missionctrl" + def test_root_has_heartbeat_and_logs(self): + data = client.get("/").json() + assert "heartbeat" in data + assert "log_summary" in data + assert "uptime_seconds" in data + class TestHealthEndpoint: def test_health_returns_200(self): @@ -38,6 +44,11 @@ def test_health_has_healthy(self): data = client.get("/health").json() assert data["healthy"] is True + def test_health_has_detailed_heartbeat_fields(self): + data = client.get("/health").json() + for key in ["status", "service", "container_id", "port", "timestamp_utc", "uptime_seconds"]: + assert key in data + class TestResetEndpoint: def test_reset_returns_200(self): @@ -96,6 +107,33 @@ def test_state_has_tasks(self): client.post("/reset", json={"task_id": "easy"}) data = client.get("/state").json() assert "tasks" in data + assert "timestamp_utc" in data + + +class TestHeartbeatEndpoints: + def test_web_returns_200(self): + resp = client.get("/web") + assert resp.status_code == 200 + + def test_ports_returns_200(self): + resp = client.get("/ports") + assert resp.status_code == 200 + + def test_dashboard_ping_returns_200(self): + resp = client.get("/dashboard/ping") + assert resp.status_code == 200 + + def test_web_and_ports_have_required_fields(self): + web = client.get("/web").json() + ports = client.get("/ports").json() + for payload in [web, ports]: + for key in ["status", "service", "build_id", "runtime", "host", "port", "timestamp_utc"]: + assert key in payload + + def test_ports_payload_has_port_details(self): + data = client.get("/ports").json() + assert "details" in data + assert "known_open_ports" in data["details"] class TestHistoryEndpoint: