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:
@@ -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<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) {
|
||||
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<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) {
|
||||
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);
|
||||
|
||||
@@ -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/<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():
|
||||
"""Regression: app must NOT crash on Refresh Agents button click.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user