Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 73f80d9c6c | |||
| e4a1c20fc2 | |||
| 9cade2f2f8 | |||
| 18b5ffdfd9 | |||
| aa88a1cb4a |
@@ -40,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;
|
||||
@@ -83,8 +93,8 @@ struct AgentRow {
|
||||
struct AppState {
|
||||
// Connection
|
||||
char base_url[512] = "https://agents.organic-machine.com";
|
||||
char apikey_buf[256] = ""; // populated from AGENTS_API_KEY env at startup, never via UI
|
||||
bool apikey_from_env = false;
|
||||
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;
|
||||
@@ -207,21 +217,101 @@ static void db_load_connection(AppState& s) {
|
||||
sqlite3_finalize(stmt);
|
||||
}
|
||||
|
||||
// load_apikey_from_env reads AGENTS_API_KEY into s.apikey_buf. Trims trailing
|
||||
// whitespace (env vars can carry \r on Windows when sourced from .bat).
|
||||
static void load_apikey_from_env(AppState& s) {
|
||||
// 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) {
|
||||
s.apikey_from_env = false;
|
||||
s.apikey_buf[0] = '\0';
|
||||
return;
|
||||
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;
|
||||
}
|
||||
}
|
||||
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';
|
||||
|
||||
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";
|
||||
}
|
||||
s.apikey_from_env = (n > 0);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 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}
|
||||
@@ -342,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();
|
||||
{
|
||||
@@ -349,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;
|
||||
@@ -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) {
|
||||
s.status_sse.stop();
|
||||
fn_sse::Config cfg;
|
||||
@@ -394,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...");
|
||||
@@ -477,13 +646,21 @@ static void draw_connection_panel(AppState& s) {
|
||||
|
||||
ImGui::Text("API Key:");
|
||||
ImGui::SameLine();
|
||||
if (s.apikey_from_env) {
|
||||
if (s.apikey_source == "env") {
|
||||
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 {
|
||||
ImGui::TextColored(ImVec4(0.9f, 0.3f, 0.3f, 1.0f),
|
||||
TI_ALERT_TRIANGLE " AGENTS_API_KEY env var missing");
|
||||
ImGui::TextDisabled(" Launch with: AGENTS_API_KEY=$(pass agentes/api-key) <exe>");
|
||||
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();
|
||||
@@ -510,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);
|
||||
@@ -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 };
|
||||
|
||||
// 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()) {
|
||||
@@ -753,12 +947,27 @@ static void draw_agents_panel(AppState& s) {
|
||||
|
||||
// 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)");
|
||||
}
|
||||
@@ -942,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);
|
||||
@@ -954,6 +1177,12 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -979,8 +1208,12 @@ static int run_connect_test(const std::string& base_url) {
|
||||
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()) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1047,6 +1280,7 @@ static int run_connect_test(const std::string& base_url) {
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -1056,6 +1290,11 @@ int main(int argc, char** argv) {
|
||||
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) {
|
||||
@@ -1081,17 +1320,18 @@ int main(int argc, char** argv) {
|
||||
cfg.panels = panels;
|
||||
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_load_connection(g_state);
|
||||
load_apikey_from_env(g_state);
|
||||
if (!g_state.apikey_from_env) {
|
||||
fn_log::log_warn("[startup] AGENTS_API_KEY env var missing — backend calls will fail. "
|
||||
"Launch with: AGENTS_API_KEY=$(pass agentes/api-key) ...");
|
||||
}
|
||||
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.
+120
-6
@@ -82,18 +82,132 @@ def test_connect_succeeds_with_valid_apikey():
|
||||
assert n > 0, f"expected at least 1 agent, got {n}"
|
||||
|
||||
|
||||
def test_connect_fails_without_apikey():
|
||||
"""FAIL on stderr, exit 1, when AGENTS_API_KEY is empty."""
|
||||
# Force-empty AGENTS_API_KEY; bypass WSLENV by clearing it too.
|
||||
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": "AGENTS_API_KEY"},
|
||||
env={**os.environ, "AGENTS_API_KEY": "", "WSLENV": ""},
|
||||
)
|
||||
assert r.returncode != 0
|
||||
assert "AGENTS_API_KEY" in r.stderr, f"stderr=[{r.stderr!r}]"
|
||||
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():
|
||||
|
||||
Reference in New Issue
Block a user