feat(history): seed Logs + Status Feed with history on connect

Both panels were empty on Connect / agent select — only new events since
subscribe appeared. Backend already persists per-agent logs to
logs/<id>/YYYY-MM-DD.jsonl AND now keeps the last 100 status diffs in
a ring buffer (agents_and_robots 71b3b2b).

Frontend changes:
- fetch_log_history(s, agent_id, n) → GET /agents/{id}/logs?n=N, fills
  s.log_lines BEFORE SSE subscribe so context appears instantly.
  Handles the {count,id,lines:[...]} response shape from the backend.
- start_log_sse now spawns this fetch on entry; SSE adds new lines on top.
- fetch_status_history(s, n) → GET /status/recent?n=N, fills
  s.status_events with [hist]-tagged entries before the live SSE attaches.
- Connect handler dispatches fetch_status_history() in a worker thread
  alongside the existing start_status_sse + fetch_agents_async.

E2E (4 new, 29 total):
- test_status_recent_history_endpoint   — shape contract
- test_status_recent_captures_stop_start_events — drives stop/start on
  test-bot, asserts events appear in /status/recent within 5s. This is
  the "send actions and observe feed" loop the user requested.
- test_agent_logs_history_endpoint      — {count,id,lines} contract +
  lines>0 for long-running assistant-bot
- test_status_recent_unauthorized_without_bearer — auth boundary

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-22 23:43:16 +02:00
parent 9cade2f2f8
commit e4a1c20fc2
2 changed files with 128 additions and 1 deletions
+62 -1
View File
@@ -450,6 +450,35 @@ static void agent_action(AppState& s, const std::string& agent_id,
// SSE connections // 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<std::mutex> lk(s.log_mu);
for (auto& line : *arr) {
if (!line.is_string()) continue;
s.log_lines.push_back(line.get<std::string>());
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) { static void start_log_sse(AppState& s, const std::string& agent_id) {
s.log_sse.stop(); 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_lines.clear();
s.log_sse_agent_connected = agent_id; 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; fn_sse::Config cfg;
cfg.url = make_url(s, "/sse/agents/" + agent_id + "/logs"); cfg.url = make_url(s, "/sse/agents/" + agent_id + "/logs");
cfg.bearer_token = s.apikey_buf; 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<std::mutex> 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) { static void start_status_sse(AppState& s) {
s.status_sse.stop(); s.status_sse.stop();
fn_sse::Config cfg; fn_sse::Config cfg;
@@ -629,7 +687,10 @@ static void draw_connection_panel(AppState& s) {
s.connected = true; s.connected = true;
fn_log::log_info("[connect] OK"); fn_log::log_info("[connect] OK");
db_save_connection(s); 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); start_status_sse(s);
// Initial agents fetch // Initial agents fetch
fetch_agents_async(s); fetch_agents_async(s);
+66
View File
@@ -82,6 +82,72 @@ def test_connect_succeeds_with_valid_apikey():
assert n > 0, f"expected at least 1 agent, got {n}" 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/<id>/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(): def test_app_survives_auto_refresh_cycle():
"""Regression: app must NOT crash on Refresh Agents button click. """Regression: app must NOT crash on Refresh Agents button click.