diff --git a/CMakeLists.txt b/CMakeLists.txt index 0771bf7..7bd7849 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2,6 +2,7 @@ add_imgui_app(skill_tree main.cpp ${CMAKE_SOURCE_DIR}/functions/core/parse_md_frontmatter.cpp ${CMAKE_SOURCE_DIR}/functions/core/compute_ring_layout.cpp + ${CMAKE_SOURCE_DIR}/functions/core/http_request.cpp ) target_include_directories(skill_tree PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) diff --git a/app.md b/app.md index eaa1fa1..4c1a233 100644 --- a/app.md +++ b/app.md @@ -2,6 +2,7 @@ name: skill_tree lang: cpp domain: tools +version: 0.2.0 description: "Mapa interactivo de issues+flows en anillos concentricos por estado, click para spawn agentes" tags: [dashboard, meta, imgui] icon: @@ -10,6 +11,7 @@ icon: uses_functions: - parse_md_frontmatter_cpp_core - compute_ring_layout_cpp_core + - http_request_cpp_core uses_types: [] framework: "imgui" entry_point: "main.cpp" @@ -51,3 +53,14 @@ cd cpp && cmake --build build --target skill_tree -j ## Estado MVP fase A — sub-issue 0109a: shell + parsers issues/flows. Sin render de grafo todavia. + + +## Capability growth log + +Una linea por bump SemVer. Bump-type segun `.claude/commands/version.md`: +- `major`: breaking observable (CLI args, schema BBDD propia, formato wire). +- `minor`: feature aditiva (nuevo panel, endpoint, opcion). +- `patch`: bugfix sin cambio observable. + +- v0.1.0 (2026-05-18) — baseline. +- v0.2.0 (2026-05-18) — boton `Launch workflow` (async POST :8486/api/runs via fn_http::request) + feature flag `legacy_claude_fix` que oculta el boton `Claude fix` (terminal externa). Toast con run_id 3s. Issue 0116. diff --git a/main.cpp b/main.cpp index 0eaa611..bbb4db9 100644 --- a/main.cpp +++ b/main.cpp @@ -9,8 +9,10 @@ #include "core/logger.h" #include "core/parse_md_frontmatter.h" #include "core/compute_ring_layout.h" +#include "core/http_request.h" #include +#include #include #include #include @@ -18,8 +20,10 @@ #include #include #include +#include #include #include +#include #include #include #include @@ -241,6 +245,107 @@ static bool spawn_claude_terminal(const fs::path& registry_root) { #endif } +// ---- Feature flag (legacy_claude_fix) ----------------------------------- +// +// Reads dev/feature_flags.json once at startup, caches the boolean. Minimal +// substring parser (no JSON library dependency at this scope — keeps the +// boot path tiny). Looks for "legacy_claude_fix" followed by "enabled":true +// inside the same object block. Defaults to false on any parse hiccup. + +static bool g_legacy_claude_fix_flag = false; + +static void load_feature_flags(const fs::path& registry_root) { + fs::path p = registry_root / "dev" / "feature_flags.json"; + std::ifstream f(p); + if (!f.is_open()) { + g_legacy_claude_fix_flag = false; + return; + } + std::stringstream ss; ss << f.rdbuf(); + std::string s = ss.str(); + auto k = s.find("\"legacy_claude_fix\""); + if (k == std::string::npos) { g_legacy_claude_fix_flag = false; return; } + // Scan forward until the next closing brace; look for "enabled": true. + auto end = s.find('}', k); + if (end == std::string::npos) end = s.size(); + auto en = s.find("\"enabled\"", k); + if (en == std::string::npos || en > end) { g_legacy_claude_fix_flag = false; return; } + auto colon = s.find(':', en); + if (colon == std::string::npos) { g_legacy_claude_fix_flag = false; return; } + // Skip whitespace and check for "true". + size_t i = colon + 1; + while (i < s.size() && (s[i] == ' ' || s[i] == '\t' || s[i] == '\n' || s[i] == '\r')) ++i; + g_legacy_claude_fix_flag = (i + 4 <= s.size() && s.compare(i, 4, "true") == 0); +} + +// ---- Launch workflow (agent_runner_api POST) ---------------------------- +// +// Async HTTP POST to http://localhost:8486/api/runs with +// { "issue_id": "", "mode": "fix-issue" }. Toast carries the resulting +// run_id (or error) for 3 seconds. Uses fn_http::request on a detached +// std::thread so the ImGui frame is never blocked. + +struct LaunchToast { + std::mutex mu; + std::string text; // human-readable message + bool ok = true; + double expires_at = -1.0; // seconds since steady_clock epoch +}; + +static LaunchToast g_launch_toast; + +static void show_launch_toast(const std::string& msg, bool ok, double ttl_s = 3.0) { + std::lock_guard lk(g_launch_toast.mu); + g_launch_toast.text = msg; + g_launch_toast.ok = ok; + g_launch_toast.expires_at = now_seconds() + ttl_s; +} + +static std::string extract_json_string_field(const std::string& body, const std::string& key) { + std::string needle = "\"" + key + "\""; + auto k = body.find(needle); + if (k == std::string::npos) return {}; + auto colon = body.find(':', k); + if (colon == std::string::npos) return {}; + auto q1 = body.find('"', colon + 1); + if (q1 == std::string::npos) return {}; + auto q2 = body.find('"', q1 + 1); + if (q2 == std::string::npos) return {}; + return body.substr(q1 + 1, q2 - q1 - 1); +} + +static void launch_workflow_async(const std::string& issue_id) { + show_launch_toast(std::string(TI_LOADER " launching workflow for ") + issue_id + "...", true, 30.0); + std::thread([issue_id]() { + fn_http::Request req; + req.method = "POST"; + req.url = "http://localhost:8486/api/runs"; + req.headers.push_back({"Content-Type", "application/json"}); + // Minimal hand-rolled JSON — both fields are simple ASCII slugs. + req.body = "{\"issue_id\":\"" + issue_id + "\",\"mode\":\"fix-issue\"}"; + req.timeout_ms = 3000; + fn_http::Response resp = fn_http::request(req); + if (resp.status == 0) { + show_launch_toast(std::string("agent_runner_api unreachable: ") + resp.error, false); + fn_log::log_warn("skill_tree: launch_workflow %s -> transport error: %s", + issue_id.c_str(), resp.error.c_str()); + return; + } + if (resp.status >= 200 && resp.status < 300) { + std::string run_id = extract_json_string_field(resp.body, "run_id"); + if (run_id.empty()) run_id = extract_json_string_field(resp.body, "id"); + std::string msg = "run_id=" + (run_id.empty() ? std::string("(unknown)") : run_id); + show_launch_toast(msg, true); + fn_log::log_info("skill_tree: launch_workflow %s -> %s", issue_id.c_str(), msg.c_str()); + } else { + char buf[128]; std::snprintf(buf, sizeof(buf), "HTTP %d", resp.status); + show_launch_toast(std::string(buf) + ": " + resp.body.substr(0, 80), false); + fn_log::log_warn("skill_tree: launch_workflow %s -> %s body=%s", + issue_id.c_str(), buf, resp.body.c_str()); + } + }).detach(); +} + // ---- Scanner ------------------------------------------------------------ static void scan_dir(const fs::path& dir, NodeKind kind, ScanResult& out) { @@ -1123,7 +1228,12 @@ static void draw_inspector() { ImGui::Separator(); - // === Claude fix: lanza terminal externa con claude === + // === Launch workflow / Claude fix === + // + // Default path: POST to http://localhost:8486/api/runs and let the + // agent_runner_api spawn the worker headless. Legacy path (terminal + // external + interactive claude CLI) sits behind feature flag + // `legacy_claude_fix` in dev/feature_flags.json. Default OFF (issue 0116). static double s_last_launch_t = -1e9; static bool s_last_launch_ok = true; static std::string s_last_launch_id; @@ -1131,23 +1241,55 @@ static void draw_inspector() { const Bucket b = status_bucket(n.status_eff); const bool fixable = (b != Bucket::Done); if (fixable) { - ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32(168, 85, 247, 200)); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32(192, 132, 252, 240)); - ImGui::PushStyleColor(ImGuiCol_ButtonActive, IM_COL32(147, 51, 234, 255)); - if (ImGui::Button(TI_TERMINAL_2 " Claude fix")) { - s_last_launch_ok = spawn_claude_terminal(g_root); - s_last_launch_t = now_seconds(); - s_last_launch_id = n.id; - fn_log::log_info("skill_tree: spawn claude terminal for %s -> %s", - n.id.c_str(), s_last_launch_ok ? "ok" : "fail"); + // Launch workflow (always available): async POST to agent_runner_api. + ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32( 16, 185, 129, 220)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32( 52, 211, 153, 250)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, IM_COL32( 6, 148, 110, 255)); + if (ImGui::Button(TI_ROCKET " Launch workflow")) { + launch_workflow_async(n.id); + fn_log::log_info("skill_tree: launch_workflow requested for %s", n.id.c_str()); } ImGui::PopStyleColor(3); ImGui::SameLine(); - ImGui::TextDisabled("abre terminal externa con `claude --dangerously-skip-permissions` en fn_registry"); + ImGui::TextDisabled("POST :8486/api/runs (fix-issue) — agent corre headless"); + + // Legacy Claude fix: only rendered when feature flag is ON. + if (g_legacy_claude_fix_flag) { + ImGui::PushStyleColor(ImGuiCol_Button, IM_COL32(168, 85, 247, 200)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, IM_COL32(192, 132, 252, 240)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, IM_COL32(147, 51, 234, 255)); + if (ImGui::Button(TI_TERMINAL_2 " Claude fix")) { + s_last_launch_ok = spawn_claude_terminal(g_root); + s_last_launch_t = now_seconds(); + s_last_launch_id = n.id; + fn_log::log_info("skill_tree: spawn claude terminal for %s -> %s", + n.id.c_str(), s_last_launch_ok ? "ok" : "fail"); + } + ImGui::PopStyleColor(3); + ImGui::SameLine(); + ImGui::TextDisabled("legacy: abre terminal externa con `claude --dangerously-skip-permissions`"); + } } else { ImGui::TextDisabled("Issue done — no fix necesario."); } - if (s_last_launch_id == n.id && (now_seconds() - s_last_launch_t) < 5.0) { + + // Toast del Launch workflow (run_id / error). Thread-safe lectura. + { + std::lock_guard lk(g_launch_toast.mu); + if (!g_launch_toast.text.empty() && now_seconds() < g_launch_toast.expires_at) { + if (g_launch_toast.ok) { + ImGui::TextColored(ImVec4(0.4f, 0.95f, 0.5f, 1.0f), + TI_CHECK " %s", g_launch_toast.text.c_str()); + } else { + ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.5f, 1.0f), + TI_ALERT_TRIANGLE " %s", g_launch_toast.text.c_str()); + } + } + } + + // Toast legacy Claude fix. + if (g_legacy_claude_fix_flag && + s_last_launch_id == n.id && (now_seconds() - s_last_launch_t) < 5.0) { if (s_last_launch_ok) { ImGui::TextColored(ImVec4(0.4f, 0.95f, 0.5f, 1.0f), TI_CHECK " terminal lanzada (revisa Windows Terminal / WSL)"); @@ -1385,6 +1527,9 @@ static void render() { g_root = discover_registry_root(); fn_log::log_info("skill_tree: discover_registry_root -> '%s'", g_root.empty() ? "(empty)" : g_root.string().c_str()); + load_feature_flags(g_root); + fn_log::log_info("skill_tree: legacy_claude_fix flag = %s", + g_legacy_claude_fix_flag ? "ON" : "OFF"); reload_scan(); } if (g_show_tree) draw_tree();