feat: Launch workflow boton via agent_runner_api (issue 0116)

- Anade boton 'Launch workflow' (TI_ROCKET) que hace POST async a
  http://localhost:8486/api/runs con {issue_id, mode:'fix-issue'}.
- HTTP async via std::thread + fn_http::request (de http_request_cpp_core)
  con timeout 3s. NO bloquea el frame.
- Feature flag 'legacy_claude_fix' (default OFF) controla la visibilidad
  del boton 'Claude fix' legacy (terminal externa + claude --dangerously-
  skip-permissions). Flag leido al arrancar desde dev/feature_flags.json.
- Toast 3s con run_id devuelto por el API (o error si :8486 down /
  transport fail / HTTP non-2xx). Render thread-safe con mutex.
- CMakeLists.txt linkea cpp/functions/core/http_request.cpp.
- app.md: version 0.1.0 -> 0.2.0, uses_functions anade http_request_cpp_core,
  capability growth log con entrada v0.2.0.
This commit is contained in:
agent
2026-05-18 18:46:02 +02:00
parent 202bfc5ccb
commit 9ee3be8e4e
3 changed files with 171 additions and 12 deletions
+1
View File
@@ -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})
+13
View File
@@ -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.
+148 -3
View File
@@ -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 <algorithm>
#include <atomic>
#include <chrono>
#include <cmath>
#include <cstdlib>
@@ -18,8 +20,10 @@
#include <filesystem>
#include <fstream>
#include <map>
#include <mutex>
#include <sstream>
#include <string>
#include <thread>
#include <unordered_map>
#include <unordered_set>
#include <vector>
@@ -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": "<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<std::mutex> 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,6 +1241,20 @@ static void draw_inspector() {
const Bucket b = status_bucket(n.status_eff);
const bool fixable = (b != Bucket::Done);
if (fixable) {
// 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("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));
@@ -1143,11 +1267,29 @@ static void draw_inspector() {
}
ImGui::PopStyleColor(3);
ImGui::SameLine();
ImGui::TextDisabled("abre terminal externa con `claude --dangerously-skip-permissions` en fn_registry");
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<std::mutex> 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();