Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 73f80d9c6c | |||
| e4a1c20fc2 | |||
| 9cade2f2f8 | |||
| 18b5ffdfd9 | |||
| aa88a1cb4a | |||
| c8b7adf81d | |||
| af83e571c6 | |||
| b61626b759 | |||
| 47d80b4479 | |||
| ea3c62d2f2 | |||
| 91c4eed686 |
@@ -17,6 +17,12 @@ if(TARGET fn_table_viz)
|
||||
target_link_libraries(agents_dashboard PRIVATE fn_table_viz)
|
||||
endif()
|
||||
|
||||
# fn_module_data_table: render_grid_stage0 + ColumnSpec + TableEvent (issue 0131)
|
||||
# Module lives in cpp/modules/data_table/. Optional: #if __has_include guard applies.
|
||||
if(TARGET fn_module_data_table)
|
||||
target_link_libraries(agents_dashboard PRIVATE fn_module_data_table)
|
||||
endif()
|
||||
|
||||
if(WIN32)
|
||||
set_target_properties(agents_dashboard PROPERTIES WIN32_EXECUTABLE TRUE)
|
||||
# secret_store.cpp uses CryptProtectData / CryptUnprotectData (crypt32)
|
||||
|
||||
@@ -7,13 +7,14 @@ icon:
|
||||
phosphor: "robot"
|
||||
accent: "#8b5cf6"
|
||||
tags: [agents, dashboard, sse, http-client, imgui]
|
||||
version: 0.1.0
|
||||
version: 0.2.0
|
||||
uses_functions:
|
||||
- http_request_cpp_core
|
||||
- http_get_json_cpp_core
|
||||
- sse_client_cpp_core
|
||||
- secret_store_cpp_infra
|
||||
- logger_cpp_core
|
||||
- data_table_cpp_viz
|
||||
uses_types: []
|
||||
framework: "imgui"
|
||||
entry_point: "main.cpp"
|
||||
@@ -38,7 +39,7 @@ Frontend C++ ImGui para gestionar agentes Matrix de agents_and_robots via HTTPS
|
||||
## Panels
|
||||
|
||||
- **Connection** — base_url + apikey input (masked), Test button, LED status SSE. Save credentials cifradas en `local_files/agents_dashboard.db`.
|
||||
- **Agents** — tabla con id, status (icon colored), uptime, msg_24h, botones Start/Stop/Restart/Logs por fila.
|
||||
- **Agents** — tabla data_table_cpp_viz (render_grid_stage0) con 11 columnas: Status(Badge), ID, Name, Uptime, Msg/24h, Start/Stop/Restart/Clear Memory/Del Cache/Logs (Buttons). Modals de confirmacion para acciones destructivas.
|
||||
- **Logs** — selector agente + tail buffer SSE (5000 lineas), autoscroll, pause.
|
||||
- **Status Feed** — panel colapsable con eventos del `/sse/status` en tiempo real.
|
||||
|
||||
@@ -73,7 +74,9 @@ cmake --build cpp/build/windows --target agents_dashboard -j
|
||||
- `sse_client_cpp_core` — /sse/agents/{id}/logs + /sse/status
|
||||
- `secret_store_cpp_infra` — DPAPI Windows / XOR Linux para apikey en SQLite
|
||||
- `logger_cpp_core` — logging en memoria + archivo
|
||||
- `data_table_cpp_viz` — render_grid_stage0 para tabla Agents con Badge/Button renderers
|
||||
|
||||
## Capability growth log
|
||||
|
||||
v0.1.0 (2026-05-22) — Paneles Connection + Agents + Logs + Status Feed. HTTPS+apikey, SSE reconnect, DPAPI credentials.
|
||||
v0.2.0 (2026-05-22) — Migrado tabla Agents a data_table_cpp_viz (issue 0131). Botones Start/Stop/Restart/Clear Memory/Del Cache/Logs por fila. Endpoints clear_memory + delete_cache. Campos uptime_seconds + messages_24h reales.
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
//
|
||||
// Panels:
|
||||
// Connection — base_url + apikey input, Test button, SSE status LED
|
||||
// Agents — data table (id, status, uptime, msg_24h, actions)
|
||||
// Agents — data table (id, status, uptime, msg_24h, actions) via data_table_cpp_viz
|
||||
// Logs — SSE tail buffer for /sse/agents/{id}/logs
|
||||
// Status Feed — SSE events from /sse/status (collapsible)
|
||||
//
|
||||
// Issue 0129. Registry functions used:
|
||||
// Issue 0129 + 0131. Registry functions used:
|
||||
// http_request_cpp_core, http_get_json_cpp_core,
|
||||
// sse_client_cpp_core, data_table_cpp_viz, logger_cpp_core,
|
||||
// secret_store_cpp_infra
|
||||
@@ -21,6 +21,12 @@
|
||||
#include "infra/secret_store.h"
|
||||
#include "nlohmann/json.hpp"
|
||||
|
||||
// data_table_cpp_viz: render_grid_stage0 + declarative column specs (issue 0131)
|
||||
#if __has_include("viz/data_table_grid.h")
|
||||
# include "viz/data_table_grid.h"
|
||||
# define HAS_DATA_TABLE 1
|
||||
#endif
|
||||
|
||||
// SQLite (vendored via fn_framework)
|
||||
#include <sqlite3.h>
|
||||
|
||||
@@ -34,6 +40,16 @@
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
#ifdef _WIN32
|
||||
# ifndef NOMINMAX
|
||||
# define NOMINMAX
|
||||
# endif
|
||||
# ifndef WIN32_LEAN_AND_MEAN
|
||||
# define WIN32_LEAN_AND_MEAN
|
||||
# endif
|
||||
# include <windows.h>
|
||||
#endif
|
||||
#include <vector>
|
||||
|
||||
using json = nlohmann::json;
|
||||
@@ -77,8 +93,8 @@ struct AgentRow {
|
||||
struct AppState {
|
||||
// Connection
|
||||
char base_url[512] = "https://agents.organic-machine.com";
|
||||
char apikey_buf[256] = "";
|
||||
bool apikey_masked = true;
|
||||
char apikey_buf[256] = ""; // populated at startup from env OR `pass agentes/api-key`
|
||||
std::string apikey_source; // "env" | "pass" | "missing"
|
||||
bool connected = false;
|
||||
std::string connect_error;
|
||||
long long last_fetch_ms = 0;
|
||||
@@ -115,6 +131,16 @@ struct AppState {
|
||||
std::mutex action_mu;
|
||||
std::string action_feedback;
|
||||
long long action_feedback_ts = 0;
|
||||
|
||||
// Confirmation modals for destructive actions (clear_memory, delete_cache)
|
||||
std::string confirm_agent_id; // agent being confirmed
|
||||
std::string confirm_action; // "clear_memory" | "delete_cache"
|
||||
bool confirm_open = false;
|
||||
|
||||
#ifdef HAS_DATA_TABLE
|
||||
// Per-panel data_table State for the agents grid
|
||||
data_table::State agents_tbl_state;
|
||||
#endif
|
||||
};
|
||||
|
||||
static AppState g_state;
|
||||
@@ -160,53 +186,134 @@ static bool db_open(AppState& s) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// db_save_connection persists ONLY base_url. apikey lives in env var (sourced
|
||||
// from `pass agentes/api-key`), never on disk.
|
||||
static void db_save_connection(AppState& s) {
|
||||
if (!s.db) return;
|
||||
auto blob = fn_secret::encrypt(s.apikey_buf);
|
||||
if (blob.empty()) {
|
||||
fn_log::log_warn("[db] encrypt failed, not saving apikey");
|
||||
return;
|
||||
}
|
||||
// Upsert connection id=1
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
const char* sql =
|
||||
"INSERT INTO connections (id, name, base_url, apikey_encrypted, last_used)"
|
||||
" VALUES (1, 'default', ?, ?, strftime('%s','now'))"
|
||||
" VALUES (1, 'default', ?, x'00', strftime('%s','now'))"
|
||||
" ON CONFLICT(id) DO UPDATE SET"
|
||||
" base_url=excluded.base_url,"
|
||||
" apikey_encrypted=excluded.apikey_encrypted,"
|
||||
" last_used=excluded.last_used;";
|
||||
if (sqlite3_prepare_v2(s.db, sql, -1, &stmt, nullptr) != SQLITE_OK) return;
|
||||
sqlite3_bind_text(stmt, 1, s.base_url, -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_blob(stmt, 2, blob.data(), (int)blob.size(), SQLITE_TRANSIENT);
|
||||
sqlite3_step(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
fn_log::log_info("[db] connection saved");
|
||||
fn_log::log_info("[db] base_url saved");
|
||||
}
|
||||
|
||||
// db_load_connection reads base_url only. apikey is sourced from env var.
|
||||
static void db_load_connection(AppState& s) {
|
||||
if (!s.db) return;
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
const char* sql = "SELECT base_url, apikey_encrypted FROM connections WHERE id=1 LIMIT 1;";
|
||||
const char* sql = "SELECT base_url FROM connections WHERE id=1 LIMIT 1;";
|
||||
if (sqlite3_prepare_v2(s.db, sql, -1, &stmt, nullptr) != SQLITE_OK) return;
|
||||
if (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
const char* url = (const char*)sqlite3_column_text(stmt, 0);
|
||||
const void* blob = sqlite3_column_blob(stmt, 1);
|
||||
int blob_sz = sqlite3_column_bytes(stmt, 1);
|
||||
if (url) snprintf(s.base_url, sizeof(s.base_url), "%s", url);
|
||||
if (blob && blob_sz > 0) {
|
||||
std::vector<uint8_t> b((const uint8_t*)blob,
|
||||
(const uint8_t*)blob + blob_sz);
|
||||
std::string key = fn_secret::decrypt(b);
|
||||
if (!key.empty()) {
|
||||
snprintf(s.apikey_buf, sizeof(s.apikey_buf), "%s", key.c_str());
|
||||
fn_log::log_info("[db] credentials loaded");
|
||||
}
|
||||
}
|
||||
if (url && *url) snprintf(s.base_url, sizeof(s.base_url), "%s", url);
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
}
|
||||
|
||||
// Helper: rstrip whitespace + control chars.
|
||||
static void rstrip_ctrl(std::string& s) {
|
||||
while (!s.empty() && (unsigned char)s.back() <= 0x20) s.pop_back();
|
||||
}
|
||||
|
||||
// fetch_apikey_via_pass runs `pass agentes/api-key | head -n1` and returns the
|
||||
// secret on stdout. On Windows the command runs INSIDE WSL via wsl.exe (pass
|
||||
// lives in the WSL user's GnuPG keychain). Returns empty string on failure
|
||||
// (pass not installed, GPG locked, entry missing).
|
||||
static std::string fetch_apikey_via_pass() {
|
||||
std::string out;
|
||||
|
||||
#ifdef _WIN32
|
||||
// Spawn: wsl.exe -e sh -c "pass agentes/api-key 2>/dev/null | head -n1"
|
||||
std::wstring cmdline =
|
||||
L"wsl.exe -e sh -c \"pass agentes/api-key 2>/dev/null | head -n1\"";
|
||||
|
||||
SECURITY_ATTRIBUTES sa{};
|
||||
sa.nLength = sizeof(sa);
|
||||
sa.bInheritHandle = TRUE;
|
||||
|
||||
HANDLE rd = nullptr, wr = nullptr;
|
||||
if (!CreatePipe(&rd, &wr, &sa, 0)) return out;
|
||||
SetHandleInformation(rd, HANDLE_FLAG_INHERIT, 0);
|
||||
|
||||
STARTUPINFOW si{};
|
||||
si.cb = sizeof(si);
|
||||
si.dwFlags = STARTF_USESTDHANDLES;
|
||||
si.hStdOutput = wr;
|
||||
si.hStdError = wr;
|
||||
si.hStdInput = GetStdHandle(STD_INPUT_HANDLE);
|
||||
|
||||
PROCESS_INFORMATION pi{};
|
||||
std::wstring mutable_cmd = cmdline; // CreateProcessW needs writable buffer
|
||||
BOOL ok = CreateProcessW(nullptr, mutable_cmd.data(), nullptr, nullptr,
|
||||
TRUE, CREATE_NO_WINDOW, nullptr, nullptr, &si, &pi);
|
||||
CloseHandle(wr);
|
||||
if (!ok) {
|
||||
CloseHandle(rd);
|
||||
return out;
|
||||
}
|
||||
char buf[1024];
|
||||
DWORD got = 0;
|
||||
while (ReadFile(rd, buf, sizeof(buf), &got, nullptr) && got > 0)
|
||||
out.append(buf, buf + got);
|
||||
CloseHandle(rd);
|
||||
WaitForSingleObject(pi.hProcess, 5000); // 5s max
|
||||
CloseHandle(pi.hProcess);
|
||||
CloseHandle(pi.hThread);
|
||||
#else
|
||||
FILE* p = popen("pass agentes/api-key 2>/dev/null | head -n1", "r");
|
||||
if (p) {
|
||||
char buf[1024];
|
||||
size_t n;
|
||||
while ((n = std::fread(buf, 1, sizeof(buf), p)) > 0) out.append(buf, n);
|
||||
pclose(p);
|
||||
}
|
||||
#endif
|
||||
|
||||
rstrip_ctrl(out);
|
||||
// Sanity check: a 32-byte hex apikey is 64 chars. Reject anything shorter
|
||||
// than 16 (would catch error messages like "Error: ...").
|
||||
if (out.size() < 16) out.clear();
|
||||
return out;
|
||||
}
|
||||
|
||||
// load_apikey loads the apikey into s.apikey_buf with two-tier fallback:
|
||||
// 1) AGENTS_API_KEY env var (apikey_source = "env")
|
||||
// 2) `pass agentes/api-key` (apikey_source = "pass")
|
||||
// 3) empty (apikey_source = "missing")
|
||||
//
|
||||
// This lets the app launch from the App Hub (or any double-click) without
|
||||
// the user having to inject the env var manually — the apikey is fetched
|
||||
// from the user's pass store on demand (GPG agent must be unlocked).
|
||||
static void load_apikey(AppState& s) {
|
||||
s.apikey_buf[0] = '\0';
|
||||
s.apikey_source = "missing";
|
||||
|
||||
const char* k = std::getenv("AGENTS_API_KEY");
|
||||
if (k && *k) {
|
||||
snprintf(s.apikey_buf, sizeof(s.apikey_buf), "%s", k);
|
||||
size_t n = strlen(s.apikey_buf);
|
||||
while (n > 0 && (unsigned char)s.apikey_buf[n - 1] <= 0x20)
|
||||
s.apikey_buf[--n] = '\0';
|
||||
if (n > 0) {
|
||||
s.apikey_source = "env";
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
std::string from_pass = fetch_apikey_via_pass();
|
||||
if (!from_pass.empty()) {
|
||||
snprintf(s.apikey_buf, sizeof(s.apikey_buf), "%s", from_pass.c_str());
|
||||
s.apikey_source = "pass";
|
||||
}
|
||||
}
|
||||
|
||||
static void db_save_state(AppState& s, const char* key, const char* value) {
|
||||
if (!s.db) return;
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
@@ -222,13 +329,29 @@ static void db_save_state(AppState& s, const char* key, const char* value) {
|
||||
// HTTP helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// trim_url removes leading/trailing whitespace and control chars.
|
||||
// Defensive: paste-from-terminal often leaves CR/LF/spaces in the buffer,
|
||||
// which curl reports as "Bad hostname" (exit 3).
|
||||
static std::string trim_url(const std::string& in) {
|
||||
auto is_junk = [](unsigned char c) {
|
||||
return c <= 0x20 || c == 0x7F;
|
||||
};
|
||||
size_t b = 0, e = in.size();
|
||||
while (b < e && is_junk((unsigned char)in[b])) ++b;
|
||||
while (e > b && is_junk((unsigned char)in[e - 1])) --e;
|
||||
return in.substr(b, e - b);
|
||||
}
|
||||
|
||||
static std::string make_url(const AppState& s, const std::string& path) {
|
||||
std::string base = s.base_url;
|
||||
std::string base = trim_url(s.base_url);
|
||||
while (!base.empty() && base.back() == '/') base.pop_back();
|
||||
return base + path;
|
||||
}
|
||||
|
||||
// Parse agents JSON array from /agents endpoint
|
||||
// Parse agents JSON array from /agents endpoint.
|
||||
// Backend shape (agents_and_robots/internal/api/handlers.go v0.2):
|
||||
// { id, name, version, desc, enabled: bool, running: bool, pid: int,
|
||||
// instances: int, config_path, uptime_seconds: int64, messages_24h: int }
|
||||
static std::vector<AgentRow> parse_agents(const std::string& body) {
|
||||
std::vector<AgentRow> rows;
|
||||
auto j = json::parse(body, nullptr, false);
|
||||
@@ -237,37 +360,60 @@ static std::vector<AgentRow> parse_agents(const std::string& body) {
|
||||
AgentRow r;
|
||||
r.id = a.value("id", "");
|
||||
r.display_name = a.value("name", r.id);
|
||||
r.status = a.value("status", "unknown");
|
||||
r.uptime_s = a.value("uptime_seconds", (long long)0);
|
||||
r.msg_24h = a.value("messages_24h", 0);
|
||||
bool enabled = a.value("enabled", false);
|
||||
bool running = a.value("running", false);
|
||||
if (running) r.status = "running";
|
||||
else if (!enabled) r.status = "disabled";
|
||||
else r.status = "stopped";
|
||||
// v0.2: real uptime_seconds and messages_24h from backend
|
||||
r.uptime_s = a.value("uptime_seconds", (long long)0);
|
||||
r.msg_24h = a.value("messages_24h", 0);
|
||||
rows.push_back(std::move(r));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
// FN_DBG: stderr + flush. Survives crashes (fn_log buffers).
|
||||
#define FN_DBG(...) do { fprintf(stderr, "[DBG] " __VA_ARGS__); fputc('\n', stderr); fflush(stderr); } while(0)
|
||||
|
||||
// Fetch agents in background thread
|
||||
static void fetch_agents_async(AppState& s) {
|
||||
if (s.fetching) return;
|
||||
FN_DBG("fetch_agents_async ENTER s.fetching=%d apikey_len=%zu",
|
||||
(int)s.fetching, strlen(s.apikey_buf));
|
||||
if (s.fetching) {
|
||||
FN_DBG("fetch_agents_async SKIP already fetching");
|
||||
return;
|
||||
}
|
||||
s.fetching = true;
|
||||
std::thread([&s]() {
|
||||
FN_DBG("fetch thread STARTED");
|
||||
fn_http::Request req;
|
||||
req.method = "GET";
|
||||
req.url = make_url(s, "/agents");
|
||||
req.bearer_token = s.apikey_buf;
|
||||
req.timeout_ms = 8000;
|
||||
FN_DBG("fetch thread requesting url=%s bearer_len=%zu", req.url.c_str(), req.bearer_token.size());
|
||||
auto res = fn_http::request(req);
|
||||
std::lock_guard<std::mutex> lk(s.agents_mu);
|
||||
if (!res.error.empty()) {
|
||||
s.agents_error = "Transport error: " + res.error;
|
||||
} else if (res.status != 200) {
|
||||
s.agents_error = "HTTP " + std::to_string(res.status);
|
||||
} else {
|
||||
s.agents = parse_agents(res.body);
|
||||
s.agents_error.clear();
|
||||
s.agents_fetched_ms = now_ms();
|
||||
FN_DBG("fetch thread response status=%d err=[%s] body_len=%zu",
|
||||
res.status, res.error.c_str(), res.body.size());
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(s.agents_mu);
|
||||
if (!res.error.empty()) {
|
||||
s.agents_error = "Transport error: " + res.error;
|
||||
} else if (res.status != 200) {
|
||||
s.agents_error = "HTTP " + std::to_string(res.status);
|
||||
} else {
|
||||
FN_DBG("fetch thread parsing body...");
|
||||
s.agents = parse_agents(res.body);
|
||||
s.agents_error.clear();
|
||||
s.agents_fetched_ms = now_ms();
|
||||
FN_DBG("fetch thread parsed %zu rows", s.agents.size());
|
||||
}
|
||||
}
|
||||
s.fetching = false;
|
||||
FN_DBG("fetch thread DONE");
|
||||
}).detach();
|
||||
FN_DBG("fetch_agents_async EXIT (thread detached)");
|
||||
}
|
||||
|
||||
// POST action to /agents/{id}/{action}
|
||||
@@ -304,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();
|
||||
{
|
||||
@@ -311,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;
|
||||
@@ -328,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;
|
||||
@@ -356,6 +560,9 @@ static void start_status_sse(AppState& s) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static bool g_self_test = false;
|
||||
static int g_auto_refresh_after_frames = 0; // >0: trigger fetch_agents_async after N frames
|
||||
static int g_auto_exit_after_frames = 0; // >0: exit after N frames (for headless test)
|
||||
static int g_frame_count = 0;
|
||||
|
||||
static bool run_self_test() {
|
||||
fn_log::log_info("[self-test] checking subsystems...");
|
||||
@@ -432,26 +639,28 @@ static void draw_connection_panel(AppState& s) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fn_secret::is_strong()) {
|
||||
ImGui::TextColored(ImVec4(1.0f, 0.7f, 0.2f, 1.0f),
|
||||
TI_ALERT_TRIANGLE " Linux: apikey uses weak encryption (XOR fallback)");
|
||||
ImGui::Separator();
|
||||
}
|
||||
|
||||
ImGui::Text("Base URL:");
|
||||
ImGui::SameLine();
|
||||
ImGui::SetNextItemWidth(-1);
|
||||
ImGui::InputText("##base_url", s.base_url, sizeof(s.base_url));
|
||||
|
||||
ImGui::Text("API Key: ");
|
||||
ImGui::Text("API Key:");
|
||||
ImGui::SameLine();
|
||||
ImGuiInputTextFlags key_flags = s.apikey_masked
|
||||
? ImGuiInputTextFlags_Password : ImGuiInputTextFlags_None;
|
||||
ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x - 80);
|
||||
ImGui::InputText("##apikey", s.apikey_buf, sizeof(s.apikey_buf), key_flags);
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button(s.apikey_masked ? TI_EYE " Show" : TI_EYE_OFF " Hide")) {
|
||||
s.apikey_masked = !s.apikey_masked;
|
||||
if (s.apikey_source == "env") {
|
||||
ImGui::TextColored(ImVec4(0.2f, 0.85f, 0.4f, 1.0f),
|
||||
TI_CHECK " loaded from AGENTS_API_KEY env var");
|
||||
} else if (s.apikey_source == "pass") {
|
||||
ImGui::TextColored(ImVec4(0.2f, 0.85f, 0.4f, 1.0f),
|
||||
TI_CHECK " loaded via `pass agentes/api-key`");
|
||||
} else {
|
||||
ImGui::TextColored(ImVec4(0.9f, 0.3f, 0.3f, 1.0f),
|
||||
TI_ALERT_TRIANGLE " apikey not found (env empty + pass failed)");
|
||||
ImGui::TextDisabled(" Make sure GPG agent is unlocked: `pass agentes/api-key`");
|
||||
ImGui::TextDisabled(" Or launch with: AGENTS_API_KEY=$(pass agentes/api-key) <exe>");
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button(TI_REFRESH " Retry pass")) {
|
||||
load_apikey(s);
|
||||
}
|
||||
}
|
||||
|
||||
ImGui::Separator();
|
||||
@@ -478,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);
|
||||
@@ -544,6 +756,15 @@ static const char* status_icon(const std::string& s) {
|
||||
return TI_CIRCLE_DOTTED;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agents panel — rendered via data_table_cpp_viz (render_grid_stage0).
|
||||
// Columns: Status | ID | Name | Uptime | Msg/24h | Start | Stop | Restart |
|
||||
// ClearMem | DelCache | Logs
|
||||
// Actions are Button renderer columns; events handled after render_grid_stage0.
|
||||
//
|
||||
// Fallback (compile without fn_module_data_table): plain text list.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static void draw_agents_panel(AppState& s) {
|
||||
if (!ImGui::Begin(TI_ROBOT " Agents", &g_show_agents)) {
|
||||
ImGui::End();
|
||||
@@ -569,87 +790,258 @@ static void draw_agents_panel(AppState& s) {
|
||||
|
||||
ImGui::Separator();
|
||||
|
||||
// Table
|
||||
ImGuiTableFlags flags = ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg |
|
||||
ImGuiTableFlags_Resizable | ImGuiTableFlags_ScrollY |
|
||||
ImGuiTableFlags_SizingStretchProp;
|
||||
float available_h = ImGui::GetContentRegionAvail().y;
|
||||
if (ImGui::BeginTable("##agents_tbl", 6, flags, ImVec2(0, available_h))) {
|
||||
ImGui::TableSetupScrollFreeze(0, 1);
|
||||
ImGui::TableSetupColumn("Status", ImGuiTableColumnFlags_WidthFixed, 70);
|
||||
ImGui::TableSetupColumn("ID", ImGuiTableColumnFlags_WidthStretch);
|
||||
ImGui::TableSetupColumn("Name", ImGuiTableColumnFlags_WidthStretch);
|
||||
ImGui::TableSetupColumn("Uptime", ImGuiTableColumnFlags_WidthFixed, 65);
|
||||
ImGui::TableSetupColumn("Msg/24h",ImGuiTableColumnFlags_WidthFixed, 65);
|
||||
ImGui::TableSetupColumn("Actions",ImGuiTableColumnFlags_WidthFixed, 120);
|
||||
ImGui::TableHeadersRow();
|
||||
// Confirmation modal for destructive actions
|
||||
if (s.confirm_open) {
|
||||
ImGui::OpenPopup("##confirm_action");
|
||||
s.confirm_open = false;
|
||||
}
|
||||
if (ImGui::BeginPopupModal("##confirm_action", nullptr,
|
||||
ImGuiWindowFlags_AlwaysAutoResize)) {
|
||||
if (s.confirm_action == "clear_memory") {
|
||||
ImGui::Text("Clear memory for agent '%s'?", s.confirm_agent_id.c_str());
|
||||
ImGui::TextDisabled("This will delete all messages and facts.");
|
||||
} else {
|
||||
ImGui::Text("Delete cache for agent '%s'?", s.confirm_agent_id.c_str());
|
||||
ImGui::TextDisabled("This will remove crypto/ and cache/ directories.");
|
||||
}
|
||||
ImGui::Separator();
|
||||
if (ImGui::Button("Confirm", ImVec2(120, 0))) {
|
||||
agent_action(s, s.confirm_agent_id, s.confirm_action);
|
||||
ImGui::CloseCurrentPopup();
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button("Cancel", ImVec2(120, 0))) {
|
||||
ImGui::CloseCurrentPopup();
|
||||
}
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
|
||||
#ifdef HAS_DATA_TABLE
|
||||
// ----- data_table_cpp_viz path (issue 0131 migration) -----
|
||||
// Column layout:
|
||||
// 0=Status 1=ID 2=Name 3=Uptime 4=Msg24h
|
||||
// 5=Start 6=Stop 7=Restart 8=ClearMem 9=DelCache 10=Logs
|
||||
static constexpr int N_COLS = 11;
|
||||
static const char* kHeaders[N_COLS] = {
|
||||
"Status", "ID", "Name", "Uptime", "Msg/24h",
|
||||
"Start", "Stop", "Restart", "Clear Memory", "Del Cache", "Logs"
|
||||
};
|
||||
static const data_table::ColumnType kTypes[N_COLS] = {
|
||||
data_table::ColumnType::String, data_table::ColumnType::String,
|
||||
data_table::ColumnType::String, data_table::ColumnType::String,
|
||||
data_table::ColumnType::Int,
|
||||
data_table::ColumnType::String, data_table::ColumnType::String,
|
||||
data_table::ColumnType::String, data_table::ColumnType::String,
|
||||
data_table::ColumnType::String, data_table::ColumnType::String
|
||||
};
|
||||
static const int kSrcForEff[N_COLS] = { 0,1,2,3,4, 5,6,7,8,9,10 };
|
||||
|
||||
// First-render init for State: col_visible + col_order sized to N_COLS.
|
||||
// Without this, render_grid_stage0 indexes into empty std::vector<bool>
|
||||
// -> undefined behaviour -> Windows access-violation (exit 5).
|
||||
if ((int)s.agents_tbl_state.col_visible.size() != N_COLS) {
|
||||
s.agents_tbl_state.col_visible.assign(N_COLS, true);
|
||||
}
|
||||
if ((int)s.agents_tbl_state.col_order.size() != N_COLS) {
|
||||
s.agents_tbl_state.col_order.resize(N_COLS);
|
||||
for (int c = 0; c < N_COLS; ++c) s.agents_tbl_state.col_order[c] = c;
|
||||
}
|
||||
if ((int)s.agents_tbl_state.stages.size() < 1) {
|
||||
s.agents_tbl_state.stages.resize(1);
|
||||
}
|
||||
|
||||
// Build column specs (Badge for Status, Button for action columns)
|
||||
static data_table::TableInput main_t;
|
||||
if (main_t.column_specs.empty()) {
|
||||
main_t.column_specs.resize(N_COLS);
|
||||
// Status: Badge renderer
|
||||
auto& cs_status = main_t.column_specs[0];
|
||||
cs_status.id = "status";
|
||||
cs_status.renderer = data_table::CellRenderer::Badge;
|
||||
cs_status.badges = {
|
||||
{ "running", "#22c55e", "" },
|
||||
{ "stopped", "#6b7280", "" },
|
||||
{ "disabled", "#374151", "" },
|
||||
{ "crashed", "#ef4444", "" },
|
||||
};
|
||||
// Text columns
|
||||
for (int c : {1,2,3,4}) {
|
||||
main_t.column_specs[c].id = kHeaders[c];
|
||||
main_t.column_specs[c].renderer = data_table::CellRenderer::Text;
|
||||
}
|
||||
// Button columns (indices 5..10)
|
||||
const char* btn_actions[] = { "start","stop","restart","clear_memory","delete_cache","logs" };
|
||||
const char* btn_labels[] = { TI_PLAYER_PLAY " Start",
|
||||
TI_PLAYER_STOP " Stop",
|
||||
TI_REFRESH " Restart",
|
||||
TI_TRASH " Clear Mem",
|
||||
TI_FOLDER_MINUS " Del Cache",
|
||||
TI_TERMINAL " Logs" };
|
||||
for (int i = 0; i < 6; ++i) {
|
||||
int c = 5 + i;
|
||||
main_t.column_specs[c].id = btn_actions[i];
|
||||
main_t.column_specs[c].renderer = data_table::CellRenderer::Button;
|
||||
main_t.column_specs[c].button_action = btn_actions[i];
|
||||
main_t.column_specs[c].button_label = btn_labels[i];
|
||||
}
|
||||
// Tooltip for destructive buttons
|
||||
main_t.column_specs[8].tooltip = "Delete all messages and facts for this agent";
|
||||
main_t.column_specs[8].tooltip_on_hover = true;
|
||||
main_t.column_specs[9].tooltip = "Remove crypto/ and cache/ directories";
|
||||
main_t.column_specs[9].tooltip_on_hover = true;
|
||||
}
|
||||
|
||||
// Snapshot agents under lock
|
||||
std::vector<AgentRow> snapshot;
|
||||
std::string agents_err;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(s.agents_mu);
|
||||
std::string filter = s.filter_buf;
|
||||
for (auto& row : s.agents) {
|
||||
// Filter
|
||||
if (!filter.empty() &&
|
||||
row.id.find(filter) == std::string::npos &&
|
||||
row.id.find(filter) == std::string::npos &&
|
||||
row.display_name.find(filter) == std::string::npos) {
|
||||
continue;
|
||||
}
|
||||
snapshot.push_back(row);
|
||||
}
|
||||
agents_err = s.agents_error;
|
||||
}
|
||||
|
||||
ImGui::TableNextRow();
|
||||
// Build flat cell array (row-major, N_COLS per row)
|
||||
int n_rows = (int)snapshot.size();
|
||||
std::vector<std::string> cell_strs;
|
||||
cell_strs.reserve((size_t)n_rows * N_COLS);
|
||||
for (auto& row : snapshot) {
|
||||
bool is_running = (row.status == "running");
|
||||
cell_strs.push_back(row.status); // 0 Status
|
||||
cell_strs.push_back(row.id); // 1 ID
|
||||
cell_strs.push_back(row.display_name); // 2 Name
|
||||
cell_strs.push_back(format_uptime(row.uptime_s)); // 3 Uptime
|
||||
cell_strs.push_back(std::to_string(row.msg_24h)); // 4 Msg/24h
|
||||
// Button cells: show label only if action is applicable
|
||||
cell_strs.push_back(!is_running ? "start" : ""); // 5 Start (disabled when running)
|
||||
cell_strs.push_back(is_running ? "stop" : ""); // 6 Stop (disabled when stopped)
|
||||
cell_strs.push_back("restart"); // 7 Restart always available
|
||||
cell_strs.push_back("clear_memory"); // 8 ClearMem
|
||||
cell_strs.push_back("delete_cache"); // 9 DelCache
|
||||
cell_strs.push_back("logs"); // 10 Logs
|
||||
}
|
||||
// Build pointer array
|
||||
std::vector<const char*> cells_ptr;
|
||||
cells_ptr.reserve(cell_strs.size());
|
||||
for (auto& s_str : cell_strs) cells_ptr.push_back(s_str.c_str());
|
||||
|
||||
// Status column
|
||||
ImGui::TableSetColumnIndex(0);
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, status_color(row.status));
|
||||
ImGui::TextUnformatted(status_icon(row.status));
|
||||
ImGui::SameLine();
|
||||
ImGui::TextUnformatted(row.status.c_str());
|
||||
ImGui::PopStyleColor();
|
||||
// All rows visible (filtering already applied above)
|
||||
std::vector<int> visible_rows(n_rows);
|
||||
for (int i = 0; i < n_rows; ++i) visible_rows[i] = i;
|
||||
|
||||
// ID
|
||||
ImGui::TableSetColumnIndex(1);
|
||||
ImGui::TextUnformatted(row.id.c_str());
|
||||
// Collect events from render_grid_stage0
|
||||
std::vector<data_table::TableEvent> events;
|
||||
float available_h = ImGui::GetContentRegionAvail().y;
|
||||
// Constrain height so the scroll region doesn't eat the whole panel
|
||||
ImVec2 tbl_size(0, available_h);
|
||||
|
||||
// Name
|
||||
ImGui::TableSetColumnIndex(2);
|
||||
ImGui::TextUnformatted(row.display_name.c_str());
|
||||
// Pass height hint via State display — we force Table view mode
|
||||
s.agents_tbl_state.display = data_table::ViewMode::Table;
|
||||
|
||||
// Uptime
|
||||
ImGui::TableSetColumnIndex(3);
|
||||
ImGui::TextUnformatted(format_uptime(row.uptime_s).c_str());
|
||||
// Need at least 1 row for the API to be happy
|
||||
if (n_rows > 0) {
|
||||
static int dbg_first = 1;
|
||||
if (dbg_first) {
|
||||
FN_DBG("agents_panel PRE-render n_rows=%d cells=%zu specs=%zu eff_h=%p eff_t=%p src=%p vis_sz=%zu",
|
||||
n_rows, cells_ptr.size(), main_t.column_specs.size(),
|
||||
(void*)kHeaders, (void*)kTypes, (void*)kSrcForEff, visible_rows.size());
|
||||
for (int r = 0; r < n_rows && r < 2; ++r) {
|
||||
for (int c = 0; c < N_COLS; ++c) {
|
||||
const char* p = cells_ptr[r * N_COLS + c];
|
||||
FN_DBG(" cell[%d][%d]=%s", r, c, p ? p : "(null)");
|
||||
}
|
||||
}
|
||||
dbg_first = 0;
|
||||
}
|
||||
render_grid_stage0("##agents_tbl",
|
||||
s.agents_tbl_state,
|
||||
cells_ptr.empty() ? nullptr : cells_ptr.data(),
|
||||
n_rows, N_COLS, N_COLS,
|
||||
kHeaders, kTypes, kSrcForEff,
|
||||
visible_rows, main_t, &events);
|
||||
static int dbg_post = 1;
|
||||
if (dbg_post) { FN_DBG("agents_panel POST-render events=%zu", events.size()); dbg_post = 0; }
|
||||
} else {
|
||||
ImGui::TextDisabled("(no agents match filter)");
|
||||
}
|
||||
|
||||
// Msg 24h
|
||||
ImGui::TableSetColumnIndex(4);
|
||||
ImGui::Text("%d", row.msg_24h);
|
||||
// Handle button events
|
||||
for (auto& ev : events) {
|
||||
if (ev.kind != data_table::TableEventKind::ButtonClick) continue;
|
||||
if (ev.row < 0 || ev.row >= n_rows) continue;
|
||||
const std::string& agent_id = snapshot[ev.row].id;
|
||||
const std::string& act = ev.action_id;
|
||||
|
||||
// Actions
|
||||
ImGui::TableSetColumnIndex(5);
|
||||
if (act == "start") {
|
||||
agent_action(s, agent_id, "start");
|
||||
} else if (act == "stop") {
|
||||
agent_action(s, agent_id, "stop");
|
||||
} else if (act == "restart") {
|
||||
agent_action(s, agent_id, "restart");
|
||||
} else if (act == "clear_memory") {
|
||||
// Require confirmation
|
||||
s.confirm_agent_id = agent_id;
|
||||
s.confirm_action = "clear_memory";
|
||||
s.confirm_open = true;
|
||||
} else if (act == "delete_cache") {
|
||||
// Require confirmation
|
||||
s.confirm_agent_id = agent_id;
|
||||
s.confirm_action = "delete_cache";
|
||||
s.confirm_open = true;
|
||||
} else if (act == "logs") {
|
||||
snprintf(s.log_agent_id, sizeof(s.log_agent_id), "%s", agent_id.c_str());
|
||||
db_save_state(s, "log_agent_id", agent_id.c_str());
|
||||
start_log_sse(s, agent_id);
|
||||
g_show_logs = true;
|
||||
}
|
||||
}
|
||||
|
||||
#else
|
||||
// ----- Fallback: plain text list (no fn_module_data_table) -----
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(s.agents_mu);
|
||||
std::string filter = s.filter_buf;
|
||||
for (auto& row : s.agents) {
|
||||
if (!filter.empty() &&
|
||||
row.id.find(filter) == std::string::npos &&
|
||||
row.display_name.find(filter) == std::string::npos) {
|
||||
continue;
|
||||
}
|
||||
ImGui::PushID(row.id.c_str());
|
||||
|
||||
ImGui::Text("[%s] %s uptime=%s msg24h=%d",
|
||||
row.status.c_str(), row.id.c_str(),
|
||||
format_uptime(row.uptime_s).c_str(), row.msg_24h);
|
||||
ImGui::SameLine();
|
||||
bool is_running = (row.status == "running");
|
||||
if (!is_running) {
|
||||
if (ImGui::SmallButton(TI_PLAYER_PLAY " Start")) {
|
||||
agent_action(s, row.id, "start");
|
||||
}
|
||||
} else {
|
||||
if (ImGui::SmallButton(TI_PLAYER_STOP " Stop")) {
|
||||
agent_action(s, row.id, "stop");
|
||||
}
|
||||
}
|
||||
if (!is_running && ImGui::SmallButton("Start")) agent_action(s, row.id, "start");
|
||||
if (is_running && ImGui::SmallButton("Stop")) agent_action(s, row.id, "stop");
|
||||
ImGui::SameLine();
|
||||
if (ImGui::SmallButton(TI_REFRESH " Restart")) {
|
||||
agent_action(s, row.id, "restart");
|
||||
}
|
||||
if (ImGui::SmallButton("Restart")) agent_action(s, row.id, "restart");
|
||||
ImGui::SameLine();
|
||||
if (ImGui::SmallButton(TI_TERMINAL " Logs")) {
|
||||
if (ImGui::SmallButton("Logs")) {
|
||||
snprintf(s.log_agent_id, sizeof(s.log_agent_id), "%s", row.id.c_str());
|
||||
db_save_state(s, "log_agent_id", row.id.c_str());
|
||||
start_log_sse(s, row.id);
|
||||
g_show_logs = true;
|
||||
}
|
||||
|
||||
ImGui::SameLine();
|
||||
if (ImGui::SmallButton("ClearMem")) {
|
||||
s.confirm_agent_id = row.id; s.confirm_action = "clear_memory"; s.confirm_open = true;
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (ImGui::SmallButton("DelCache")) {
|
||||
s.confirm_agent_id = row.id; s.confirm_action = "delete_cache"; s.confirm_open = true;
|
||||
}
|
||||
ImGui::PopID();
|
||||
}
|
||||
ImGui::EndTable();
|
||||
}
|
||||
#endif // HAS_DATA_TABLE
|
||||
|
||||
ImGui::End();
|
||||
}
|
||||
@@ -759,6 +1151,20 @@ static void draw_status_feed_panel(AppState& s) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static void render() {
|
||||
g_frame_count++;
|
||||
if (g_frame_count <= 3) {
|
||||
FN_DBG("render frame=%d", g_frame_count);
|
||||
}
|
||||
|
||||
// Headless test: simulate Refresh button click after N frames
|
||||
if (g_auto_refresh_after_frames > 0 && g_frame_count == g_auto_refresh_after_frames) {
|
||||
FN_DBG("AUTO-REFRESH triggered at frame %d", g_frame_count);
|
||||
// Mark connected so the agents panel renders
|
||||
g_state.connected = true;
|
||||
// Simulate Connect-then-Refresh: populate base_url default, kick fetch.
|
||||
fetch_agents_async(g_state);
|
||||
}
|
||||
|
||||
draw_connection_panel(g_state);
|
||||
if (g_show_agents) draw_agents_panel(g_state);
|
||||
if (g_show_logs) draw_logs_panel(g_state);
|
||||
@@ -771,19 +1177,124 @@ static void render() {
|
||||
fetch_agents_async(g_state);
|
||||
}
|
||||
}
|
||||
|
||||
// Headless test: exit after N frames
|
||||
if (g_auto_exit_after_frames > 0 && g_frame_count >= g_auto_exit_after_frames) {
|
||||
FN_DBG("AUTO-EXIT at frame %d", g_frame_count);
|
||||
std::exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// run_connect_test exercises the same fn_http path the UI uses, against a real
|
||||
// backend. apikey is read from AGENTS_API_KEY env var (never from argv) so it
|
||||
// does not leak via process listings.
|
||||
// stdout: "OK <agents_count>" exit 0
|
||||
// stderr: "FAIL <reason>" exit 1
|
||||
static int run_connect_test(const std::string& base_url) {
|
||||
std::string url = trim_url(base_url);
|
||||
while (!url.empty() && url.back() == '/') url.pop_back();
|
||||
if (url.empty()) {
|
||||
fprintf(stderr, "FAIL empty url after trim\n");
|
||||
return 1;
|
||||
}
|
||||
const char* envk = std::getenv("AGENTS_API_KEY");
|
||||
std::string apikey = envk ? envk : "";
|
||||
{
|
||||
size_t n = apikey.size();
|
||||
while (n > 0 && (unsigned char)apikey[n - 1] <= 0x20) --n;
|
||||
apikey.resize(n);
|
||||
}
|
||||
// Fallback: try `pass agentes/api-key` if env is empty (same flow as UI).
|
||||
if (apikey.empty()) {
|
||||
apikey = fetch_apikey_via_pass();
|
||||
}
|
||||
if (apikey.empty()) {
|
||||
fprintf(stderr, "FAIL apikey not found (env empty + pass failed)\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// 1) /health (no auth)
|
||||
{
|
||||
fn_http::Request req;
|
||||
req.method = "GET";
|
||||
req.url = url + "/health";
|
||||
req.timeout_ms = 8000;
|
||||
auto res = fn_http::request(req);
|
||||
if (!res.error.empty()) {
|
||||
fprintf(stderr, "FAIL health transport: %s\n", res.error.c_str());
|
||||
return 1;
|
||||
}
|
||||
if (res.status != 200) {
|
||||
fprintf(stderr, "FAIL health status %d body=%.200s\n",
|
||||
res.status, res.body.c_str());
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 2) /agents sin auth -> 401
|
||||
{
|
||||
fn_http::Request req;
|
||||
req.method = "GET";
|
||||
req.url = url + "/agents";
|
||||
req.timeout_ms = 8000;
|
||||
auto res = fn_http::request(req);
|
||||
if (!res.error.empty()) {
|
||||
fprintf(stderr, "FAIL agents-noauth transport: %s\n", res.error.c_str());
|
||||
return 1;
|
||||
}
|
||||
if (res.status != 401) {
|
||||
fprintf(stderr, "FAIL agents-noauth expected 401 got %d\n", res.status);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 3) /agents con auth -> 200 con JSON array
|
||||
{
|
||||
fn_http::Request req;
|
||||
req.method = "GET";
|
||||
req.url = url + "/agents";
|
||||
req.bearer_token = apikey;
|
||||
req.timeout_ms = 8000;
|
||||
auto res = fn_http::request(req);
|
||||
if (!res.error.empty()) {
|
||||
fprintf(stderr, "FAIL agents transport: %s\n", res.error.c_str());
|
||||
return 1;
|
||||
}
|
||||
if (res.status != 200) {
|
||||
fprintf(stderr, "FAIL agents status %d body=%.200s\n",
|
||||
res.status, res.body.c_str());
|
||||
return 1;
|
||||
}
|
||||
auto j = json::parse(res.body, nullptr, false);
|
||||
if (j.is_discarded() || !j.is_array()) {
|
||||
fprintf(stderr, "FAIL agents body not JSON array\n");
|
||||
return 1;
|
||||
}
|
||||
fprintf(stdout, "OK %zu\n", j.size());
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
FN_DBG("main ENTER argc=%d", argc);
|
||||
// Self-test mode
|
||||
for (int i = 1; i < argc; i++) {
|
||||
if (strcmp(argv[i], "--self-test") == 0) {
|
||||
g_self_test = true;
|
||||
break;
|
||||
}
|
||||
if (strcmp(argv[i], "--connect-test") == 0 && i + 1 < argc) {
|
||||
return run_connect_test(argv[i + 1]);
|
||||
}
|
||||
if (strcmp(argv[i], "--auto-refresh") == 0) {
|
||||
g_auto_refresh_after_frames = 30; // ~0.5s @ 60Hz
|
||||
g_auto_exit_after_frames = 180; // ~3s
|
||||
FN_DBG("auto-refresh mode enabled");
|
||||
}
|
||||
}
|
||||
|
||||
if (g_self_test) {
|
||||
@@ -809,12 +1320,18 @@ int main(int argc, char** argv) {
|
||||
cfg.panels = panels;
|
||||
cfg.panel_count = sizeof(panels) / sizeof(panels[0]);
|
||||
|
||||
// Init DB and load saved credentials before rendering
|
||||
// Init DB and load saved base_url + apikey (env first, fallback to `pass agentes/api-key`).
|
||||
db_open(g_state);
|
||||
db_load_connection(g_state);
|
||||
FN_DBG("startup: db loaded base_url=%s", g_state.base_url);
|
||||
load_apikey(g_state);
|
||||
FN_DBG("startup: apikey_source=%s apikey_len=%zu",
|
||||
g_state.apikey_source.c_str(), strlen(g_state.apikey_buf));
|
||||
|
||||
// Cleanup on exit
|
||||
FN_DBG("startup: calling fn::run_app");
|
||||
int ret = fn::run_app(cfg, render);
|
||||
FN_DBG("fn::run_app returned %d", ret);
|
||||
|
||||
// Persist state
|
||||
db_save_state(g_state, "log_autoscroll", g_state.log_autoscroll ? "1" : "0");
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,615 @@
|
||||
"""
|
||||
E2E connect tests for agents_dashboard against the live backend.
|
||||
|
||||
Runs the deployed .exe with --connect-test, which exercises the SAME
|
||||
fn_http code path the GUI uses (curl spawn via CreateProcessW on Windows).
|
||||
Verifies:
|
||||
|
||||
* /health → 200 sin auth
|
||||
* /agents → 401 sin Bearer
|
||||
* /agents → 200 + JSON array con Bearer
|
||||
|
||||
Skips if:
|
||||
* AGENTS_API_KEY env var missing (run with `pass agentes/api-key`)
|
||||
* Deployed exe not found
|
||||
|
||||
Run from repo root:
|
||||
AGENTS_API_KEY=$(pass agentes/api-key) \\
|
||||
python3 -m pytest -x -q projects/element_agents/apps/agents_dashboard/tests/test_connect_e2e.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
DEFAULT_EXE = Path("/mnt/c/Users/lucas/Desktop/apps/agents_dashboard/agents_dashboard.exe")
|
||||
DEFAULT_URL = "https://agents.organic-machine.com"
|
||||
|
||||
|
||||
def _exe() -> Path:
|
||||
p = Path(os.environ.get("AGENTS_DASHBOARD_EXE", DEFAULT_EXE))
|
||||
if not p.exists():
|
||||
pytest.skip(f"deployed exe not found: {p}")
|
||||
return p
|
||||
|
||||
|
||||
def _url() -> str:
|
||||
return os.environ.get("AGENTS_DASHBOARD_URL", DEFAULT_URL)
|
||||
|
||||
|
||||
def _apikey() -> str:
|
||||
k = os.environ.get("AGENTS_API_KEY", "").strip()
|
||||
if not k:
|
||||
pytest.skip("AGENTS_API_KEY env var missing (try `pass agentes/api-key`)")
|
||||
return k
|
||||
|
||||
|
||||
def _run_connect_test(url: str, env: dict | None = None) -> subprocess.CompletedProcess:
|
||||
"""Invoke .exe --connect-test with WSLENV so env propagates to Windows binary."""
|
||||
full_env = os.environ.copy()
|
||||
if env:
|
||||
full_env.update(env)
|
||||
# WSLENV: comma/colon-separated list of env names to forward to Win32
|
||||
keep = full_env.get("WSLENV", "")
|
||||
add = "AGENTS_API_KEY"
|
||||
full_env["WSLENV"] = f"{keep}:{add}" if keep else add
|
||||
return subprocess.run(
|
||||
[str(_exe()), "--connect-test", url],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
env=full_env,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_connect_succeeds_with_valid_apikey():
|
||||
"""OK <N> on stdout, exit 0, when env + URL + apikey are valid."""
|
||||
_apikey() # skip if missing
|
||||
r = _run_connect_test(_url())
|
||||
assert r.returncode == 0, f"exit={r.returncode}\nstdout={r.stdout}\nstderr={r.stderr}"
|
||||
assert r.stdout.startswith("OK "), f"stdout=[{r.stdout!r}]"
|
||||
n = int(r.stdout.strip().split()[1])
|
||||
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.
|
||||
|
||||
Bug history: v0.2 migration to data_table_cpp_viz left State.col_visible
|
||||
and State.col_order uninitialized — render_grid_stage0 indexed into empty
|
||||
std::vector<bool>, causing an access violation (Windows exit code 5).
|
||||
|
||||
The --auto-refresh CLI flag triggers fetch_agents_async + a full render
|
||||
cycle from a headless GLFW window, then exits at frame 180 (~3s @ 60Hz).
|
||||
Exit 0 means the agents panel rendered the live data without crashing.
|
||||
"""
|
||||
pass_check = subprocess.run(["pass", "agentes/api-key"],
|
||||
capture_output=True, text=True, timeout=5)
|
||||
if pass_check.returncode != 0 or not pass_check.stdout.strip():
|
||||
pytest.skip("pass agentes/api-key not readable (GPG locked?)")
|
||||
|
||||
# WSL → Windows: launch the .exe and let it self-exit after 180 frames.
|
||||
r = subprocess.run(
|
||||
[str(_exe()), "--auto-refresh"],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
assert r.returncode == 0, (
|
||||
f"app crashed (exit={r.returncode}); last stderr:\n"
|
||||
+ "\n".join(r.stderr.splitlines()[-20:])
|
||||
)
|
||||
# Sanity: stderr must show that fetch_agents reached the parse step.
|
||||
assert "fetch thread parsed" in r.stderr, (
|
||||
f"fetch never reached parse; stderr:\n{r.stderr[-1000:]}"
|
||||
)
|
||||
# Sanity: render must have completed at least once (POST-render logged).
|
||||
assert "agents_panel POST-render" in r.stderr, (
|
||||
f"render_grid_stage0 crashed before completing; stderr:\n{r.stderr[-1000:]}"
|
||||
)
|
||||
|
||||
|
||||
def test_connect_falls_back_to_pass_when_env_empty():
|
||||
"""When AGENTS_API_KEY env is empty, the .exe must fetch apikey via
|
||||
`wsl.exe pass agentes/api-key` (or `pass` on Linux). This is what makes
|
||||
launching from the App Hub work without manual env injection.
|
||||
|
||||
Skipped if `pass agentes/api-key` itself can't be read (GPG locked).
|
||||
"""
|
||||
# Verify pass is unlocked before testing the fallback
|
||||
pass_check = subprocess.run(
|
||||
["pass", "agentes/api-key"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
if pass_check.returncode != 0 or not pass_check.stdout.strip():
|
||||
pytest.skip("pass agentes/api-key not readable (GPG locked?)")
|
||||
|
||||
# Force-empty AGENTS_API_KEY + bypass WSLENV propagation
|
||||
r = subprocess.run(
|
||||
[str(_exe()), "--connect-test", _url()],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
env={**os.environ, "AGENTS_API_KEY": "", "WSLENV": ""},
|
||||
)
|
||||
assert r.returncode == 0, f"pass fallback failed: stdout={r.stdout!r} stderr={r.stderr!r}"
|
||||
assert r.stdout.startswith("OK "), f"stdout=[{r.stdout!r}]"
|
||||
|
||||
|
||||
def test_connect_fails_on_bad_host():
|
||||
"""Transport-level failure on unresolvable host."""
|
||||
_apikey()
|
||||
r = _run_connect_test("https://this-host-does-not-exist-xyzzy.invalid")
|
||||
assert r.returncode != 0
|
||||
assert "FAIL" in r.stderr, f"stderr=[{r.stderr!r}]"
|
||||
|
||||
|
||||
def test_count_matches_direct_curl():
|
||||
"""N agents returned by the .exe must equal what /agents returns via curl."""
|
||||
apikey = _apikey()
|
||||
# Direct curl from WSL via Windows curl.exe (matches what fn_http uses).
|
||||
curl_bin = shutil.which("curl.exe") or "/mnt/c/Windows/System32/curl.exe"
|
||||
direct = subprocess.run(
|
||||
[
|
||||
curl_bin, "-fsS",
|
||||
"-H", f"Authorization: Bearer {apikey}",
|
||||
f"{_url()}/agents",
|
||||
],
|
||||
capture_output=True, text=True, timeout=15,
|
||||
)
|
||||
assert direct.returncode == 0, f"direct curl failed: {direct.stderr}"
|
||||
expected = len(json.loads(direct.stdout))
|
||||
|
||||
r = _run_connect_test(_url())
|
||||
assert r.returncode == 0
|
||||
got = int(r.stdout.strip().split()[1])
|
||||
assert got == expected, f"exe says {got} agents, curl says {expected}"
|
||||
|
||||
|
||||
def test_url_trim_robust_to_whitespace():
|
||||
"""URL with leading/trailing whitespace + CR/LF must still connect."""
|
||||
_apikey()
|
||||
# Wrap URL with whitespace + CRLF; trim_url() in main.cpp must strip.
|
||||
url = " \r\n " + _url() + " \r\n\t"
|
||||
r = _run_connect_test(url)
|
||||
assert r.returncode == 0, f"trim failed: stderr={r.stderr!r}"
|
||||
assert r.stdout.startswith("OK ")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backend direct: shape contract + SSE + control endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _curl(args: list[str], timeout: int = 15) -> subprocess.CompletedProcess:
|
||||
curl_bin = shutil.which("curl.exe") or "/mnt/c/Windows/System32/curl.exe"
|
||||
return subprocess.run([curl_bin, *args], capture_output=True, text=True, timeout=timeout)
|
||||
|
||||
|
||||
def test_agents_shape_has_fields_used_by_ui():
|
||||
"""Backend /agents response must carry the fields parse_agents() reads."""
|
||||
apikey = _apikey()
|
||||
r = _curl([
|
||||
"-fsS",
|
||||
"-H", f"Authorization: Bearer {apikey}",
|
||||
f"{_url()}/agents",
|
||||
])
|
||||
assert r.returncode == 0, r.stderr
|
||||
agents = json.loads(r.stdout)
|
||||
assert isinstance(agents, list) and len(agents) > 0
|
||||
required = {"id", "name", "enabled", "running"}
|
||||
for a in agents:
|
||||
missing = required - set(a.keys())
|
||||
assert not missing, f"agent {a.get('id')} missing fields {missing}: {a}"
|
||||
assert isinstance(a["enabled"], bool)
|
||||
assert isinstance(a["running"], bool)
|
||||
|
||||
|
||||
def test_sse_status_streams_not_unsupported():
|
||||
"""/sse/status must return text/event-stream, NOT plain 'streaming unsupported'.
|
||||
|
||||
Regression: statusWriter wrapper used to hide http.Flusher → SSE handlers
|
||||
aborted with 500 'streaming unsupported'. Fixed in agents_and_robots
|
||||
commit 4822208 by adding statusWriter.Flush().
|
||||
"""
|
||||
apikey = _apikey()
|
||||
# -m 3 = max 3s; SSE keeps the connection open, so we capture initial bytes
|
||||
# and stop. -i = include response headers.
|
||||
r = _curl([
|
||||
"-sN", "-i", "-m", "3",
|
||||
"-H", f"Authorization: Bearer {apikey}",
|
||||
f"{_url()}/sse/status",
|
||||
], timeout=8)
|
||||
body = r.stdout
|
||||
assert "streaming unsupported" not in body, f"SSE not flusher-aware: {body[:300]!r}"
|
||||
# Should see SSE content-type header
|
||||
assert "text/event-stream" in body.lower(), f"no SSE content-type: {body[:300]!r}"
|
||||
|
||||
|
||||
def test_sse_logs_streams_not_unsupported():
|
||||
"""/sse/agents/{id}/logs idem."""
|
||||
apikey = _apikey()
|
||||
r = _curl([
|
||||
"-sN", "-i", "-m", "3",
|
||||
"-H", f"Authorization: Bearer {apikey}",
|
||||
f"{_url()}/sse/agents/assistant-bot/logs",
|
||||
], timeout=8)
|
||||
body = r.stdout
|
||||
assert "streaming unsupported" not in body, f"SSE not flusher-aware: {body[:300]!r}"
|
||||
assert "text/event-stream" in body.lower(), f"no SSE content-type: {body[:300]!r}"
|
||||
|
||||
|
||||
def test_sse_status_emits_initial_ping_within_1s():
|
||||
"""Backend MUST emit ":ping" immediately after headers (regression guard).
|
||||
|
||||
Without this, agents_dashboard sat on "connecting" indefinitely because
|
||||
fgets() blocked waiting for the first body byte.
|
||||
"""
|
||||
apikey = _apikey()
|
||||
r = _curl([
|
||||
"-sN", "-m", "2",
|
||||
"-H", f"Authorization: Bearer {apikey}",
|
||||
f"{_url()}/sse/status",
|
||||
], timeout=5)
|
||||
# Body should contain at minimum the ping comment within 2s window
|
||||
assert ": ping" in r.stdout, f"no initial ping in 2s: {r.stdout[:300]!r}"
|
||||
|
||||
|
||||
def test_sse_logs_emits_initial_ping_within_1s():
|
||||
apikey = _apikey()
|
||||
r = _curl([
|
||||
"-sN", "-m", "2",
|
||||
"-H", f"Authorization: Bearer {apikey}",
|
||||
f"{_url()}/sse/agents/assistant-bot/logs",
|
||||
], timeout=5)
|
||||
assert ": ping" in r.stdout, f"no initial ping in 2s: {r.stdout[:300]!r}"
|
||||
|
||||
|
||||
def test_sse_logs_actually_streams_log_lines():
|
||||
"""Beyond the ping, the log SSE must emit `event: log` frames with data.
|
||||
|
||||
assistant-bot is a long-running agent that emits log lines frequently,
|
||||
so we collect a 2s window and expect at least one log event.
|
||||
"""
|
||||
apikey = _apikey()
|
||||
r = _curl([
|
||||
"-sN", "-m", "2",
|
||||
"-H", f"Authorization: Bearer {apikey}",
|
||||
f"{_url()}/sse/agents/assistant-bot/logs",
|
||||
], timeout=5)
|
||||
assert "event: log" in r.stdout, f"no log events in 2s window: {r.stdout[:500]!r}"
|
||||
|
||||
|
||||
def test_sse_unauthorized_without_bearer():
|
||||
"""SSE endpoints respect the same auth middleware as REST endpoints."""
|
||||
r = _curl([
|
||||
"-s", "-o", "/dev/null", "-w", "%{http_code}",
|
||||
f"{_url()}/sse/status",
|
||||
])
|
||||
assert r.stdout == "401", f"expected 401 got {r.stdout!r}"
|
||||
|
||||
|
||||
def test_health_no_auth_required():
|
||||
"""/health must respond 200 without any Authorization header."""
|
||||
r = _curl(["-fsS", f"{_url()}/health"])
|
||||
assert r.returncode == 0, r.stderr
|
||||
body = json.loads(r.stdout)
|
||||
assert body.get("status") == "ok"
|
||||
|
||||
|
||||
def test_agents_unauthorized_without_bearer():
|
||||
"""/agents must return 401 without auth."""
|
||||
r = _curl(["-s", "-o", "/dev/null", "-w", "%{http_code}", f"{_url()}/agents"])
|
||||
assert r.stdout == "401", f"expected 401 got {r.stdout!r}"
|
||||
|
||||
|
||||
def test_agents_unauthorized_with_bad_bearer():
|
||||
"""/agents must return 401 with wrong apikey."""
|
||||
r = _curl([
|
||||
"-s", "-o", "/dev/null", "-w", "%{http_code}",
|
||||
"-H", "Authorization: Bearer wrong-key-xxxxx",
|
||||
f"{_url()}/agents",
|
||||
])
|
||||
assert r.stdout == "401", f"expected 401 got {r.stdout!r}"
|
||||
|
||||
|
||||
def test_control_start_endpoint_responds():
|
||||
"""POST /agents/{id}/start returns JSON (idempotent in unified mode).
|
||||
|
||||
Note: in unified mode (current backend), individual start/stop is a partial
|
||||
no-op — Manager.Start returns success since the goroutine is already running.
|
||||
Real per-agent control is a v0.2 backend feature (see issue 0131 if filed).
|
||||
Here we only assert the endpoint is reachable + returns valid JSON.
|
||||
"""
|
||||
apikey = _apikey()
|
||||
r = _curl([
|
||||
"-sS", "-X", "POST",
|
||||
"-H", f"Authorization: Bearer {apikey}",
|
||||
f"{_url()}/agents/test-bot/start",
|
||||
])
|
||||
assert r.returncode == 0, r.stderr
|
||||
body = json.loads(r.stdout)
|
||||
# accept either {status: started, ...} or {error: ...} — only assert it's JSON
|
||||
assert isinstance(body, dict)
|
||||
|
||||
|
||||
def test_get_single_agent_returns_logs():
|
||||
"""GET /agents/{id} must include 'logs' array (might be empty)."""
|
||||
apikey = _apikey()
|
||||
r = _curl([
|
||||
"-fsS",
|
||||
"-H", f"Authorization: Bearer {apikey}",
|
||||
f"{_url()}/agents/assistant-bot",
|
||||
])
|
||||
assert r.returncode == 0, r.stderr
|
||||
body = json.loads(r.stdout)
|
||||
assert body.get("id") == "assistant-bot"
|
||||
assert "logs" in body
|
||||
assert isinstance(body["logs"], list)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# v0.2 — per-agent control + new fields (issue 0131)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_uptime_field_present():
|
||||
"""GET /agents response must include uptime_seconds field (integer >= 0)."""
|
||||
apikey = _apikey()
|
||||
r = _curl([
|
||||
"-fsS",
|
||||
"-H", f"Authorization: Bearer {apikey}",
|
||||
f"{_url()}/agents",
|
||||
])
|
||||
assert r.returncode == 0, r.stderr
|
||||
agents = json.loads(r.stdout)
|
||||
assert len(agents) > 0, "no agents returned"
|
||||
for a in agents:
|
||||
assert "uptime_seconds" in a, f"agent {a.get('id')} missing uptime_seconds"
|
||||
assert isinstance(a["uptime_seconds"], int) and a["uptime_seconds"] >= 0, \
|
||||
f"agent {a.get('id')} uptime_seconds={a['uptime_seconds']!r} not a non-negative int"
|
||||
|
||||
|
||||
def test_msg_24h_field_present():
|
||||
"""GET /agents response must include messages_24h field (integer >= 0)."""
|
||||
apikey = _apikey()
|
||||
r = _curl([
|
||||
"-fsS",
|
||||
"-H", f"Authorization: Bearer {apikey}",
|
||||
f"{_url()}/agents",
|
||||
])
|
||||
assert r.returncode == 0, r.stderr
|
||||
agents = json.loads(r.stdout)
|
||||
assert len(agents) > 0, "no agents returned"
|
||||
for a in agents:
|
||||
assert "messages_24h" in a, f"agent {a.get('id')} missing messages_24h"
|
||||
assert isinstance(a["messages_24h"], int) and a["messages_24h"] >= 0, \
|
||||
f"agent {a.get('id')} messages_24h={a['messages_24h']!r} not a non-negative int"
|
||||
|
||||
|
||||
def test_clear_memory_requires_apikey():
|
||||
"""POST /agents/{id}/clear_memory must return 401 without Bearer token."""
|
||||
r = _curl([
|
||||
"-s", "-o", "/dev/null", "-w", "%{http_code}",
|
||||
"-X", "POST",
|
||||
f"{_url()}/agents/test-bot/clear_memory",
|
||||
])
|
||||
assert r.stdout == "401", f"expected 401 got {r.stdout!r}"
|
||||
|
||||
|
||||
def test_delete_cache_requires_apikey():
|
||||
"""POST /agents/{id}/delete_cache must return 401 without Bearer token."""
|
||||
r = _curl([
|
||||
"-s", "-o", "/dev/null", "-w", "%{http_code}",
|
||||
"-X", "POST",
|
||||
f"{_url()}/agents/test-bot/delete_cache",
|
||||
])
|
||||
assert r.stdout == "401", f"expected 401 got {r.stdout!r}"
|
||||
|
||||
|
||||
def test_control_roundtrip():
|
||||
"""Stop test-bot → poll running=false → start → restart.
|
||||
|
||||
SECURITY: Only test-bot is used here. Never call stop on real agents
|
||||
during E2E. test-bot is a stateless robot with no ongoing conversations.
|
||||
"""
|
||||
apikey = _apikey()
|
||||
BASE = _url()
|
||||
|
||||
def get_running(agent_id: str) -> bool:
|
||||
r = _curl([
|
||||
"-fsS",
|
||||
"-H", f"Authorization: Bearer {apikey}",
|
||||
f"{BASE}/agents/{agent_id}",
|
||||
])
|
||||
return bool(json.loads(r.stdout).get("running", False))
|
||||
|
||||
# --- Stop ---
|
||||
r = _curl([
|
||||
"-sS", "-X", "POST",
|
||||
"-H", f"Authorization: Bearer {apikey}",
|
||||
f"{BASE}/agents/test-bot/stop",
|
||||
])
|
||||
assert r.returncode == 0, f"stop failed: {r.stderr}"
|
||||
body = json.loads(r.stdout)
|
||||
# Accept "stopped" or "not_running" as valid outcomes
|
||||
assert body.get("status") in ("stopped", "not_running", "ok"), f"unexpected stop status: {body}"
|
||||
|
||||
# Poll until running=false (max 3s)
|
||||
import time
|
||||
for _ in range(15):
|
||||
if not get_running("test-bot"):
|
||||
break
|
||||
time.sleep(0.2)
|
||||
assert not get_running("test-bot"), "test-bot still running after stop"
|
||||
|
||||
# --- Start ---
|
||||
r = _curl([
|
||||
"-sS", "-X", "POST",
|
||||
"-H", f"Authorization: Bearer {apikey}",
|
||||
f"{BASE}/agents/test-bot/start",
|
||||
])
|
||||
assert r.returncode == 0, f"start failed: {r.stderr}"
|
||||
body = json.loads(r.stdout)
|
||||
assert body.get("status") in ("started", "ok"), f"unexpected start status: {body}"
|
||||
|
||||
# Poll until running=true (max 5s)
|
||||
for _ in range(25):
|
||||
if get_running("test-bot"):
|
||||
break
|
||||
time.sleep(0.2)
|
||||
assert get_running("test-bot"), "test-bot not running after start"
|
||||
|
||||
# --- Restart ---
|
||||
r = _curl([
|
||||
"-sS", "-X", "POST",
|
||||
"-H", f"Authorization: Bearer {apikey}",
|
||||
f"{BASE}/agents/test-bot/restart",
|
||||
])
|
||||
assert r.returncode == 0, f"restart failed: {r.stderr}"
|
||||
body = json.loads(r.stdout)
|
||||
assert body.get("status") in ("restarted", "ok"), f"unexpected restart status: {body}"
|
||||
|
||||
# After restart test-bot must be running again (max 5s)
|
||||
for _ in range(25):
|
||||
if get_running("test-bot"):
|
||||
break
|
||||
time.sleep(0.2)
|
||||
assert get_running("test-bot"), "test-bot not running after restart"
|
||||
|
||||
|
||||
def test_unified_stop_does_not_kill_launcher():
|
||||
"""Stopping test-bot must leave assistant-bot (and other real agents) running.
|
||||
|
||||
In unified mode, all agents are goroutines. Per-agent stop must cancel
|
||||
only the target goroutine — not the launcher process.
|
||||
SECURITY: Only test-bot is stopped. assistant-bot is only read, never mutated.
|
||||
"""
|
||||
apikey = _apikey()
|
||||
BASE = _url()
|
||||
|
||||
def get_running(agent_id: str) -> bool:
|
||||
r = _curl([
|
||||
"-fsS",
|
||||
"-H", f"Authorization: Bearer {apikey}",
|
||||
f"{BASE}/agents/{agent_id}",
|
||||
])
|
||||
if r.returncode != 0:
|
||||
return False
|
||||
return bool(json.loads(r.stdout).get("running", False))
|
||||
|
||||
# Stop test-bot only
|
||||
r = _curl([
|
||||
"-sS", "-X", "POST",
|
||||
"-H", f"Authorization: Bearer {apikey}",
|
||||
f"{BASE}/agents/test-bot/stop",
|
||||
])
|
||||
assert r.returncode == 0, f"stop test-bot failed: {r.stderr}"
|
||||
|
||||
# assistant-bot must still be running (launcher is alive)
|
||||
assert get_running("assistant-bot"), \
|
||||
"assistant-bot not running after stopping test-bot — launcher may have crashed"
|
||||
|
||||
# Restore test-bot
|
||||
_curl([
|
||||
"-sS", "-X", "POST",
|
||||
"-H", f"Authorization: Bearer {apikey}",
|
||||
f"{BASE}/agents/test-bot/start",
|
||||
])
|
||||
|
||||
|
||||
def test_clear_memory_response_shape():
|
||||
"""POST /agents/{id}/clear_memory (authorized) returns JSON with expected fields.
|
||||
|
||||
Uses test-bot which has minimal/no memory, so deleting is always safe.
|
||||
Verifies: status, messages_deleted, facts_deleted keys present.
|
||||
"""
|
||||
apikey = _apikey()
|
||||
r = _curl([
|
||||
"-sS", "-X", "POST",
|
||||
"-H", f"Authorization: Bearer {apikey}",
|
||||
f"{_url()}/agents/test-bot/clear_memory",
|
||||
])
|
||||
assert r.returncode == 0, f"clear_memory failed: {r.stderr}"
|
||||
body = json.loads(r.stdout)
|
||||
assert body.get("status") == "cleared", f"expected status=cleared: {body}"
|
||||
assert "messages_deleted" in body, f"missing messages_deleted: {body}"
|
||||
assert "facts_deleted" in body, f"missing facts_deleted: {body}"
|
||||
assert isinstance(body["messages_deleted"], int), \
|
||||
f"messages_deleted not int: {body['messages_deleted']!r}"
|
||||
assert isinstance(body["facts_deleted"], int), \
|
||||
f"facts_deleted not int: {body['facts_deleted']!r}"
|
||||
Reference in New Issue
Block a user