chore: auto-commit (8 archivos)

- backend/handlers.go
- data.cpp
- data.h
- main.cpp
- panel_board.cpp
- panel_filters.cpp
- appicon.ico
- backend/kanban_cpp_backend.exe

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 19:38:15 +02:00
parent 255e8dcf71
commit 98bf278472
8 changed files with 374 additions and 51 deletions
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

+78 -4
View File
@@ -1,8 +1,10 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
@@ -11,6 +13,8 @@ import (
"fn-registry/functions/infra"
)
const agentRunnerBase = "http://127.0.0.1:8486"
func (s *Server) registerRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/health", s.handleHealth)
mux.HandleFunc("/api/issues", s.handleIssues)
@@ -19,6 +23,8 @@ func (s *Server) registerRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/flows/", s.handleFlowByID)
mux.HandleFunc("/api/meta", s.handleMeta)
mux.HandleFunc("/api/sse", s.handleSSE)
mux.HandleFunc("/api/agent_status", s.handleAgentStatus)
mux.HandleFunc("/api/agent_launch", s.handleAgentLaunch)
}
func writeJSON(w http.ResponseWriter, status int, v any) {
@@ -279,13 +285,81 @@ func (s *Server) handleFlowByID(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleMeta(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{
"statuses": []string{"pendiente", "in-progress", "bloqueado", "completado", "deferred", "descartado"},
"priorities": []string{"critica", "alta", "media", "baja"},
"scopes": []string{"registry-only", "app-scoped", "multi-app", "cross-stack"},
"types": []string{"feature", "bugfix", "refactor", "docs", "chore", "research", "infra", "app", "spike", "epic", "planning"},
"statuses": []string{"ideas", "pendiente", "in-progress", "bloqueado", "completado", "deferred", "descartado"},
"board_columns": []string{"ideas", "pendiente", "in-progress", "completado"},
"priorities": []string{"critica", "alta", "media", "baja"},
"scopes": []string{"registry-only", "app-scoped", "multi-app", "cross-stack"},
"types": []string{"feature", "bugfix", "refactor", "docs", "chore", "research", "infra", "app", "spike", "epic", "planning"},
})
}
// GET /api/agent_status — proxies agent_runner_api running runs, returns map issue_id -> run_id
func (s *Server) handleAgentStatus(w http.ResponseWriter, r *http.Request) {
resp, err := http.Get(agentRunnerBase + "/api/runs?status=running")
if err != nil {
writeJSON(w, 200, map[string]any{"available": false, "active": map[string]string{}})
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var runs []map[string]any
if err := json.Unmarshal(body, &runs); err != nil {
writeJSON(w, 200, map[string]any{"available": false, "active": map[string]string{}})
return
}
active := map[string]string{}
for _, run := range runs {
issueID, _ := run["issue_id"].(string)
runID, _ := run["id"].(string)
if issueID != "" && runID != "" {
active[issueID] = runID
}
}
writeJSON(w, 200, map[string]any{"available": true, "active": active})
}
// POST /api/agent_launch {"issue_id":"NNNN"} — forwards to agent_runner_api
func (s *Server) handleAgentLaunch(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeErr(w, 405, "method not allowed")
return
}
var req struct {
IssueID string `json:"issue_id"`
Mode string `json:"mode"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, 400, "bad json")
return
}
if req.IssueID == "" {
writeErr(w, 400, "issue_id required")
return
}
if req.Mode == "" {
req.Mode = "fix-issue"
}
payload, _ := json.Marshal(map[string]string{
"issue_id": req.IssueID,
"mode": req.Mode,
"kanban_app": "kanban_cpp",
})
resp, err := http.Post(agentRunnerBase+"/api/runs", "application/json", bytes.NewReader(payload))
if err != nil {
writeErr(w, 502, fmt.Sprintf("agent_runner_api unreachable: %v", err))
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
writeErr(w, resp.StatusCode, string(body))
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
w.Write(body)
}
func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
BIN
View File
Binary file not shown.
+55
View File
@@ -86,6 +86,16 @@ static fn_http::Response http_patch_json(const std::string& path, const std::str
return fn_http::request(req);
}
static fn_http::Response http_post_json(const std::string& path, const std::string& body) {
fn_http::Request req;
req.method = "POST";
req.url = state().backend_url + path;
req.timeout_ms = 10000;
req.headers.push_back({"Content-Type", "application/json"});
req.body = body;
return fn_http::request(req);
}
bool refresh_issues() {
auto resp = http_get("/api/issues");
if (resp.status != 200) {
@@ -179,6 +189,51 @@ bool patch_issue_status(const std::string& id, const std::string& new_status) {
return true;
}
bool refresh_agent_status() {
auto resp = http_get("/api/agent_status");
if (resp.status != 200) {
std::lock_guard<std::mutex> g(state().mu);
state().agent_runner_up = false;
state().agent_active.clear();
return false;
}
auto j = json::parse(resp.body, nullptr, false);
if (j.is_discarded()) return false;
std::map<std::string, std::string> active;
if (j.contains("active") && j["active"].is_object()) {
for (auto it = j["active"].begin(); it != j["active"].end(); ++it) {
if (it.value().is_string()) active[it.key()] = it.value().get<std::string>();
}
}
bool up = j.value("available", false);
std::lock_guard<std::mutex> g(state().mu);
state().agent_runner_up = up;
state().agent_active = std::move(active);
return true;
}
bool launch_agent(const std::string& issue_id) {
std::string body = "{\"issue_id\":" + json_escape(issue_id) + ",\"mode\":\"fix-issue\"}";
auto resp = http_post_json("/api/agent_launch", body);
if (resp.status < 200 || resp.status >= 300) {
std::lock_guard<std::mutex> g(state().mu);
state().last_launch_msg = "launch failed (" + std::to_string(resp.status) + "): " + resp.body;
return false;
}
{
std::lock_guard<std::mutex> g(state().mu);
state().last_launch_msg = "launched agent on " + issue_id;
// Optimistically mark as active so the dot shows up immediately.
state().agent_active[issue_id] = "pending";
}
return true;
}
bool is_agent_active(const std::string& issue_id) {
std::lock_guard<std::mutex> g(state().mu);
return state().agent_active.find(issue_id) != state().agent_active.end();
}
bool patch_issue_fields(const std::string& id, const std::string& json_partial) {
auto resp = http_patch_json("/api/issues/" + id, json_partial);
if (resp.status != 200) {
+20 -11
View File
@@ -1,5 +1,6 @@
#pragma once
#include <map>
#include <mutex>
#include <set>
#include <string>
@@ -48,20 +49,25 @@ struct Filters {
std::set<std::string> priorities;
std::set<std::string> tags;
bool include_completed = false;
bool show_issues = true;
bool show_flows = true;
};
struct State {
std::mutex mu;
std::string backend_url = "http://127.0.0.1:8487";
std::vector<Issue> issues;
std::vector<Flow> flows;
Meta meta;
Filters filters;
std::string selected_issue_id;
Issue selected_issue_detail;
bool loading = false;
std::string last_error;
long long last_refresh_ns = 0;
std::mutex mu;
std::string backend_url = "http://127.0.0.1:8487";
std::vector<Issue> issues;
std::vector<Flow> flows;
Meta meta;
Filters filters;
std::string selected_issue_id;
Issue selected_issue_detail;
bool loading = false;
std::string last_error;
long long last_refresh_ns = 0;
std::map<std::string, std::string> agent_active; // issue_id -> run_id
bool agent_runner_up = false;
std::string last_launch_msg;
};
State& state();
@@ -73,6 +79,9 @@ bool refresh_meta();
bool refresh_issue_detail(const std::string& id);
bool patch_issue_status(const std::string& id, const std::string& new_status);
bool patch_issue_fields(const std::string& id, const std::string& json_partial);
bool refresh_agent_status();
bool launch_agent(const std::string& issue_id);
bool is_agent_active(const std::string& issue_id);
// Background SSE subscription (no-op if already started).
void start_sse();
+10 -3
View File
@@ -28,12 +28,19 @@ static void start_refresh_thread() {
kanban::refresh_meta();
kanban::refresh_issues();
kanban::refresh_flows();
kanban::refresh_agent_status();
kanban::start_sse();
int tick = 0;
while (g_refresh_thread_alive) {
std::this_thread::sleep_for(std::chrono::seconds(30));
// Fast loop (every 3s) for agent status; full refresh every 30s.
std::this_thread::sleep_for(std::chrono::seconds(3));
if (!g_refresh_thread_alive) break;
kanban::refresh_issues();
kanban::refresh_flows();
kanban::refresh_agent_status();
if (++tick >= 10) {
tick = 0;
kanban::refresh_issues();
kanban::refresh_flows();
}
}
kanban::stop_sse();
});
+203 -33
View File
@@ -3,17 +3,29 @@
#include <imgui.h>
#include <imgui_internal.h>
#include <map>
#include <mutex>
#include <string>
#include <utility>
#include <vector>
#include "core/icons_tabler.h"
namespace kanban {
static const char* k_columns[] = {"pendiente", "in-progress", "bloqueado", "completado"};
static const char* k_columns[] = {"ideas", "pendiente", "in-progress", "completado"};
static const char* k_column_icons[] = {TI_BULB, TI_CLIPBOARD, TI_TOOLS, TI_CIRCLE_CHECK};
static const int k_n_columns = sizeof(k_columns) / sizeof(k_columns[0]);
// Map flow status (free-form) → one of the 4 board columns.
static int flow_column_index(const std::string& s) {
if (s == "draft" || s == "ideas") return 0;
if (s == "active" || s == "in-progress") return 2;
if (s == "done" || s == "completed" || s == "completado") return 3;
// pending, paused, "" → pendiente
return 1;
}
static ImU32 priority_color(const std::string& p) {
if (p == "critica") return IM_COL32(255, 80, 80, 255);
if (p == "alta") return IM_COL32(255, 165, 0, 255);
@@ -22,14 +34,25 @@ static ImU32 priority_color(const std::string& p) {
return IM_COL32(180, 180, 180, 255);
}
static ImU32 status_tint(const std::string& s) {
if (s == "bloqueado") return IM_COL32(120, 60, 60, 200);
if (s == "completado") return IM_COL32( 60, 110, 60, 100);
if (s == "in-progress") return IM_COL32( 70, 90, 140, 100);
if (s == "ideas") return IM_COL32(110, 90, 140, 100);
return IM_COL32(60, 60, 70, 100);
}
static void draw_card(const Issue& iss) {
ImGui::PushID(iss.id.c_str());
ImVec2 size(0.0f, 64.0f);
const float card_h = 110.0f;
ImVec2 size(0.0f, card_h);
ImU32 bg = status_tint(iss.status);
ImGui::PushStyleColor(ImGuiCol_ChildBg, bg);
ImGui::BeginChild("card", size, true, ImGuiWindowFlags_NoScrollbar);
// Header: id + priority badge
ImGui::TextColored(ImColor(180, 180, 180, 255), "%s", iss.id.c_str());
// Top row: id · priority dot · agent icon (right-aligned) · block badge if blocked
ImGui::TextColored(ImColor(200, 200, 200, 255), "%s", iss.id.c_str());
ImGui::SameLine();
{
ImVec2 cur = ImGui::GetCursorScreenPos();
@@ -39,22 +62,42 @@ static void draw_card(const Issue& iss) {
dl->AddCircleFilled(ImVec2(cur.x + r, cur.y + r + 2), r, c);
ImGui::Dummy(ImVec2(2 * r + 4, 2 * r));
ImGui::SameLine();
ImGui::TextUnformatted(iss.priority.c_str());
ImGui::TextColored(ImColor(180, 180, 180, 255), "%s", iss.priority.c_str());
}
// Title (truncate to ~60 chars)
// Right-aligned indicators (agent active + blocked).
{
std::string indicators;
bool active = is_agent_active(iss.id);
if (active) indicators += TI_ROBOT;
if (iss.status == "bloqueado") { if (!indicators.empty()) indicators += " "; indicators += TI_LOCK; }
if (!indicators.empty()) {
ImVec2 ts = ImGui::CalcTextSize(indicators.c_str());
float avail = ImGui::GetContentRegionAvail().x;
ImGui::SameLine(0, avail - ts.x - 4);
if (active) {
ImGui::TextColored(ImColor(120, 220, 120, 255), "%s", indicators.c_str());
} else {
ImGui::TextColored(ImColor(220, 120, 120, 255), "%s", indicators.c_str());
}
}
}
// Title (2-3 lines)
std::string t = iss.title;
if (t.size() > 70) t = t.substr(0, 67) + "...";
if (t.size() > 140) t = t.substr(0, 137) + "...";
ImGui::TextWrapped("%s", t.c_str());
// First domain chip
// Bottom: first domain chip
if (!iss.domain.empty()) {
ImGui::TextColored(ImColor(140, 200, 255, 255), TI_TAG " %s", iss.domain[0].c_str());
ImGui::TextColored(ImColor(140, 200, 255, 255), "%s %s", TI_TAG, iss.domain[0].c_str());
}
ImGui::EndChild();
ImGui::PopStyleColor();
if (ImGui::IsItemClicked()) {
// Click → load detail
if (ImGui::IsItemClicked(ImGuiMouseButton_Left)) {
refresh_issue_detail(iss.id);
}
@@ -65,26 +108,106 @@ static void draw_card(const Issue& iss) {
ImGui::EndDragDropSource();
}
// Right-click context menu
if (ImGui::BeginPopupContextItem("card_ctx")) {
if (iss.status == "bloqueado") {
if (ImGui::MenuItem(TI_LOCK_OPEN " Unblock (→ pendiente)")) {
patch_issue_status(iss.id, "pendiente");
}
} else {
if (ImGui::MenuItem(TI_LOCK " Block this issue")) {
patch_issue_status(iss.id, "bloqueado");
}
}
ImGui::Separator();
if (ImGui::MenuItem(TI_PLAYER_PLAY " Launch agent (fix-issue)", nullptr, false, !is_agent_active(iss.id))) {
launch_agent(iss.id);
}
if (is_agent_active(iss.id)) {
ImGui::TextDisabled(TI_ROBOT " agent already running");
}
ImGui::Separator();
if (ImGui::MenuItem("Move → ideas")) patch_issue_status(iss.id, "ideas");
if (ImGui::MenuItem("Move → pendiente")) patch_issue_status(iss.id, "pendiente");
if (ImGui::MenuItem("Move → in-progress")) patch_issue_status(iss.id, "in-progress");
if (ImGui::MenuItem("Move → completado")) patch_issue_status(iss.id, "completado");
ImGui::EndPopup();
}
ImGui::PopID();
}
static void draw_column(const char* status_key, std::vector<const Issue*>& items) {
static void draw_flow_card(const Flow& fl, int child_issue_count) {
ImGui::PushID(("flow_" + fl.id).c_str());
const float card_h = 88.0f;
ImVec2 size(0.0f, card_h);
ImU32 bg = IM_COL32(80, 60, 110, 100);
ImGui::PushStyleColor(ImGuiCol_ChildBg, bg);
ImGui::BeginChild("flow_card", size, true, ImGuiWindowFlags_NoScrollbar);
ImGui::TextColored(ImColor(190, 170, 230, 255), "%s flow %s", TI_GIT_BRANCH, fl.id.c_str());
{
ImVec2 ts = ImGui::CalcTextSize("999 issues");
float avail = ImGui::GetContentRegionAvail().x;
ImGui::SameLine(0, avail - ts.x - 4);
ImGui::TextDisabled("%d issues", child_issue_count);
}
std::string t = fl.title;
if (t.size() > 140) t = t.substr(0, 137) + "...";
ImGui::TextWrapped("%s", t.c_str());
if (!fl.tags.empty()) {
ImGui::TextColored(ImColor(140, 200, 255, 255), "%s %s", TI_TAG, fl.tags[0].c_str());
}
ImGui::EndChild();
ImGui::PopStyleColor();
// Click → set selected_issue_id to first child issue (best UX guess).
if (ImGui::IsItemClicked(ImGuiMouseButton_Left)) {
std::lock_guard<std::mutex> g(state().mu);
for (const auto& iss : state().issues) {
if (iss.flow == fl.id) {
state().selected_issue_id = iss.id;
state().selected_issue_detail = iss;
break;
}
}
}
if (ImGui::BeginPopupContextItem("flow_ctx")) {
ImGui::TextDisabled("flow %s — %d issues", fl.id.c_str(), child_issue_count);
ImGui::Separator();
ImGui::TextDisabled("(no actions; edit %s manually)", fl.file_path.c_str());
ImGui::EndPopup();
}
ImGui::PopID();
}
static void draw_column(const char* status_key, const char* icon,
std::vector<const Issue*>& issues,
std::vector<std::pair<const Flow*, int>>& flows) {
ImGui::TableNextColumn();
// Column header with count
ImGui::PushFont(nullptr);
ImGui::TextColored(ImColor(220, 220, 220, 255), "%s (%zu)", status_key, items.size());
size_t total = issues.size() + flows.size();
ImGui::TextColored(ImColor(220, 220, 220, 255), "%s %s (%zu)", icon, status_key, total);
ImGui::PopFont();
ImGui::Separator();
// Drop target on the whole column
ImVec2 region = ImGui::GetContentRegionAvail();
ImGui::BeginChild((std::string("col_") + status_key).c_str(), region, false);
for (const auto* iss : items) {
// Issues first (heavier visual), then flows (lighter).
for (const auto* iss : issues) {
draw_card(*iss);
}
// Bottom drop zone
ImGui::InvisibleButton("col_drop", ImVec2(-1, 16));
for (const auto& [fl, cnt] : flows) {
draw_flow_card(*fl, cnt);
}
ImGui::InvisibleButton("col_drop", ImVec2(-1, 24));
if (ImGui::BeginDragDropTarget()) {
if (const ImGuiPayload* p = ImGui::AcceptDragDropPayload("KANBAN_ISSUE")) {
std::string id((const char*)p->Data, p->DataSize - 1);
@@ -109,37 +232,84 @@ void draw_board() {
return;
}
// Snapshot under lock
std::vector<Issue> snapshot;
std::vector<Issue> issue_snap;
std::vector<Flow> flow_snap;
bool agent_up = false;
int agent_running_count = 0;
std::string last_launch;
bool show_issues_flag = true;
bool show_flows_flag = true;
{
std::lock_guard<std::mutex> g(state().mu);
snapshot = state().issues;
issue_snap = state().issues;
flow_snap = state().flows;
agent_up = state().agent_runner_up;
agent_running_count = (int)state().agent_active.size();
last_launch = state().last_launch_msg;
show_issues_flag = state().filters.show_issues;
show_flows_flag = state().filters.show_flows;
}
// Bucket issues by status
std::vector<std::vector<const Issue*>> buckets(k_n_columns);
// Bucket issues per column.
std::vector<std::vector<const Issue*>> issue_buckets(k_n_columns);
int filtered_out = 0;
for (const auto& iss : snapshot) {
if (!passes_filters(iss)) {
filtered_out++;
continue;
}
for (int c = 0; c < k_n_columns; ++c) {
if (iss.status == k_columns[c]) {
buckets[c].push_back(&iss);
break;
if (show_issues_flag) {
for (const auto& iss : issue_snap) {
if (!passes_filters(iss)) {
filtered_out++;
continue;
}
std::string s = iss.status;
if (s == "bloqueado") s = "pendiente";
for (int c = 0; c < k_n_columns; ++c) {
if (s == k_columns[c]) {
issue_buckets[c].push_back(&iss);
break;
}
}
}
}
ImGui::Text("%zu total — %d filtered out", snapshot.size(), filtered_out);
// Count children per flow (issues that reference flow_id).
std::map<std::string, int> flow_child_count;
for (const auto& iss : issue_snap) {
if (!iss.flow.empty()) flow_child_count[iss.flow]++;
}
// Bucket flows per column using flow_column_index.
std::vector<std::vector<std::pair<const Flow*, int>>> flow_buckets(k_n_columns);
if (show_flows_flag) {
for (const auto& fl : flow_snap) {
int col = flow_column_index(fl.status);
int cnt = flow_child_count.count(fl.id) ? flow_child_count[fl.id] : 0;
flow_buckets[col].push_back({&fl, cnt});
}
}
// Header line
size_t total_visible = 0;
for (int c = 0; c < k_n_columns; ++c) total_visible += issue_buckets[c].size() + flow_buckets[c].size();
ImGui::Text("%zu issues + %zu flows — %d filtered out — visible %zu",
issue_snap.size(), flow_snap.size(), filtered_out, total_visible);
ImGui::SameLine();
ImGui::TextDisabled(" | ");
ImGui::SameLine();
if (agent_up) {
ImGui::TextColored(ImColor(120, 220, 120, 255), TI_ROBOT " agent_runner_api OK — %d active", agent_running_count);
} else {
ImGui::TextColored(ImColor(220, 140, 140, 255), TI_ROBOT " agent_runner_api offline");
}
if (!last_launch.empty()) {
ImGui::SameLine();
ImGui::TextDisabled(" | %s", last_launch.c_str());
}
ImGui::Separator();
if (ImGui::BeginTable("board_table", k_n_columns,
ImGuiTableFlags_BordersInnerV | ImGuiTableFlags_Resizable | ImGuiTableFlags_SizingStretchSame)) {
ImGui::TableNextRow();
for (int c = 0; c < k_n_columns; ++c) {
draw_column(k_columns[c], buckets[c]);
draw_column(k_columns[c], k_column_icons[c], issue_buckets[c], flow_buckets[c]);
}
ImGui::EndTable();
}
+8
View File
@@ -44,6 +44,14 @@ void draw_filters() {
domains = collect_domains();
tags = collect_tags();
// Type toggle (Issues / Flows / Both).
ImGui::TextUnformatted("Show:");
ImGui::SameLine();
ImGui::Checkbox("Issues", &state().filters.show_issues);
ImGui::SameLine();
ImGui::Checkbox("Flows", &state().filters.show_flows);
ImGui::Separator();
multi_select("Priorities", meta.priorities, state().filters.priorities);
multi_select("Scopes", meta.scopes, state().filters.scopes);
multi_select("Domains", domains, state().filters.domains);