Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 73f80d9c6c | |||
| e4a1c20fc2 | |||
| 9cade2f2f8 | |||
| 18b5ffdfd9 | |||
| aa88a1cb4a |
@@ -40,6 +40,16 @@
|
|||||||
#include <sstream>
|
#include <sstream>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <thread>
|
#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>
|
#include <vector>
|
||||||
|
|
||||||
using json = nlohmann::json;
|
using json = nlohmann::json;
|
||||||
@@ -83,8 +93,8 @@ struct AgentRow {
|
|||||||
struct AppState {
|
struct AppState {
|
||||||
// Connection
|
// Connection
|
||||||
char base_url[512] = "https://agents.organic-machine.com";
|
char base_url[512] = "https://agents.organic-machine.com";
|
||||||
char apikey_buf[256] = ""; // populated from AGENTS_API_KEY env at startup, never via UI
|
char apikey_buf[256] = ""; // populated at startup from env OR `pass agentes/api-key`
|
||||||
bool apikey_from_env = false;
|
std::string apikey_source; // "env" | "pass" | "missing"
|
||||||
bool connected = false;
|
bool connected = false;
|
||||||
std::string connect_error;
|
std::string connect_error;
|
||||||
long long last_fetch_ms = 0;
|
long long last_fetch_ms = 0;
|
||||||
@@ -207,21 +217,101 @@ static void db_load_connection(AppState& s) {
|
|||||||
sqlite3_finalize(stmt);
|
sqlite3_finalize(stmt);
|
||||||
}
|
}
|
||||||
|
|
||||||
// load_apikey_from_env reads AGENTS_API_KEY into s.apikey_buf. Trims trailing
|
// Helper: rstrip whitespace + control chars.
|
||||||
// whitespace (env vars can carry \r on Windows when sourced from .bat).
|
static void rstrip_ctrl(std::string& s) {
|
||||||
static void load_apikey_from_env(AppState& 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");
|
const char* k = std::getenv("AGENTS_API_KEY");
|
||||||
if (!k || !*k) {
|
if (k && *k) {
|
||||||
s.apikey_from_env = false;
|
snprintf(s.apikey_buf, sizeof(s.apikey_buf), "%s", k);
|
||||||
s.apikey_buf[0] = '\0';
|
size_t n = strlen(s.apikey_buf);
|
||||||
return;
|
while (n > 0 && (unsigned char)s.apikey_buf[n - 1] <= 0x20)
|
||||||
|
s.apikey_buf[--n] = '\0';
|
||||||
|
if (n > 0) {
|
||||||
|
s.apikey_source = "env";
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
snprintf(s.apikey_buf, sizeof(s.apikey_buf), "%s", k);
|
|
||||||
size_t n = strlen(s.apikey_buf);
|
std::string from_pass = fetch_apikey_via_pass();
|
||||||
while (n > 0 && (unsigned char)s.apikey_buf[n - 1] <= 0x20) {
|
if (!from_pass.empty()) {
|
||||||
s.apikey_buf[--n] = '\0';
|
snprintf(s.apikey_buf, sizeof(s.apikey_buf), "%s", from_pass.c_str());
|
||||||
|
s.apikey_source = "pass";
|
||||||
}
|
}
|
||||||
s.apikey_from_env = (n > 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static void db_save_state(AppState& s, const char* key, const char* value) {
|
static void db_save_state(AppState& s, const char* key, const char* value) {
|
||||||
@@ -283,29 +373,47 @@ static std::vector<AgentRow> parse_agents(const std::string& body) {
|
|||||||
return rows;
|
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
|
// Fetch agents in background thread
|
||||||
static void fetch_agents_async(AppState& s) {
|
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;
|
s.fetching = true;
|
||||||
std::thread([&s]() {
|
std::thread([&s]() {
|
||||||
|
FN_DBG("fetch thread STARTED");
|
||||||
fn_http::Request req;
|
fn_http::Request req;
|
||||||
req.method = "GET";
|
req.method = "GET";
|
||||||
req.url = make_url(s, "/agents");
|
req.url = make_url(s, "/agents");
|
||||||
req.bearer_token = s.apikey_buf;
|
req.bearer_token = s.apikey_buf;
|
||||||
req.timeout_ms = 8000;
|
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);
|
auto res = fn_http::request(req);
|
||||||
std::lock_guard<std::mutex> lk(s.agents_mu);
|
FN_DBG("fetch thread response status=%d err=[%s] body_len=%zu",
|
||||||
if (!res.error.empty()) {
|
res.status, res.error.c_str(), res.body.size());
|
||||||
s.agents_error = "Transport error: " + res.error;
|
{
|
||||||
} else if (res.status != 200) {
|
std::lock_guard<std::mutex> lk(s.agents_mu);
|
||||||
s.agents_error = "HTTP " + std::to_string(res.status);
|
if (!res.error.empty()) {
|
||||||
} else {
|
s.agents_error = "Transport error: " + res.error;
|
||||||
s.agents = parse_agents(res.body);
|
} else if (res.status != 200) {
|
||||||
s.agents_error.clear();
|
s.agents_error = "HTTP " + std::to_string(res.status);
|
||||||
s.agents_fetched_ms = now_ms();
|
} 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;
|
s.fetching = false;
|
||||||
|
FN_DBG("fetch thread DONE");
|
||||||
}).detach();
|
}).detach();
|
||||||
|
FN_DBG("fetch_agents_async EXIT (thread detached)");
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST action to /agents/{id}/{action}
|
// POST action to /agents/{id}/{action}
|
||||||
@@ -342,6 +450,35 @@ static void agent_action(AppState& s, const std::string& agent_id,
|
|||||||
// SSE connections
|
// SSE connections
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Fetch historical log tail via REST before subscribing to SSE.
|
||||||
|
// Returns synchronously; caller usually spawns it in a thread + starts SSE next.
|
||||||
|
static void fetch_log_history(AppState& s, const std::string& agent_id, int n) {
|
||||||
|
fn_http::Request req;
|
||||||
|
req.method = "GET";
|
||||||
|
req.url = make_url(s, "/agents/" + agent_id + "/logs?n=" + std::to_string(n));
|
||||||
|
req.bearer_token = s.apikey_buf;
|
||||||
|
req.timeout_ms = 5000;
|
||||||
|
auto res = fn_http::request(req);
|
||||||
|
if (!res.error.empty() || res.status != 200) {
|
||||||
|
FN_DBG("fetch_log_history %s failed: status=%d err=%s",
|
||||||
|
agent_id.c_str(), res.status, res.error.c_str());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
auto j = json::parse(res.body, nullptr, false);
|
||||||
|
if (j.is_discarded()) return;
|
||||||
|
// Endpoint returns {count, id, lines:[...]} (object) — not a raw array.
|
||||||
|
const auto* arr = j.is_array() ? &j : j.contains("lines") ? &j["lines"] : nullptr;
|
||||||
|
if (!arr || !arr->is_array()) return;
|
||||||
|
std::lock_guard<std::mutex> lk(s.log_mu);
|
||||||
|
for (auto& line : *arr) {
|
||||||
|
if (!line.is_string()) continue;
|
||||||
|
s.log_lines.push_back(line.get<std::string>());
|
||||||
|
while (s.log_lines.size() > 5000) s.log_lines.pop_front();
|
||||||
|
}
|
||||||
|
FN_DBG("fetch_log_history %s loaded %zu lines",
|
||||||
|
agent_id.c_str(), s.log_lines.size());
|
||||||
|
}
|
||||||
|
|
||||||
static void start_log_sse(AppState& s, const std::string& agent_id) {
|
static void start_log_sse(AppState& s, const std::string& agent_id) {
|
||||||
s.log_sse.stop();
|
s.log_sse.stop();
|
||||||
{
|
{
|
||||||
@@ -349,6 +486,11 @@ static void start_log_sse(AppState& s, const std::string& agent_id) {
|
|||||||
s.log_lines.clear();
|
s.log_lines.clear();
|
||||||
s.log_sse_agent_connected = agent_id;
|
s.log_sse_agent_connected = agent_id;
|
||||||
}
|
}
|
||||||
|
// Populate historical tail BEFORE subscribing so the user sees context
|
||||||
|
// immediately instead of an empty panel until new lines appear.
|
||||||
|
if (!agent_id.empty()) {
|
||||||
|
std::thread([&s, agent_id]() { fetch_log_history(s, agent_id, 200); }).detach();
|
||||||
|
}
|
||||||
fn_sse::Config cfg;
|
fn_sse::Config cfg;
|
||||||
cfg.url = make_url(s, "/sse/agents/" + agent_id + "/logs");
|
cfg.url = make_url(s, "/sse/agents/" + agent_id + "/logs");
|
||||||
cfg.bearer_token = s.apikey_buf;
|
cfg.bearer_token = s.apikey_buf;
|
||||||
@@ -366,6 +508,30 @@ static void start_log_sse(AppState& s, const std::string& agent_id) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch recent status events to seed the Status Feed panel on connect.
|
||||||
|
static void fetch_status_history(AppState& s, int n) {
|
||||||
|
fn_http::Request req;
|
||||||
|
req.method = "GET";
|
||||||
|
req.url = make_url(s, "/status/recent?n=" + std::to_string(n));
|
||||||
|
req.bearer_token = s.apikey_buf;
|
||||||
|
req.timeout_ms = 5000;
|
||||||
|
auto res = fn_http::request(req);
|
||||||
|
if (!res.error.empty() || res.status != 200) {
|
||||||
|
FN_DBG("fetch_status_history failed: status=%d err=%s",
|
||||||
|
res.status, res.error.c_str());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
auto j = json::parse(res.body, nullptr, false);
|
||||||
|
if (j.is_discarded() || !j.is_array()) return;
|
||||||
|
std::lock_guard<std::mutex> lk(s.status_mu);
|
||||||
|
for (auto& ev : j) {
|
||||||
|
std::string entry = "[hist] " + ev.dump();
|
||||||
|
s.status_events.push_front(entry);
|
||||||
|
while (s.status_events.size() > 200) s.status_events.pop_back();
|
||||||
|
}
|
||||||
|
FN_DBG("fetch_status_history loaded %zu events into feed", s.status_events.size());
|
||||||
|
}
|
||||||
|
|
||||||
static void start_status_sse(AppState& s) {
|
static void start_status_sse(AppState& s) {
|
||||||
s.status_sse.stop();
|
s.status_sse.stop();
|
||||||
fn_sse::Config cfg;
|
fn_sse::Config cfg;
|
||||||
@@ -394,6 +560,9 @@ static void start_status_sse(AppState& s) {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
static bool g_self_test = false;
|
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() {
|
static bool run_self_test() {
|
||||||
fn_log::log_info("[self-test] checking subsystems...");
|
fn_log::log_info("[self-test] checking subsystems...");
|
||||||
@@ -477,13 +646,21 @@ static void draw_connection_panel(AppState& s) {
|
|||||||
|
|
||||||
ImGui::Text("API Key:");
|
ImGui::Text("API Key:");
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
if (s.apikey_from_env) {
|
if (s.apikey_source == "env") {
|
||||||
ImGui::TextColored(ImVec4(0.2f, 0.85f, 0.4f, 1.0f),
|
ImGui::TextColored(ImVec4(0.2f, 0.85f, 0.4f, 1.0f),
|
||||||
TI_CHECK " loaded from AGENTS_API_KEY env (pass agentes/api-key)");
|
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 {
|
} else {
|
||||||
ImGui::TextColored(ImVec4(0.9f, 0.3f, 0.3f, 1.0f),
|
ImGui::TextColored(ImVec4(0.9f, 0.3f, 0.3f, 1.0f),
|
||||||
TI_ALERT_TRIANGLE " AGENTS_API_KEY env var missing");
|
TI_ALERT_TRIANGLE " apikey not found (env empty + pass failed)");
|
||||||
ImGui::TextDisabled(" Launch with: AGENTS_API_KEY=$(pass agentes/api-key) <exe>");
|
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();
|
ImGui::Separator();
|
||||||
@@ -510,7 +687,10 @@ static void draw_connection_panel(AppState& s) {
|
|||||||
s.connected = true;
|
s.connected = true;
|
||||||
fn_log::log_info("[connect] OK");
|
fn_log::log_info("[connect] OK");
|
||||||
db_save_connection(s);
|
db_save_connection(s);
|
||||||
// Start SSEs
|
// Seed Status Feed with history BEFORE subscribing live, so the
|
||||||
|
// user sees recent activity from the moment Connect succeeds.
|
||||||
|
std::thread([&s]() { fetch_status_history(s, 100); }).detach();
|
||||||
|
// Start SSEs (Status live)
|
||||||
start_status_sse(s);
|
start_status_sse(s);
|
||||||
// Initial agents fetch
|
// Initial agents fetch
|
||||||
fetch_agents_async(s);
|
fetch_agents_async(s);
|
||||||
@@ -656,6 +836,20 @@ static void draw_agents_panel(AppState& s) {
|
|||||||
};
|
};
|
||||||
static const int kSrcForEff[N_COLS] = { 0,1,2,3,4, 5,6,7,8,9,10 };
|
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)
|
// Build column specs (Badge for Status, Button for action columns)
|
||||||
static data_table::TableInput main_t;
|
static data_table::TableInput main_t;
|
||||||
if (main_t.column_specs.empty()) {
|
if (main_t.column_specs.empty()) {
|
||||||
@@ -753,12 +947,27 @@ static void draw_agents_panel(AppState& s) {
|
|||||||
|
|
||||||
// Need at least 1 row for the API to be happy
|
// Need at least 1 row for the API to be happy
|
||||||
if (n_rows > 0) {
|
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",
|
render_grid_stage0("##agents_tbl",
|
||||||
s.agents_tbl_state,
|
s.agents_tbl_state,
|
||||||
cells_ptr.empty() ? nullptr : cells_ptr.data(),
|
cells_ptr.empty() ? nullptr : cells_ptr.data(),
|
||||||
n_rows, N_COLS, N_COLS,
|
n_rows, N_COLS, N_COLS,
|
||||||
kHeaders, kTypes, kSrcForEff,
|
kHeaders, kTypes, kSrcForEff,
|
||||||
visible_rows, main_t, &events);
|
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 {
|
} else {
|
||||||
ImGui::TextDisabled("(no agents match filter)");
|
ImGui::TextDisabled("(no agents match filter)");
|
||||||
}
|
}
|
||||||
@@ -942,6 +1151,20 @@ static void draw_status_feed_panel(AppState& s) {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
static void render() {
|
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);
|
draw_connection_panel(g_state);
|
||||||
if (g_show_agents) draw_agents_panel(g_state);
|
if (g_show_agents) draw_agents_panel(g_state);
|
||||||
if (g_show_logs) draw_logs_panel(g_state);
|
if (g_show_logs) draw_logs_panel(g_state);
|
||||||
@@ -954,6 +1177,12 @@ static void render() {
|
|||||||
fetch_agents_async(g_state);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -979,8 +1208,12 @@ static int run_connect_test(const std::string& base_url) {
|
|||||||
while (n > 0 && (unsigned char)apikey[n - 1] <= 0x20) --n;
|
while (n > 0 && (unsigned char)apikey[n - 1] <= 0x20) --n;
|
||||||
apikey.resize(n);
|
apikey.resize(n);
|
||||||
}
|
}
|
||||||
|
// Fallback: try `pass agentes/api-key` if env is empty (same flow as UI).
|
||||||
if (apikey.empty()) {
|
if (apikey.empty()) {
|
||||||
fprintf(stderr, "FAIL AGENTS_API_KEY env var empty/missing\n");
|
apikey = fetch_apikey_via_pass();
|
||||||
|
}
|
||||||
|
if (apikey.empty()) {
|
||||||
|
fprintf(stderr, "FAIL apikey not found (env empty + pass failed)\n");
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1047,6 +1280,7 @@ static int run_connect_test(const std::string& base_url) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
int main(int argc, char** argv) {
|
int main(int argc, char** argv) {
|
||||||
|
FN_DBG("main ENTER argc=%d", argc);
|
||||||
// Self-test mode
|
// Self-test mode
|
||||||
for (int i = 1; i < argc; i++) {
|
for (int i = 1; i < argc; i++) {
|
||||||
if (strcmp(argv[i], "--self-test") == 0) {
|
if (strcmp(argv[i], "--self-test") == 0) {
|
||||||
@@ -1056,6 +1290,11 @@ int main(int argc, char** argv) {
|
|||||||
if (strcmp(argv[i], "--connect-test") == 0 && i + 1 < argc) {
|
if (strcmp(argv[i], "--connect-test") == 0 && i + 1 < argc) {
|
||||||
return run_connect_test(argv[i + 1]);
|
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) {
|
if (g_self_test) {
|
||||||
@@ -1081,17 +1320,18 @@ int main(int argc, char** argv) {
|
|||||||
cfg.panels = panels;
|
cfg.panels = panels;
|
||||||
cfg.panel_count = sizeof(panels) / sizeof(panels[0]);
|
cfg.panel_count = sizeof(panels) / sizeof(panels[0]);
|
||||||
|
|
||||||
// Init DB and load saved base_url + read apikey from env (sourced from `pass agentes/api-key`).
|
// Init DB and load saved base_url + apikey (env first, fallback to `pass agentes/api-key`).
|
||||||
db_open(g_state);
|
db_open(g_state);
|
||||||
db_load_connection(g_state);
|
db_load_connection(g_state);
|
||||||
load_apikey_from_env(g_state);
|
FN_DBG("startup: db loaded base_url=%s", g_state.base_url);
|
||||||
if (!g_state.apikey_from_env) {
|
load_apikey(g_state);
|
||||||
fn_log::log_warn("[startup] AGENTS_API_KEY env var missing — backend calls will fail. "
|
FN_DBG("startup: apikey_source=%s apikey_len=%zu",
|
||||||
"Launch with: AGENTS_API_KEY=$(pass agentes/api-key) ...");
|
g_state.apikey_source.c_str(), strlen(g_state.apikey_buf));
|
||||||
}
|
|
||||||
|
|
||||||
// Cleanup on exit
|
// Cleanup on exit
|
||||||
|
FN_DBG("startup: calling fn::run_app");
|
||||||
int ret = fn::run_app(cfg, render);
|
int ret = fn::run_app(cfg, render);
|
||||||
|
FN_DBG("fn::run_app returned %d", ret);
|
||||||
|
|
||||||
// Persist state
|
// Persist state
|
||||||
db_save_state(g_state, "log_autoscroll", g_state.log_autoscroll ? "1" : "0");
|
db_save_state(g_state, "log_autoscroll", g_state.log_autoscroll ? "1" : "0");
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
+120
-6
@@ -82,18 +82,132 @@ def test_connect_succeeds_with_valid_apikey():
|
|||||||
assert n > 0, f"expected at least 1 agent, got {n}"
|
assert n > 0, f"expected at least 1 agent, got {n}"
|
||||||
|
|
||||||
|
|
||||||
def test_connect_fails_without_apikey():
|
def test_status_recent_history_endpoint():
|
||||||
"""FAIL on stderr, exit 1, when AGENTS_API_KEY is empty."""
|
"""GET /status/recent returns a JSON array (may be empty after restart)."""
|
||||||
# Force-empty AGENTS_API_KEY; bypass WSLENV by clearing it too.
|
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(
|
r = subprocess.run(
|
||||||
[str(_exe()), "--connect-test", _url()],
|
[str(_exe()), "--connect-test", _url()],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=30,
|
timeout=30,
|
||||||
env={**os.environ, "AGENTS_API_KEY": "", "WSLENV": "AGENTS_API_KEY"},
|
env={**os.environ, "AGENTS_API_KEY": "", "WSLENV": ""},
|
||||||
)
|
)
|
||||||
assert r.returncode != 0
|
assert r.returncode == 0, f"pass fallback failed: stdout={r.stdout!r} stderr={r.stderr!r}"
|
||||||
assert "AGENTS_API_KEY" in r.stderr, f"stderr=[{r.stderr!r}]"
|
assert r.stdout.startswith("OK "), f"stdout=[{r.stdout!r}]"
|
||||||
|
|
||||||
|
|
||||||
def test_connect_fails_on_bad_host():
|
def test_connect_fails_on_bad_host():
|
||||||
|
|||||||
Reference in New Issue
Block a user