From b341d5d9ad061185cc05df5029e647dd9559948a Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Mon, 18 May 2026 18:31:17 +0200 Subject: [PATCH 1/5] =?UTF-8?q?feat(viz):=20agent=5Fruns=5Ftimeline=20help?= =?UTF-8?q?ers=20=E2=80=94=20pure=20filter/sort/format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue 0118. Pure helpers in fn_viz::timeline namespace, free of ImGui: - passes_filter / filter_and_sort (multi-select app + status + since_ts) - format_duration (running | Ns | MmSSs | HhMMm | —) - status_color_token / status_icon_id (status → fn_tokens index / TI_*) - app_chip_hex (app id → accent hex, fallback gray) Designed for unit-test isolation. Render layer (separate commit) consumes these via agent_runs_timeline.h. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../viz/agent_runs_timeline_helpers.cpp | 110 ++++++++++++++++++ .../viz/agent_runs_timeline_helpers.h | 63 ++++++++++ 2 files changed, 173 insertions(+) create mode 100644 cpp/functions/viz/agent_runs_timeline_helpers.cpp create mode 100644 cpp/functions/viz/agent_runs_timeline_helpers.h diff --git a/cpp/functions/viz/agent_runs_timeline_helpers.cpp b/cpp/functions/viz/agent_runs_timeline_helpers.cpp new file mode 100644 index 00000000..2742939c --- /dev/null +++ b/cpp/functions/viz/agent_runs_timeline_helpers.cpp @@ -0,0 +1,110 @@ +#include "viz/agent_runs_timeline_helpers.h" + +#include + +// Tabler icon macros live in core/icons_tabler.h. They are short (3-byte +// utf-8) string literals like "\xee\xa9\x9e". We don't depend on that header +// here directly to keep helpers buildable without the icon font tree — but +// the values below are pulled from cpp/functions/core/icons_tabler.h +// (Tabler v3.41). If a glyph moves upstream, update this table. + +namespace fn_viz { +namespace timeline { + +namespace { + +// Mirror of icons_tabler.h entries used by the timeline. Kept inline so the +// helpers TU compiles standalone (tests don't pull the full icon font). +constexpr const char* TI_CLOCK_ = "\xee\xa9\xb0"; // U+EA70 +constexpr const char* TI_LOADER_ = "\xee\xb2\xa3"; // U+ECA3 spinning loader +constexpr const char* TI_CIRCLE_CHECK_ = "\xee\xa9\xa7"; // U+EA67 +constexpr const char* TI_CHECKS_ = "\xee\xae\xaa"; // U+EBAA +constexpr const char* TI_GIT_MERGE_ = "\xee\xaa\xb5"; // U+EAB5 +constexpr const char* TI_BAN_ = "\xee\xa8\xae"; // U+EA2E +constexpr const char* TI_CIRCLE_X_ = "\xee\xa9\xaa"; // U+EA6A +constexpr const char* TI_HOURGLASS_ = "\xee\xbe\x93"; // U+EF93 + +bool contains(const std::vector& v, const std::string& needle) { + for (const auto& s : v) { + if (s == needle) return true; + } + return false; +} + +} // namespace + +bool passes_filter(const AgentRun& r, const TimelineFilter& f) { + if (!f.apps.empty() && !contains(f.apps, r.app)) return false; + if (!f.statuses.empty() && !contains(f.statuses, r.status)) return false; + if (f.since_ts > 0 && r.started_at < f.since_ts) return false; + return true; +} + +std::vector filter_and_sort(const std::vector& runs, + const TimelineFilter& f) { + std::vector out; + out.reserve(runs.size()); + for (const auto& r : runs) { + if (passes_filter(r, f)) out.push_back(r); + } + std::sort(out.begin(), out.end(), [](const AgentRun& a, const AgentRun& b) { + if (a.started_at != b.started_at) return a.started_at > b.started_at; + return a.id < b.id; + }); + return out; +} + +std::string format_duration(int64_t started_at, int64_t finished_at) { + if (finished_at == 0) return "running"; + int64_t dur = finished_at - started_at; + if (dur < 0) return "—"; + + char buf[32]; + if (dur < 60) { + std::snprintf(buf, sizeof(buf), "%llds", (long long)dur); + } else if (dur < 3600) { + long long m = dur / 60; + long long s = dur % 60; + std::snprintf(buf, sizeof(buf), "%lldm%02llds", m, s); + } else { + long long h = dur / 3600; + long long m = (dur % 3600) / 60; + std::snprintf(buf, sizeof(buf), "%lldh%02lldm", h, m); + } + return std::string(buf); +} + +int status_color_token(const std::string& status) { + if (status == "pending") return 1; // info + if (status == "running") return 1; // info + if (status == "done") return 2; // success + if (status == "validated") return 2; // success + if (status == "merged") return 3; // primary + if (status == "aborted") return 5; // warning + if (status == "failed") return 6; // danger + return 0; // neutral +} + +std::string status_icon_id(const std::string& status) { + if (status == "pending") return TI_CLOCK_; + if (status == "running") return TI_LOADER_; + if (status == "done") return TI_CIRCLE_CHECK_; + if (status == "validated") return TI_CHECKS_; + if (status == "merged") return TI_GIT_MERGE_; + if (status == "aborted") return TI_BAN_; + if (status == "failed") return TI_CIRCLE_X_; + return TI_HOURGLASS_; +} + +std::string app_chip_hex(const std::string& app) { + // Hardcoded palette. Keep aligned with the apps' icon.accent in app.md. + if (app == "kanban_cpp") return "#a855f7"; // violet + if (app == "skill_tree") return "#0ea5e9"; // sky + if (app == "graph_explorer") return "#16a34a"; // green + if (app == "shaders_lab") return "#f97316"; // orange + if (app == "registry_dashboard") return "#0ea5e9"; + return "#5C5F66"; // neutral gray (matches fn_tokens::border_strong) +} + +} // namespace timeline +} // namespace fn_viz diff --git a/cpp/functions/viz/agent_runs_timeline_helpers.h b/cpp/functions/viz/agent_runs_timeline_helpers.h new file mode 100644 index 00000000..abe36d67 --- /dev/null +++ b/cpp/functions/viz/agent_runs_timeline_helpers.h @@ -0,0 +1,63 @@ +#pragma once +// +// agent_runs_timeline_helpers.h — pure helpers for the agent runs timeline panel. +// +// These helpers operate on AgentRun + TimelineFilter from agent_runs_timeline.h +// but never call ImGui. They are unit-testable in isolation (see +// cpp/tests/test_agent_runs_timeline.cpp). +// +// Issue 0118. + +#include "viz/agent_runs_timeline.h" + +#include +#include +#include + +namespace fn_viz { +namespace timeline { + +// True iff `r` matches every active sub-filter: +// - filter.apps empty => any app +// - filter.statuses empty => any status +// - filter.since_ts == 0 => any started_at +// All filters are AND-combined. +bool passes_filter(const AgentRun& r, const TimelineFilter& f); + +// Returns a copy of `runs` with `passes_filter` applied and sorted by +// `started_at` descending (most recent first). Stable for runs with equal +// started_at: ties broken by id ascending. +std::vector filter_and_sort(const std::vector& runs, + const TimelineFilter& f); + +// Human-friendly duration string built from two unix-epoch SECOND timestamps. +// - finished_at == 0 => "running" +// - duration < 60s => "Ns" (e.g. "45s") +// - duration < 3600s => "MmSs" (e.g. "12m05s") +// - else => "HhMm" (e.g. "1h12m") +// Negative or inverted (finished < started) => "—". +std::string format_duration(int64_t started_at, int64_t finished_at); + +// Returns a small integer color token (0..7) per status. The render layer +// maps this token to an actual ImVec4 using fn_tokens::colors. +// +// pending -> 1 (info) +// running -> 1 (info) +// done -> 2 (success) +// validated -> 2 (success) +// merged -> 3 (primary) +// aborted -> 5 (warning) +// failed -> 6 (danger) +// -> 0 (neutral) +int status_color_token(const std::string& status); + +// Returns the TI_* macro string for the given status (already utf-8 encoded +// 3-byte sequence). Render layer uses these directly in ImGui::Text. +std::string status_icon_id(const std::string& status); + +// Hardcoded chip color hex for known app ids. Returns "#5C5F66" (border_strong +// / neutral gray) for unknown apps. Render layer parses the hex to ImVec4. +std::string app_chip_hex(const std::string& app); + +} // namespace timeline +} // namespace fn_viz From 048a4a1457e4a1ad95fd42af2c865be4fc0e15b3 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Mon, 18 May 2026 18:31:29 +0200 Subject: [PATCH 2/5] feat(viz): agent_runs_timeline ImGui panel + SSE stub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue 0118. fn_viz::render_agent_runs_timeline(TimelineState&): - Filtros: multi-select apps, multi-select statuses, Since (days). - Connection badge (● green / ◐ amber / ○ red) por state.connection_status. - Tabla 7 cols: status icon | app chip | issue/card | branch | dod badge | duration | started. Selectable SpanAllColumns dispara on_select callback. - Footer: contadores per-status sobre el set completo. Thread-safe: snapshot bajo runs_mutex al inicio del frame. SSE client NO implementado — poll_sse_runs() es stub documentado en .md ## Gotchas. Consumer puede usar http_request_cpp_core para polling fallback contra GET /api/runs hasta que un endpoint /api/runs/stream estable aparezca. Co-Authored-By: Claude Opus 4.7 (1M context) --- cpp/functions/viz/agent_runs_timeline.cpp | 280 ++++++++++++++++++++++ cpp/functions/viz/agent_runs_timeline.h | 82 +++++++ 2 files changed, 362 insertions(+) create mode 100644 cpp/functions/viz/agent_runs_timeline.cpp create mode 100644 cpp/functions/viz/agent_runs_timeline.h diff --git a/cpp/functions/viz/agent_runs_timeline.cpp b/cpp/functions/viz/agent_runs_timeline.cpp new file mode 100644 index 00000000..49aeae4d --- /dev/null +++ b/cpp/functions/viz/agent_runs_timeline.cpp @@ -0,0 +1,280 @@ +#include "viz/agent_runs_timeline.h" +#include "viz/agent_runs_timeline_helpers.h" +#include "core/icons_tabler.h" +#include "core/tokens.h" + +#include "imgui.h" + +#include +#include +#include +#include + +namespace fn_viz { + +namespace { + +// Map color token -> fn_tokens color. Mirrors timeline::status_color_token. +ImVec4 token_to_color(int t) { + using namespace fn_tokens::colors; + switch (t) { + case 1: return info; + case 2: return success; + case 3: return primary; + case 5: return warning; + case 6: return error; + case 0: + default: return text_muted; + } +} + +// Parse "#rrggbb" to ImVec4. On bad input returns text_muted. +ImVec4 parse_hex(const std::string& h) { + if (h.size() != 7 || h[0] != '#') return fn_tokens::colors::text_muted; + auto hex2 = [](const std::string& s, size_t i) -> int { + int v = 0; + for (int k = 0; k < 2; ++k) { + char c = s[i + k]; + int d = (c >= '0' && c <= '9') ? c - '0' + : (c >= 'a' && c <= 'f') ? 10 + c - 'a' + : (c >= 'A' && c <= 'F') ? 10 + c - 'A' + : -1; + if (d < 0) return -1; + v = v * 16 + d; + } + return v; + }; + int r = hex2(h, 1), g = hex2(h, 3), b = hex2(h, 5); + if (r < 0 || g < 0 || b < 0) return fn_tokens::colors::text_muted; + return ImVec4(r / 255.0f, g / 255.0f, b / 255.0f, 1.0f); +} + +void draw_app_chip(const std::string& app) { + ImVec4 c = parse_hex(timeline::app_chip_hex(app)); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(c.x, c.y, c.z, 0.20f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(c.x, c.y, c.z, 0.30f)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(c.x, c.y, c.z, 0.40f)); + ImGui::PushStyleColor(ImGuiCol_Text, c); + ImGui::SmallButton(app.empty() ? "?" : app.c_str()); + ImGui::PopStyleColor(4); +} + +void draw_dod_badge(int done, int validated, int total) { + char buf[48]; + std::snprintf(buf, sizeof(buf), "%d/%d/%d", done, validated, total); + ImVec4 c = (validated >= total && total > 0) + ? fn_tokens::colors::success + : (done > 0 ? fn_tokens::colors::info : fn_tokens::colors::text_muted); + ImGui::TextColored(c, "%s", buf); +} + +std::string fmt_started(int64_t ts) { + if (ts <= 0) return "—"; + std::time_t t = (std::time_t)ts; + std::tm tm{}; +#if defined(_WIN32) + localtime_s(&tm, &t); +#else + localtime_r(&t, &tm); +#endif + char buf[32]; + std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M", &tm); + return std::string(buf); +} + +void draw_filters(TimelineState& state, + const std::vector& known_apps, + const std::vector& known_statuses) { + if (ImGui::Button(TI_FILTER " Apps")) ImGui::OpenPopup("##apps_filter"); + if (ImGui::BeginPopup("##apps_filter")) { + for (const auto& a : known_apps) { + bool sel = false; + for (const auto& s : state.filter.apps) if (s == a) { sel = true; break; } + if (ImGui::Checkbox(a.c_str(), &sel)) { + if (sel) state.filter.apps.push_back(a); + else { + state.filter.apps.erase( + std::remove(state.filter.apps.begin(), state.filter.apps.end(), a), + state.filter.apps.end()); + } + } + } + ImGui::EndPopup(); + } + ImGui::SameLine(); + if (ImGui::Button(TI_FILTER " Status")) ImGui::OpenPopup("##status_filter"); + if (ImGui::BeginPopup("##status_filter")) { + for (const auto& s : known_statuses) { + bool sel = false; + for (const auto& x : state.filter.statuses) if (x == s) { sel = true; break; } + if (ImGui::Checkbox(s.c_str(), &sel)) { + if (sel) state.filter.statuses.push_back(s); + else { + state.filter.statuses.erase( + std::remove(state.filter.statuses.begin(), state.filter.statuses.end(), s), + state.filter.statuses.end()); + } + } + } + ImGui::EndPopup(); + } + ImGui::SameLine(); + int days = (state.filter.since_ts == 0) + ? 0 + : (int)((std::time(nullptr) - state.filter.since_ts) / 86400); + if (ImGui::InputInt("Since (days)", &days, 1, 7)) { + if (days <= 0) state.filter.since_ts = 0; + else state.filter.since_ts = std::time(nullptr) - (int64_t)days * 86400; + } +} + +void draw_connection_badge(const std::string& status) { + ImVec4 c; + const char* sym; + if (status == "connected") { + c = fn_tokens::colors::success; + sym = "\xe2\x97\x8f"; // ● U+25CF + } else if (status == "connecting") { + c = fn_tokens::colors::warning; + sym = "\xe2\x97\x90"; // ◐ + } else { + c = fn_tokens::colors::error; + sym = "\xe2\x97\x8b"; // ○ U+25CB + } + ImGui::TextColored(c, "%s %s", sym, + status.empty() ? "disconnected" : status.c_str()); +} + +} // namespace + +void render_agent_runs_timeline(TimelineState& state) { + // Snapshot under the lock so the rest of the frame is lock-free. + std::vector runs_copy; + std::string conn_copy; + { + std::lock_guard lk(state.runs_mutex); + runs_copy = state.runs; + conn_copy = state.connection_status; + } + + // Build "known" sets from the runs themselves so the dropdowns reflect + // whatever the source emits, even if it grows beyond the docs list. + std::vector known_apps; + std::vector known_statuses; + { + std::unordered_map apps_seen, status_seen; + for (const auto& r : runs_copy) { + if (!r.app.empty() && !apps_seen[r.app]++) known_apps.push_back(r.app); + if (!r.status.empty() && !status_seen[r.status]++) known_statuses.push_back(r.status); + } + } + + draw_filters(state, known_apps, known_statuses); + ImGui::SameLine(); + ImGui::Dummy(ImVec2(16, 0)); + ImGui::SameLine(); + draw_connection_badge(conn_copy); + + ImGui::Separator(); + + auto rows = timeline::filter_and_sort(runs_copy, state.filter); + + if (ImGui::BeginTable("##agent_runs_table", 7, + ImGuiTableFlags_RowBg | ImGuiTableFlags_Borders | + ImGuiTableFlags_Resizable | ImGuiTableFlags_ScrollY)) { + ImGui::TableSetupColumn("", ImGuiTableColumnFlags_WidthFixed, 24.0f); + ImGui::TableSetupColumn("app", ImGuiTableColumnFlags_WidthFixed, 130.0f); + ImGui::TableSetupColumn("issue/card",ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("branch", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("dod", ImGuiTableColumnFlags_WidthFixed, 70.0f); + ImGui::TableSetupColumn("duration", ImGuiTableColumnFlags_WidthFixed, 90.0f); + ImGui::TableSetupColumn("started", ImGuiTableColumnFlags_WidthFixed, 130.0f); + ImGui::TableHeadersRow(); + + for (const auto& r : rows) { + ImGui::TableNextRow(); + + // Whole-row highlight + selectable via the first cell spanning. + ImGui::TableSetColumnIndex(0); + bool selected = (state.selected_run_id == r.id); + ImGui::PushID(r.id.c_str()); + + // Icon coloured by status. + ImVec4 c = token_to_color(timeline::status_color_token(r.status)); + ImGui::TextColored(c, "%s", timeline::status_icon_id(r.status).c_str()); + + // Make the row clickable: invisible button spanning all columns. + // Simpler — use Selectable on cell 1 with SpanAllColumns. + ImGui::TableSetColumnIndex(1); + char label[96]; + std::snprintf(label, sizeof(label), "##row_%s", r.id.c_str()); + if (ImGui::Selectable(label, selected, + ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowOverlap)) { + state.selected_run_id = r.id; + if (state.on_select) state.on_select(r.id); + } + ImGui::SameLine(); + draw_app_chip(r.app); + + ImGui::TableSetColumnIndex(2); + if (!r.issue_id.empty() && !r.card_id.empty()) { + ImGui::Text("%s / %s", r.issue_id.c_str(), r.card_id.c_str()); + } else if (!r.issue_id.empty()) { + ImGui::TextUnformatted(r.issue_id.c_str()); + } else { + ImGui::TextUnformatted(r.card_id.c_str()); + } + + ImGui::TableSetColumnIndex(3); + ImGui::TextUnformatted(r.branch.c_str()); + + ImGui::TableSetColumnIndex(4); + draw_dod_badge(r.dod_done, r.dod_validated, r.dod_total); + + ImGui::TableSetColumnIndex(5); + std::string dur = timeline::format_duration(r.started_at, r.finished_at); + ImGui::TextColored( + r.finished_at == 0 ? fn_tokens::colors::info : fn_tokens::colors::text, + "%s", dur.c_str()); + + ImGui::TableSetColumnIndex(6); + ImGui::TextUnformatted(fmt_started(r.started_at).c_str()); + + ImGui::PopID(); + } + + ImGui::EndTable(); + } + + // Footer counts per status (over the FULL set, not the filtered one — gives + // a global sense even when filters narrow the table). + ImGui::Separator(); + std::unordered_map counts; + for (const auto& r : runs_copy) counts[r.status]++; + bool first = true; + for (const auto& s : {"pending", "running", "done", "validated", "merged", "aborted", "failed"}) { + int n = counts[s]; + if (!first) { ImGui::SameLine(); ImGui::TextUnformatted(" "); ImGui::SameLine(); } + first = false; + ImVec4 c = token_to_color(timeline::status_color_token(s)); + ImGui::TextColored(c, "%s: %d", s, n); + } +} + +void poll_sse_runs(TimelineState& state) { + // STUB. See header comment + agent_runs_timeline.md ## Gotchas. + // + // Intended future wiring: + // 1. spawn background thread with libcurl multi handle on state.sse_url + // - parse "data:" lines, json-decode AgentRun events + // - lock state.runs_mutex; upsert by run.id; set connection_status + // 2. OR call fn_core::http_request("GET", url + "?since=...", ...) + // every N seconds and full-refresh state.runs + // + // For now: noop so that consumers that wire poll_sse_runs() in their + // main loop don't crash. Consumers populate state.runs manually + // (fixtures, in-process orchestrator) until a real SSE issue lands. + (void)state; +} + +} // namespace fn_viz diff --git a/cpp/functions/viz/agent_runs_timeline.h b/cpp/functions/viz/agent_runs_timeline.h new file mode 100644 index 00000000..cfe14f47 --- /dev/null +++ b/cpp/functions/viz/agent_runs_timeline.h @@ -0,0 +1,82 @@ +#pragma once +// +// agent_runs_timeline.h — ImGui panel: cross-app timeline of agent runs. +// +// API surface declared per issue 0118. Render is impure (uses ImGui); pure +// helpers live in agent_runs_timeline_helpers.{h,cpp} so unit tests can +// exercise filter / sort / formatting without a GL context. +// +// Typical wiring (consumer side): +// +// fn_viz::TimelineState g_state; +// g_state.sse_url = "http://127.0.0.1:8497/api/runs/stream"; +// g_state.on_select = [](const std::string& id){ open_detail_panel(id); }; +// +// ImGui::Begin("Agent runs"); +// fn_viz::render_agent_runs_timeline(g_state); +// ImGui::End(); +// +// // Whenever a worker thread (real SSE client, polling fallback, fixture) +// // wants to update the list: +// { +// std::lock_guard lk(g_state.runs_mutex); +// g_state.runs = new_runs; +// g_state.connection_status = "connected"; +// } +// +// SSE client itself is OUT OF SCOPE for this issue — see the documented stub +// in poll_sse_runs() and the .md ## Gotchas section. + +#include +#include +#include +#include +#include + +namespace fn_viz { + +struct AgentRun { + std::string id; + std::string app; // kanban_cpp | skill_tree | ... + std::string issue_id; + std::string card_id; + std::string branch; + std::string status; // pending|running|done|validated|merged|aborted|failed + int64_t started_at = 0; // unix epoch seconds + int64_t finished_at = 0; // 0 if still running + int dod_total = 0; + int dod_done = 0; + int dod_validated = 0; +}; + +struct TimelineFilter { + std::vector apps; + std::vector statuses; + int64_t since_ts = 0; // 0 means "no lower bound" +}; + +struct TimelineState { + std::string sse_url; + std::vector runs; + TimelineFilter filter; + std::function on_select; + std::string selected_run_id; + std::string connection_status; // "connected"|"disconnected"|"connecting" + std::mutex runs_mutex; // SSE thread writes, UI reads +}; + +// Renders the timeline INLINE inside the current ImGui window. The caller +// owns ImGui::Begin/End — this function only draws filters, badge, table +// and footer counts. Safe to call every frame; cost is O(N) over runs. +void render_agent_runs_timeline(TimelineState& state); + +// STUB. Documented in agent_runs_timeline.md ## Gotchas. +// Today this is a no-op. The intent is that a future issue wires either: +// (a) a real SSE client (libcurl multi + custom read callback), or +// (b) a polling fallback that calls fn_core::http_request("GET", url, ...) +// every N seconds and replaces state.runs under runs_mutex. +// Until then, the consumer is responsible for populating state.runs (e.g. +// test fixtures, or an in-process orchestrator that owns the data already). +void poll_sse_runs(TimelineState& state); + +} // namespace fn_viz From af26c78e706772b05148328bfe7621df6d4adb3a Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Mon, 18 May 2026 18:31:37 +0200 Subject: [PATCH 3/5] =?UTF-8?q?test(viz):=20test=5Fagent=5Fruns=5Ftimeline?= =?UTF-8?q?=20=E2=80=94=2017=20cases=20/=2053=20assertions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue 0118. Catch2 coverage de los helpers puros: - passes_filter: filtro vacio, filter por app, app+status combinado, since_ts - filter_and_sort: orden started_at DESC, combina filtro + sort - format_duration: running, segundos, minutos, horas, timestamps invertidos - status_color_token: mapping conocido + fallback neutral - status_icon_id: no-empty + distinto entre statuses - app_chip_hex: apps conocidas + fallback gris Mock fixture de 5 AgentRun mixtos en make_mock(). Co-Authored-By: Claude Opus 4.7 (1M context) --- cpp/tests/CMakeLists.txt | 4 + cpp/tests/test_agent_runs_timeline.cpp | 183 +++++++++++++++++++++++++ 2 files changed, 187 insertions(+) create mode 100644 cpp/tests/test_agent_runs_timeline.cpp diff --git a/cpp/tests/CMakeLists.txt b/cpp/tests/CMakeLists.txt index 9fa887bb..0b9ac6cd 100644 --- a/cpp/tests/CMakeLists.txt +++ b/cpp/tests/CMakeLists.txt @@ -297,3 +297,7 @@ target_compile_definitions(test_visual PRIVATE "FN_TEST_REPO_ROOT=\"${CMAKE_SOURCE_DIR}/..\"") # Asegura que primitives_gallery existe antes de correr el test. add_dependencies(test_visual primitives_gallery) + +# --- Issue 0118 — agent_runs_timeline: helpers puros ---------- +add_fn_test(test_agent_runs_timeline test_agent_runs_timeline.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../functions/viz/agent_runs_timeline_helpers.cpp) diff --git a/cpp/tests/test_agent_runs_timeline.cpp b/cpp/tests/test_agent_runs_timeline.cpp new file mode 100644 index 00000000..ca1a856b --- /dev/null +++ b/cpp/tests/test_agent_runs_timeline.cpp @@ -0,0 +1,183 @@ +// Tests for the pure helpers of fn_viz::agent_runs_timeline (issue 0118). +// +// Render path (ImGui) is intentionally NOT tested here — only filter / sort / +// duration / status mapping logic. + +#define CATCH_CONFIG_MAIN +#include "catch_amalgamated.hpp" + +#include "viz/agent_runs_timeline_helpers.h" + +#include +#include +#include + +using fn_viz::AgentRun; +using fn_viz::TimelineFilter; +using namespace fn_viz::timeline; + +static std::vector make_mock() { + std::vector runs; + AgentRun r; + + r = {}; r.id = "r1"; r.app = "kanban_cpp"; r.issue_id = "0118"; r.card_id = "c1"; + r.branch = "issue/0118"; r.status = "running"; r.started_at = 1000; + r.finished_at = 0; r.dod_total = 5; r.dod_done = 2; r.dod_validated = 0; + runs.push_back(r); + + r = {}; r.id = "r2"; r.app = "skill_tree"; r.issue_id = "0107"; r.card_id = "c2"; + r.branch = "issue/0107"; r.status = "done"; r.started_at = 800; + r.finished_at = 860; r.dod_total = 3; r.dod_done = 3; r.dod_validated = 0; + runs.push_back(r); + + r = {}; r.id = "r3"; r.app = "kanban_cpp"; r.issue_id = "0100"; r.card_id = "c3"; + r.branch = "issue/0100"; r.status = "merged"; r.started_at = 600; + r.finished_at = 4200; r.dod_total = 4; r.dod_done = 4; r.dod_validated = 4; + runs.push_back(r); + + r = {}; r.id = "r4"; r.app = "skill_tree"; r.issue_id = "0090"; r.card_id = "c4"; + r.branch = "quick/x"; r.status = "failed"; r.started_at = 1200; + r.finished_at = 1205; r.dod_total = 2; r.dod_done = 0; r.dod_validated = 0; + runs.push_back(r); + + r = {}; r.id = "r5"; r.app = "graph_explorer"; r.issue_id = "0080"; r.card_id = "c5"; + r.branch = "issue/0080"; r.status = "validated"; r.started_at = 1500; + r.finished_at = 5000; r.dod_total = 6; r.dod_done = 6; r.dod_validated = 5; + runs.push_back(r); + + return runs; +} + +TEST_CASE("passes_filter — empty filter matches everything", "[timeline]") { + auto runs = make_mock(); + TimelineFilter f; + for (const auto& r : runs) REQUIRE(passes_filter(r, f)); +} + +TEST_CASE("passes_filter — app filter", "[timeline]") { + auto runs = make_mock(); + TimelineFilter f; + f.apps = {"kanban_cpp"}; + int n = 0; + for (const auto& r : runs) if (passes_filter(r, f)) ++n; + REQUIRE(n == 2); + + f.apps = {"kanban_cpp", "graph_explorer"}; + n = 0; + for (const auto& r : runs) if (passes_filter(r, f)) ++n; + REQUIRE(n == 3); +} + +TEST_CASE("passes_filter — status filter combined with app", "[timeline]") { + auto runs = make_mock(); + TimelineFilter f; + f.apps = {"kanban_cpp"}; + f.statuses = {"merged"}; + int n = 0; + AgentRun match{}; + for (const auto& r : runs) { + if (passes_filter(r, f)) { ++n; match = r; } + } + REQUIRE(n == 1); + REQUIRE(match.id == "r3"); +} + +TEST_CASE("passes_filter — since_ts lower bound", "[timeline]") { + auto runs = make_mock(); + TimelineFilter f; + f.since_ts = 1000; // keep r1 (1000), r4 (1200), r5 (1500) + int n = 0; + for (const auto& r : runs) if (passes_filter(r, f)) ++n; + REQUIRE(n == 3); +} + +TEST_CASE("filter_and_sort — sorts by started_at desc", "[timeline]") { + auto runs = make_mock(); + TimelineFilter f; // no filter + auto out = filter_and_sort(runs, f); + REQUIRE(out.size() == 5); + // expected order by started_at desc: r5(1500), r4(1200), r1(1000), r2(800), r3(600) + REQUIRE(out[0].id == "r5"); + REQUIRE(out[1].id == "r4"); + REQUIRE(out[2].id == "r1"); + REQUIRE(out[3].id == "r2"); + REQUIRE(out[4].id == "r3"); +} + +TEST_CASE("filter_and_sort — combines filter and sort", "[timeline]") { + auto runs = make_mock(); + TimelineFilter f; + f.apps = {"kanban_cpp"}; + auto out = filter_and_sort(runs, f); + REQUIRE(out.size() == 2); + REQUIRE(out[0].id == "r1"); // 1000 + REQUIRE(out[1].id == "r3"); // 600 +} + +TEST_CASE("format_duration — running when finished_at=0", "[timeline]") { + REQUIRE(format_duration(1000, 0) == "running"); +} + +TEST_CASE("format_duration — seconds", "[timeline]") { + REQUIRE(format_duration(1000, 1045) == "45s"); + REQUIRE(format_duration(1000, 1000) == "0s"); +} + +TEST_CASE("format_duration — minutes + seconds", "[timeline]") { + REQUIRE(format_duration(0, 65) == "1m05s"); + REQUIRE(format_duration(0, 305) == "5m05s"); + REQUIRE(format_duration(0, 3599) == "59m59s"); +} + +TEST_CASE("format_duration — hours + minutes", "[timeline]") { + REQUIRE(format_duration(0, 3600) == "1h00m"); + REQUIRE(format_duration(0, 3600 + 720) == "1h12m"); + REQUIRE(format_duration(0, 7200 + 1800) == "2h30m"); +} + +TEST_CASE("format_duration — inverted timestamps", "[timeline]") { + REQUIRE(format_duration(2000, 1000) == "—"); +} + +TEST_CASE("status_color_token — known statuses", "[timeline]") { + REQUIRE(status_color_token("pending") == 1); + REQUIRE(status_color_token("running") == 1); + REQUIRE(status_color_token("done") == 2); + REQUIRE(status_color_token("validated") == 2); + REQUIRE(status_color_token("merged") == 3); + REQUIRE(status_color_token("aborted") == 5); + REQUIRE(status_color_token("failed") == 6); +} + +TEST_CASE("status_color_token — unknown falls back to neutral", "[timeline]") { + REQUIRE(status_color_token("") == 0); + REQUIRE(status_color_token("zombie") == 0); +} + +TEST_CASE("status_icon_id — non-empty for known statuses", "[timeline]") { + for (const std::string s : {"pending", "running", "done", "validated", + "merged", "aborted", "failed"}) { + REQUIRE_FALSE(status_icon_id(s).empty()); + } + // unknown still returns a glyph (hourglass), never empty + REQUIRE_FALSE(status_icon_id("nope").empty()); +} + +TEST_CASE("status_icon_id — distinct for distinct statuses", "[timeline]") { + auto a = status_icon_id("running"); + auto b = status_icon_id("done"); + auto c = status_icon_id("failed"); + REQUIRE(a != b); + REQUIRE(b != c); + REQUIRE(a != c); +} + +TEST_CASE("app_chip_hex — known apps", "[timeline]") { + REQUIRE(app_chip_hex("kanban_cpp") == "#a855f7"); + REQUIRE(app_chip_hex("skill_tree") == "#0ea5e9"); +} + +TEST_CASE("app_chip_hex — unknown falls back to gray", "[timeline]") { + REQUIRE(app_chip_hex("never_seen") == "#5C5F66"); + REQUIRE(app_chip_hex("") == "#5C5F66"); +} From 4ab3678b4559fe5d7c20a7389968282073f6797b Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Mon, 18 May 2026 18:31:45 +0200 Subject: [PATCH 4/5] =?UTF-8?q?docs(viz):=20agent=5Fruns=5Ftimeline.md=20?= =?UTF-8?q?=E2=80=94=20frontmatter=20+=20self-doc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue 0118. Frontmatter completo (kind, lang, domain, version, purity, signature, params, tags=agents/timeline/sse/imgui/viz/panel, uses_functions=http_request_cpp_core, error_type, tested). Secciones obligatorias por contrato self-doc: - ## Ejemplo — wiring concreto con TimelineState g_state + lock + render - ## Cuando usarla — dashboard cross-app de agentes - ## Gotchas — SSE stub, ts en segundos, mutex obligatorio, no autoscroll, ImGuiSelectableFlags_AllowOverlap (rename desde AllowItemOverlap), app chip hex hardcoded, since_ts no determinista en tests Co-Authored-By: Claude Opus 4.7 (1M context) --- cpp/functions/viz/agent_runs_timeline.md | 113 +++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 cpp/functions/viz/agent_runs_timeline.md diff --git a/cpp/functions/viz/agent_runs_timeline.md b/cpp/functions/viz/agent_runs_timeline.md new file mode 100644 index 00000000..b23e5a90 --- /dev/null +++ b/cpp/functions/viz/agent_runs_timeline.md @@ -0,0 +1,113 @@ +--- +name: agent_runs_timeline +kind: function +lang: cpp +domain: viz +version: 0.1.0 +description: "Panel ImGui timeline de agent runs cross-app, filtros + sort + click row + connection badge" +tags: [agents, timeline, sse, imgui, viz, panel] +purity: impure +signature: "fn_viz::render_agent_runs_timeline(state) — ImGui draw call" +params: + - name: state + desc: "TimelineState con runs, filter, callbacks. Mutex protege runs ante el thread del SSE/poll" +output: "void — pinta el panel inline en el current ImGui window" +uses_functions: + - http_request_cpp_core +uses_types: [] +returns: [] +returns_optional: false +error_type: error_go_core +imports: [imgui] +example: "ver ## Ejemplo abajo; demo visual TODO en cpp/apps/primitives_gallery/demos_viz.cpp" +tested: true +tests: [test_agent_runs_timeline] +test_file_path: "cpp/tests/test_agent_runs_timeline.cpp" +file_path: "cpp/functions/viz/agent_runs_timeline.cpp" +framework: "imgui" +--- + +# agent_runs_timeline + +Panel ImGui que pinta una tabla cross-app de "agent runs" (ejecuciones de +`fn-orquestador` / `/autonomous-task` / hooks que disparan agentes) con: + +- Filtros: multi-select de **apps**, multi-select de **statuses**, slider + `Since (days)` para acotar `started_at`. +- **Connection badge** (● verde / ○ rojo / ◐ amarillo) segun + `state.connection_status`. +- Tabla ordenada por `started_at` DESC con columnas: + status icon | app (chip coloreado) | issue/card | branch | dod + (badge `done/validated/total`) | duration | started (humano). +- Row click → setea `state.selected_run_id` e invoca `state.on_select(id)` + si existe. +- Footer: contadores por status sobre el set FULL (no el filtrado). + +La logica pura (filter / sort / formato duration / mapping status→color/icon +/ app→chip hex) vive en `agent_runs_timeline_helpers.{h,cpp}` y es testable +sin contexto ImGui (ver `cpp/tests/test_agent_runs_timeline.cpp`). + +## Ejemplo + +```cpp +#include "viz/agent_runs_timeline.h" + +static fn_viz::TimelineState g_runs_state; + +// En setup (una vez): +g_runs_state.sse_url = "http://127.0.0.1:8497/api/runs/stream"; +g_runs_state.on_select = [](const std::string& run_id) { + open_run_detail_window(run_id); +}; + +// Por algun mecanismo (test fixture, http poll, future SSE) — poblar runs: +{ + std::lock_guard lk(g_runs_state.runs_mutex); + g_runs_state.runs = { + {"r1", "kanban_cpp", "0118", "c1", "issue/0118", "running", + /*started*/ 1731920000, /*finished*/ 0, /*dod*/ 5, 2, 0}, + }; + g_runs_state.connection_status = "connected"; +} + +// En render (cada frame): +ImGui::Begin("Agent runs"); +fn_viz::render_agent_runs_timeline(g_runs_state); +ImGui::End(); +``` + +## Cuando usarla + +Cuando construyas un dashboard cross-app que exponga el estado de los agentes +(orquestador, autonomous-task, hooks reactivos): registry_dashboard, kanban, +agent_runner_ui. La quieres siempre que el usuario necesite una vista unica +"que esta corriendo / que merge / que fallo" sin abrir N apps. Tambien util +como panel `Recent runs` dentro de una app que dispara agentes propios. + +## Gotchas + +- **SSE client NO esta implementado.** `poll_sse_runs()` es un stub + documentado. Hoy el consumer tiene que poblar `state.runs` por su cuenta + (fixture, orquestador in-proc, o polling manual con + `http_request_cpp_core` contra `GET /api/runs`). Cuando exista un endpoint + estable `/api/runs/stream`, conectar via libcurl multi en thread aparte y + empujar updates bajo `state.runs_mutex`. +- **Timestamps en segundos epoch** (no ms). `format_duration` asume `int64_t` + unix-epoch seconds; si tu fuente emite ms, divide entre 1000 antes de + rellenar `started_at`/`finished_at`. +- **`runs_mutex` es obligatorio** cuando algun thread distinto del UI escribe + `state.runs` o `state.connection_status`. El render hace un snapshot bajo + lock al principio del frame para no bloquear el resto del dibujo. +- **No autoescroll**. Si la tabla crece y queres mantener "lo mas reciente + visible", anade tu scroll-to-top tras detectar `runs.size()` cambia. + Decision deliberada: muchas vistas prefieren no saltar mientras lees. +- **Selectable + SpanAllColumns**: usa `ImGuiSelectableFlags_AllowOverlap` + (Dear ImGui >= 1.91). En forks viejos pasaba a llamarse + `AllowItemOverlap` — si tu vendor de imgui es viejo, sustituye en + `agent_runs_timeline.cpp`. +- **App chip colors** estan hardcodeados en `app_chip_hex`. Anadir entrada + cuando salga una app nueva; sin entrada cae a gris neutral. Mantener el + hex aligned con `icon.accent` del `app.md` de cada app C++. +- **Filter "Since (days)"** convierte a/desde epoch usando `std::time(nullptr)`: + no determinista en tests. Por eso los tests del helper pasan `since_ts` + directamente en segundos epoch ficticios (ej. `f.since_ts = 1000`). From 9440470a73963eadcef82150df1ed9bbbc996b8c Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Mon, 18 May 2026 18:31:53 +0200 Subject: [PATCH 5/5] =?UTF-8?q?docs:=20cerrar=20issue=200118=20=E2=80=94?= =?UTF-8?q?=20agent=5Fruns=5Ftimeline=5Fcpp=5Fviz=20registrado?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Funcion + helpers + tests + .md indexados. - cpp/functions/viz/agent_runs_timeline.{h,cpp,md} - cpp/functions/viz/agent_runs_timeline_helpers.{h,cpp} - cpp/tests/test_agent_runs_timeline.cpp (17 cases, 53 assertions, PASS) - cpp/tests/CMakeLists.txt: add_fn_test test_agent_runs_timeline fn index: 1283 functions (+1). Co-Authored-By: Claude Opus 4.7 (1M context) --- dev/issues/{ => completed}/0118-agent-runs-timeline-cpp.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename dev/issues/{ => completed}/0118-agent-runs-timeline-cpp.md (100%) diff --git a/dev/issues/0118-agent-runs-timeline-cpp.md b/dev/issues/completed/0118-agent-runs-timeline-cpp.md similarity index 100% rename from dev/issues/0118-agent-runs-timeline-cpp.md rename to dev/issues/completed/0118-agent-runs-timeline-cpp.md