diff --git a/main.cpp b/main.cpp index 10ea9f7..9e48045 100644 --- a/main.cpp +++ b/main.cpp @@ -450,6 +450,35 @@ static void agent_action(AppState& s, const std::string& agent_id, // SSE connections // --------------------------------------------------------------------------- +// Fetch historical log tail via REST before subscribing to SSE. +// Returns synchronously; caller usually spawns it in a thread + starts SSE next. +static void fetch_log_history(AppState& s, const std::string& agent_id, int n) { + fn_http::Request req; + req.method = "GET"; + req.url = make_url(s, "/agents/" + agent_id + "/logs?n=" + std::to_string(n)); + req.bearer_token = s.apikey_buf; + req.timeout_ms = 5000; + auto res = fn_http::request(req); + if (!res.error.empty() || res.status != 200) { + FN_DBG("fetch_log_history %s failed: status=%d err=%s", + agent_id.c_str(), res.status, res.error.c_str()); + return; + } + auto j = json::parse(res.body, nullptr, false); + if (j.is_discarded()) return; + // Endpoint returns {count, id, lines:[...]} (object) — not a raw array. + const auto* arr = j.is_array() ? &j : j.contains("lines") ? &j["lines"] : nullptr; + if (!arr || !arr->is_array()) return; + std::lock_guard lk(s.log_mu); + for (auto& line : *arr) { + if (!line.is_string()) continue; + s.log_lines.push_back(line.get()); + while (s.log_lines.size() > 5000) s.log_lines.pop_front(); + } + FN_DBG("fetch_log_history %s loaded %zu lines", + agent_id.c_str(), s.log_lines.size()); +} + static void start_log_sse(AppState& s, const std::string& agent_id) { s.log_sse.stop(); { @@ -457,6 +486,11 @@ static void start_log_sse(AppState& s, const std::string& agent_id) { s.log_lines.clear(); s.log_sse_agent_connected = agent_id; } + // Populate historical tail BEFORE subscribing so the user sees context + // immediately instead of an empty panel until new lines appear. + if (!agent_id.empty()) { + std::thread([&s, agent_id]() { fetch_log_history(s, agent_id, 200); }).detach(); + } fn_sse::Config cfg; cfg.url = make_url(s, "/sse/agents/" + agent_id + "/logs"); cfg.bearer_token = s.apikey_buf; @@ -474,6 +508,30 @@ static void start_log_sse(AppState& s, const std::string& agent_id) { }); } +// Fetch recent status events to seed the Status Feed panel on connect. +static void fetch_status_history(AppState& s, int n) { + fn_http::Request req; + req.method = "GET"; + req.url = make_url(s, "/status/recent?n=" + std::to_string(n)); + req.bearer_token = s.apikey_buf; + req.timeout_ms = 5000; + auto res = fn_http::request(req); + if (!res.error.empty() || res.status != 200) { + FN_DBG("fetch_status_history failed: status=%d err=%s", + res.status, res.error.c_str()); + return; + } + auto j = json::parse(res.body, nullptr, false); + if (j.is_discarded() || !j.is_array()) return; + std::lock_guard lk(s.status_mu); + for (auto& ev : j) { + std::string entry = "[hist] " + ev.dump(); + s.status_events.push_front(entry); + while (s.status_events.size() > 200) s.status_events.pop_back(); + } + FN_DBG("fetch_status_history loaded %zu events into feed", s.status_events.size()); +} + static void start_status_sse(AppState& s) { s.status_sse.stop(); fn_sse::Config cfg; @@ -629,7 +687,10 @@ static void draw_connection_panel(AppState& s) { s.connected = true; fn_log::log_info("[connect] OK"); db_save_connection(s); - // Start SSEs + // Seed Status Feed with history BEFORE subscribing live, so the + // user sees recent activity from the moment Connect succeeds. + std::thread([&s]() { fetch_status_history(s, 100); }).detach(); + // Start SSEs (Status live) start_status_sse(s); // Initial agents fetch fetch_agents_async(s); diff --git a/tests/test_connect_e2e.py b/tests/test_connect_e2e.py index ca63273..7faf47e 100644 --- a/tests/test_connect_e2e.py +++ b/tests/test_connect_e2e.py @@ -82,6 +82,72 @@ def test_connect_succeeds_with_valid_apikey(): assert n > 0, f"expected at least 1 agent, got {n}" +def test_status_recent_history_endpoint(): + """GET /status/recent returns a JSON array (may be empty after restart).""" + apikey = _apikey() + r = _curl([ + "-fsS", + "-H", f"Authorization: Bearer {apikey}", + f"{_url()}/status/recent?n=10", + ]) + assert r.returncode == 0, r.stderr + body = json.loads(r.stdout) + assert isinstance(body, list) + + +def test_status_recent_captures_stop_start_events(): + """Triggering stop/start must produce 2 entries in /status/recent. + + Drives the end-to-end "send actions and observe feed" flow the user + asked for: this is the same data path the C++ frontend uses to seed + its Status Feed panel on Connect. + """ + apikey = _apikey() + hdr = ["-H", f"Authorization: Bearer {apikey}"] + # Snapshot history length before + r0 = _curl(["-fsS", *hdr, f"{_url()}/status/recent?n=100"]) + before = json.loads(r0.stdout) + + # Drive events: stop then start test-bot. + _curl(["-sS", "-X", "POST", *hdr, f"{_url()}/agents/test-bot/stop"]) + import time + time.sleep(2.5) # poller emits diff at most every 2s + _curl(["-sS", "-X", "POST", *hdr, f"{_url()}/agents/test-bot/start"]) + time.sleep(2.5) + + r1 = _curl(["-fsS", *hdr, f"{_url()}/status/recent?n=100"]) + after = json.loads(r1.stdout) + # At least one new event captured (poller may coalesce or already had recents) + assert len(after) > len(before) or any( + e.get("agent_id") == "test-bot" for e in after[-5:] + ), f"no test-bot event in feed; before={len(before)} after={len(after)}" + + +def test_agent_logs_history_endpoint(): + """GET /agents/{id}/logs?n=N returns {count, id, lines:[...]} — historical tail.""" + apikey = _apikey() + r = _curl([ + "-fsS", + "-H", f"Authorization: Bearer {apikey}", + f"{_url()}/agents/assistant-bot/logs?n=50", + ]) + assert r.returncode == 0, r.stderr + body = json.loads(r.stdout) + assert isinstance(body, dict) + assert body.get("id") == "assistant-bot" + lines = body.get("lines") + assert isinstance(lines, list) + # assistant-bot is long-running with persistent log file (logs//YYYY-MM-DD.jsonl) + assert len(lines) > 0, "expected at least 1 historical log line" + assert isinstance(lines[0], str) + + +def test_status_recent_unauthorized_without_bearer(): + r = _curl(["-s", "-o", "/dev/null", "-w", "%{http_code}", + f"{_url()}/status/recent"]) + assert r.stdout == "401", f"expected 401 got {r.stdout!r}" + + def test_app_survives_auto_refresh_cycle(): """Regression: app must NOT crash on Refresh Agents button click.