feat: initial scaffold kanban_cpp v0.1.0

C++ ImGui kanban for steering LLM agents. Six panels (Board, Calendar,
Dashboard, Agent runs, Worktrees, DoD inspector) wired to registry
functions http_request, kpi_card, sparkline, agent_runs_timeline,
dod_evidence_panel. Backend Go on :8403 (independent operations.db from
kanban_web).
This commit is contained in:
Egutierrez
2026-05-18 18:46:09 +02:00
commit a76ec74338
42 changed files with 5922 additions and 0 deletions
+174
View File
@@ -0,0 +1,174 @@
// data.cpp — HTTP client implementation for kanban_cpp.
//
// JSON parsing is intentionally manual + permissive: backend is "ours" and
// payload shapes are stable. If we ever need a real parser, swap to nlohmann
// or rapidjson; today the extra dep is not justified (KISS).
#include "data.h"
#include "core/http_request.h"
#include "core/logger.h"
#include <cstring>
#include <cstdio>
namespace kanban_cpp {
namespace {
// Tiny helpers: scan JSON strings out of a raw buffer. NOT a real parser —
// only handles flat-ish payloads our backend emits. Good enough for MVP.
std::string find_str_field(const std::string& s, const std::string& key) {
std::string needle = "\"" + key + "\":";
size_t p = s.find(needle);
if (p == std::string::npos) return "";
p += needle.size();
while (p < s.size() && (s[p] == ' ' || s[p] == '\t')) ++p;
if (p >= s.size() || s[p] != '"') return "";
++p;
std::string out;
while (p < s.size() && s[p] != '"') {
if (s[p] == '\\' && p + 1 < s.size()) {
char c = s[p + 1];
if (c == 'n') out += '\n';
else if (c == 't') out += '\t';
else out += c;
p += 2;
continue;
}
out += s[p++];
}
return out;
}
int64_t find_int_field(const std::string& s, const std::string& key) {
std::string needle = "\"" + key + "\":";
size_t p = s.find(needle);
if (p == std::string::npos) return 0;
p += needle.size();
while (p < s.size() && (s[p] == ' ' || s[p] == '\t')) ++p;
char* end = nullptr;
long long v = std::strtoll(s.c_str() + p, &end, 10);
return static_cast<int64_t>(v);
}
// Split JSON array of objects at depth 1. Returns each object as a substring.
std::vector<std::string> split_objects(const std::string& s) {
std::vector<std::string> out;
int depth = 0;
size_t start = 0;
bool in_obj = false;
for (size_t i = 0; i < s.size(); ++i) {
char c = s[i];
if (c == '{') {
if (depth == 0) { start = i; in_obj = true; }
++depth;
} else if (c == '}') {
--depth;
if (depth == 0 && in_obj) {
out.push_back(s.substr(start, i - start + 1));
in_obj = false;
}
}
}
return out;
}
fn_http::Response do_get(const std::string& url, int timeout_ms) {
fn_http::Request req;
req.method = "GET";
req.url = url;
req.timeout_ms = timeout_ms;
return fn_http::request(req);
}
fn_http::Response do_post_json(const std::string& url, const std::string& body, int timeout_ms) {
fn_http::Request req;
req.method = "POST";
req.url = url;
req.timeout_ms = timeout_ms;
req.body = body;
req.headers.push_back({"Content-Type", "application/json"});
return fn_http::request(req);
}
} // namespace
bool health(const ClientConfig& cfg) {
auto r = do_get(cfg.base_url + "/health", cfg.timeout_ms);
return r.status >= 200 && r.status < 300;
}
std::vector<Card> list_cards(const ClientConfig& cfg, std::string& err) {
auto r = do_get(cfg.base_url + "/api/cards", cfg.timeout_ms);
if (r.status == 0) { err = "transport: " + r.error; return {}; }
if (r.status >= 400) { err = "http " + std::to_string(r.status); return {}; }
std::vector<Card> out;
for (const auto& obj : split_objects(r.body)) {
Card c;
c.id = find_str_field(obj, "id");
c.title = find_str_field(obj, "title");
c.description = find_str_field(obj, "description");
c.column_id = find_str_field(obj, "column_id");
c.priority = find_str_field(obj, "priority");
c.status = find_str_field(obj, "status");
c.position = find_int_field(obj, "position");
c.due_date = find_int_field(obj, "due_date");
c.assignee = find_str_field(obj, "assignee");
if (!c.id.empty()) out.push_back(c);
}
return out;
}
std::vector<Column> list_columns(const ClientConfig& cfg, std::string& err) {
auto r = do_get(cfg.base_url + "/api/columns", cfg.timeout_ms);
if (r.status == 0) { err = "transport: " + r.error; return {}; }
if (r.status >= 400) { err = "http " + std::to_string(r.status); return {}; }
std::vector<Column> out;
for (const auto& obj : split_objects(r.body)) {
Column c;
c.id = find_str_field(obj, "id");
c.name = find_str_field(obj, "name");
c.order = static_cast<int>(find_int_field(obj, "order"));
if (!c.id.empty()) out.push_back(c);
}
return out;
}
bool move_card(const ClientConfig& cfg, const std::string& card_id,
const std::string& new_column_id, std::string& err) {
std::string body = "{\"column_id\":\"" + new_column_id + "\"}";
auto r = do_post_json(cfg.base_url + "/api/cards/" + card_id + "/move", body, cfg.timeout_ms);
if (r.status == 0) { err = "transport: " + r.error; return false; }
if (r.status >= 400) { err = "http " + std::to_string(r.status); return false; }
return true;
}
std::vector<AgentRunSummary> list_runs(const ClientConfig& cfg, std::string& err) {
auto r = do_get(cfg.agent_runner_url + "/api/runs", cfg.timeout_ms);
if (r.status == 0) { err = "transport: " + r.error; return {}; }
if (r.status >= 400) { err = "http " + std::to_string(r.status); return {}; }
std::vector<AgentRunSummary> out;
for (const auto& obj : split_objects(r.body)) {
AgentRunSummary s;
s.id = find_str_field(obj, "id");
s.card_id = find_str_field(obj, "card_id");
s.branch = find_str_field(obj, "branch");
s.status = find_str_field(obj, "status");
s.started_at = find_int_field(obj, "started_at");
s.finished_at = find_int_field(obj, "finished_at");
if (!s.id.empty()) out.push_back(s);
}
return out;
}
bool launch_workflow(const ClientConfig& cfg, const std::string& card_id,
std::string& out_run_id, std::string& err) {
std::string body = "{\"card_id\":\"" + card_id + "\"}";
auto r = do_post_json(cfg.agent_runner_url + "/api/runs", body, cfg.timeout_ms);
if (r.status == 0) { err = "transport: " + r.error; return false; }
if (r.status >= 400) { err = "http " + std::to_string(r.status); return false; }
out_run_id = find_str_field(r.body, "id");
return true;
}
} // namespace kanban_cpp