Step 431: add workflow progress dashboard model
This commit is contained in:
@@ -2758,4 +2758,13 @@ target_link_libraries(step430_test PRIVATE
|
||||
tree_sitter_javascript tree_sitter_typescript
|
||||
tree_sitter_java tree_sitter_rust tree_sitter_go)
|
||||
|
||||
add_executable(step431_test tests/step431_test.cpp)
|
||||
target_include_directories(step431_test PRIVATE src)
|
||||
target_link_libraries(step431_test PRIVATE
|
||||
nlohmann_json::nlohmann_json
|
||||
unofficial::tree-sitter::tree-sitter
|
||||
tree_sitter_python tree_sitter_cpp tree_sitter_elisp
|
||||
tree_sitter_javascript tree_sitter_typescript
|
||||
tree_sitter_java tree_sitter_rust tree_sitter_go)
|
||||
|
||||
# Step 12: Dear ImGui shell scaffolding created (main.cpp exists but not built due to dependencies)
|
||||
|
||||
112
editor/src/WorkflowProgressDashboard.h
Normal file
112
editor/src/WorkflowProgressDashboard.h
Normal file
@@ -0,0 +1,112 @@
|
||||
#pragma once
|
||||
|
||||
#include "WorkflowProgress.h"
|
||||
#include "WorkflowCostTracker.h"
|
||||
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
struct ThroughputPoint {
|
||||
std::string timestamp;
|
||||
int completed = 0;
|
||||
};
|
||||
|
||||
struct WorkerSlice {
|
||||
std::string workerType;
|
||||
int count = 0;
|
||||
float percent = 0.0f;
|
||||
};
|
||||
|
||||
struct BlockerSummary {
|
||||
std::string type;
|
||||
int count = 0;
|
||||
std::string description;
|
||||
};
|
||||
|
||||
struct ProgressDashboardModel {
|
||||
float completionPercent = 0.0f;
|
||||
int completedItems = 0;
|
||||
int totalItems = 0;
|
||||
float itemsPerMinute = 0.0f;
|
||||
float estimatedRemainingMinutes = -1.0f;
|
||||
std::vector<ThroughputPoint> throughputSeries;
|
||||
std::vector<WorkerSlice> workerBreakdown;
|
||||
std::vector<BlockerSummary> blockerSummary;
|
||||
double actualCost = 0.0;
|
||||
double estimatedCost = 0.0;
|
||||
double estimatedRemainingCost = 0.0;
|
||||
};
|
||||
|
||||
class WorkflowProgressDashboard {
|
||||
public:
|
||||
static ProgressDashboardModel build(const WorkflowState& wf,
|
||||
const WorkflowProgress& progress,
|
||||
const WorkflowCostTracker* costTracker = nullptr) {
|
||||
ProgressDashboardModel m;
|
||||
auto snapshot = progress.getSnapshot();
|
||||
m.completionPercent = snapshot.completionPercent;
|
||||
m.completedItems = snapshot.completedItems;
|
||||
m.totalItems = snapshot.totalItems;
|
||||
m.itemsPerMinute = snapshot.itemsPerMinute;
|
||||
m.estimatedRemainingMinutes = snapshot.estimatedRemainingMinutes;
|
||||
m.throughputSeries = buildThroughputSeries(progress.getTimeline());
|
||||
m.workerBreakdown = buildWorkerBreakdown(wf.getStats().byWorkerType);
|
||||
m.blockerSummary = buildBlockerSummary(snapshot.blockers);
|
||||
|
||||
if (costTracker) {
|
||||
auto cost = costTracker->summarize();
|
||||
m.actualCost = cost.actualCost;
|
||||
m.estimatedCost = cost.estimatedCost;
|
||||
int remaining = std::max(0, m.totalItems - m.completedItems);
|
||||
double avgPerItem = 0.0;
|
||||
if (m.completedItems > 0) avgPerItem = m.actualCost / (double)m.completedItems;
|
||||
m.estimatedRemainingCost = avgPerItem * (double)remaining;
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
private:
|
||||
static std::vector<ThroughputPoint> buildThroughputSeries(
|
||||
const std::vector<TimelineEntry>& timeline) {
|
||||
std::vector<ThroughputPoint> out;
|
||||
int completed = 0;
|
||||
for (const auto& e : timeline) {
|
||||
if (e.type == "completed") ++completed;
|
||||
out.push_back({e.timestamp, completed});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
static std::vector<WorkerSlice> buildWorkerBreakdown(
|
||||
const std::map<std::string, int>& byWorkerType) {
|
||||
std::vector<WorkerSlice> out;
|
||||
int total = 0;
|
||||
for (const auto& [_, count] : byWorkerType) total += count;
|
||||
for (const auto& [worker, count] : byWorkerType) {
|
||||
WorkerSlice s;
|
||||
s.workerType = worker;
|
||||
s.count = count;
|
||||
s.percent = total > 0 ? (100.0f * (float)count / (float)total) : 0.0f;
|
||||
out.push_back(s);
|
||||
}
|
||||
std::sort(out.begin(), out.end(),
|
||||
[](const WorkerSlice& a, const WorkerSlice& b) {
|
||||
return a.workerType < b.workerType;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
static std::vector<BlockerSummary> buildBlockerSummary(
|
||||
const std::vector<BlockerInfo>& blockers) {
|
||||
std::vector<BlockerSummary> out;
|
||||
for (const auto& b : blockers) {
|
||||
out.push_back({b.type, (int)b.itemIds.size(), b.description});
|
||||
}
|
||||
std::sort(out.begin(), out.end(),
|
||||
[](const BlockerSummary& a, const BlockerSummary& b) {
|
||||
return a.type < b.type;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
};
|
||||
203
editor/tests/step431_test.cpp
Normal file
203
editor/tests/step431_test.cpp
Normal file
@@ -0,0 +1,203 @@
|
||||
// Step 431: Progress Dashboard Tests (12 tests)
|
||||
|
||||
#include "WorkflowProgressDashboard.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <iostream>
|
||||
|
||||
static int passed = 0, failed = 0;
|
||||
#define TEST(name) { std::cout << " " << #name << "... "; }
|
||||
#define PASS() { std::cout << "PASS\n"; ++passed; }
|
||||
#define FAIL(msg) { std::cout << "FAIL: " << msg << "\n"; ++failed; }
|
||||
#define CHECK(cond, msg) if (!(cond)) { FAIL(msg); return; } else {}
|
||||
|
||||
static WorkItem makeItem(const std::string& id, const std::string& worker, const std::string& status) {
|
||||
WorkItem wi;
|
||||
wi.id = id;
|
||||
wi.nodeId = id + "_node";
|
||||
wi.nodeName = id;
|
||||
wi.nodeType = "Function";
|
||||
wi.workerType = worker;
|
||||
wi.status = status;
|
||||
wi.priority = "medium";
|
||||
return wi;
|
||||
}
|
||||
|
||||
static WorkflowState makeWorkflow() {
|
||||
WorkflowState wf("dashboard");
|
||||
wf.queue.enqueue(makeItem("a", "deterministic", WI_COMPLETE));
|
||||
wf.queue.enqueue(makeItem("b", "slm", WI_IN_PROGRESS));
|
||||
wf.queue.enqueue(makeItem("c", "llm", WI_REVIEW));
|
||||
wf.queue.enqueue(makeItem("d", "human", WI_READY));
|
||||
return wf;
|
||||
}
|
||||
|
||||
static WorkflowProgress makeProgress() {
|
||||
WorkflowProgress p(4);
|
||||
p.recordEvent({"routed", "a", {{"workerType", "deterministic"}}, "2026-01-01T00:00:00Z"});
|
||||
p.recordEvent({"completed", "a", json::object(), "2026-01-01T00:01:00Z"});
|
||||
p.recordEvent({"routed", "b", {{"workerType", "slm"}}, "2026-01-01T00:02:00Z"});
|
||||
p.recordEvent({"completed", "b", json::object(), "2026-01-01T00:03:00Z"});
|
||||
p.recordEvent({"blocked", "", {{"blockers", json::array({
|
||||
{{"type", "needs-review"}, {"itemIds", json::array({"c"})}, {"description", "review pending"}}
|
||||
})}}, "2026-01-01T00:04:00Z"});
|
||||
return p;
|
||||
}
|
||||
|
||||
static WorkflowCostTracker makeCosts(ModelProfileRegistry& reg) {
|
||||
WorkflowCostTracker costs(®);
|
||||
costs.recordEstimate("a", "deterministic", 100, 20);
|
||||
costs.recordEstimate("b", "slm", 800, 120);
|
||||
costs.recordActual("a", "deterministic", 80, 10);
|
||||
costs.recordActual("b", "slm", 900, 140);
|
||||
return costs;
|
||||
}
|
||||
|
||||
void test_dashboard_copies_completion_metrics() {
|
||||
TEST(dashboard_copies_completion_metrics);
|
||||
auto wf = makeWorkflow();
|
||||
auto p = makeProgress();
|
||||
auto d = WorkflowProgressDashboard::build(wf, p, nullptr);
|
||||
CHECK(d.totalItems == 4, "total items mismatch");
|
||||
CHECK(d.completedItems == 2, "completed mismatch");
|
||||
CHECK(d.completionPercent > 40.0f, "completion percent should be >40");
|
||||
PASS();
|
||||
}
|
||||
|
||||
void test_dashboard_throughput_series_from_timeline() {
|
||||
TEST(dashboard_throughput_series_from_timeline);
|
||||
auto wf = makeWorkflow();
|
||||
auto p = makeProgress();
|
||||
auto d = WorkflowProgressDashboard::build(wf, p, nullptr);
|
||||
CHECK(d.throughputSeries.size() == p.getTimeline().size(), "throughput series size mismatch");
|
||||
PASS();
|
||||
}
|
||||
|
||||
void test_throughput_series_counts_completed_only() {
|
||||
TEST(throughput_series_counts_completed_only);
|
||||
auto wf = makeWorkflow();
|
||||
auto p = makeProgress();
|
||||
auto d = WorkflowProgressDashboard::build(wf, p, nullptr);
|
||||
CHECK(!d.throughputSeries.empty(), "throughput series empty");
|
||||
CHECK(d.throughputSeries.back().completed == 2, "completed counter should end at 2");
|
||||
PASS();
|
||||
}
|
||||
|
||||
void test_worker_breakdown_contains_all_workers() {
|
||||
TEST(worker_breakdown_contains_all_workers);
|
||||
auto wf = makeWorkflow();
|
||||
auto p = makeProgress();
|
||||
auto d = WorkflowProgressDashboard::build(wf, p, nullptr);
|
||||
bool hasDet = false, hasSlm = false, hasLlm = false, hasHuman = false;
|
||||
for (const auto& s : d.workerBreakdown) {
|
||||
if (s.workerType == "deterministic") hasDet = true;
|
||||
if (s.workerType == "slm") hasSlm = true;
|
||||
if (s.workerType == "llm") hasLlm = true;
|
||||
if (s.workerType == "human") hasHuman = true;
|
||||
}
|
||||
CHECK(hasDet && hasSlm && hasLlm && hasHuman, "missing worker slice");
|
||||
PASS();
|
||||
}
|
||||
|
||||
void test_worker_breakdown_percent_sum_around_100() {
|
||||
TEST(worker_breakdown_percent_sum_around_100);
|
||||
auto wf = makeWorkflow();
|
||||
auto p = makeProgress();
|
||||
auto d = WorkflowProgressDashboard::build(wf, p, nullptr);
|
||||
float sum = 0.0f;
|
||||
for (const auto& s : d.workerBreakdown) sum += s.percent;
|
||||
CHECK(std::fabs(sum - 100.0f) < 0.5f, "percent sum should be ~100");
|
||||
PASS();
|
||||
}
|
||||
|
||||
void test_blocker_summary_reflects_snapshot() {
|
||||
TEST(blocker_summary_reflects_snapshot);
|
||||
auto wf = makeWorkflow();
|
||||
auto p = makeProgress();
|
||||
auto d = WorkflowProgressDashboard::build(wf, p, nullptr);
|
||||
CHECK(!d.blockerSummary.empty(), "blocker summary should not be empty");
|
||||
CHECK(d.blockerSummary[0].count == 1, "blocker count mismatch");
|
||||
PASS();
|
||||
}
|
||||
|
||||
void test_cost_fields_populated_with_tracker() {
|
||||
TEST(cost_fields_populated_with_tracker);
|
||||
auto wf = makeWorkflow();
|
||||
auto p = makeProgress();
|
||||
ModelProfileRegistry reg;
|
||||
auto costs = makeCosts(reg);
|
||||
auto d = WorkflowProgressDashboard::build(wf, p, &costs);
|
||||
CHECK(d.actualCost >= 0.0, "actual cost should be non-negative");
|
||||
CHECK(d.estimatedCost >= 0.0, "estimated cost should be non-negative");
|
||||
PASS();
|
||||
}
|
||||
|
||||
void test_remaining_cost_estimate_positive_when_items_left() {
|
||||
TEST(remaining_cost_estimate_positive_when_items_left);
|
||||
auto wf = makeWorkflow();
|
||||
auto p = makeProgress();
|
||||
ModelProfileRegistry reg;
|
||||
auto costs = makeCosts(reg);
|
||||
auto d = WorkflowProgressDashboard::build(wf, p, &costs);
|
||||
CHECK(d.estimatedRemainingCost >= 0.0, "remaining estimate should be non-negative");
|
||||
PASS();
|
||||
}
|
||||
|
||||
void test_costs_zero_without_tracker() {
|
||||
TEST(costs_zero_without_tracker);
|
||||
auto wf = makeWorkflow();
|
||||
auto p = makeProgress();
|
||||
auto d = WorkflowProgressDashboard::build(wf, p, nullptr);
|
||||
CHECK(d.actualCost == 0.0, "actual cost should be zero without tracker");
|
||||
CHECK(d.estimatedCost == 0.0, "estimated cost should be zero without tracker");
|
||||
PASS();
|
||||
}
|
||||
|
||||
void test_empty_timeline_yields_empty_throughput() {
|
||||
TEST(empty_timeline_yields_empty_throughput);
|
||||
auto wf = makeWorkflow();
|
||||
WorkflowProgress p(4);
|
||||
auto d = WorkflowProgressDashboard::build(wf, p, nullptr);
|
||||
CHECK(d.throughputSeries.empty(), "empty timeline should produce empty throughput");
|
||||
PASS();
|
||||
}
|
||||
|
||||
void test_eta_passthrough_from_progress_snapshot() {
|
||||
TEST(eta_passthrough_from_progress_snapshot);
|
||||
auto wf = makeWorkflow();
|
||||
auto p = makeProgress();
|
||||
auto d = WorkflowProgressDashboard::build(wf, p, nullptr);
|
||||
CHECK(d.estimatedRemainingMinutes >= 0.0f, "eta should be available in this scenario");
|
||||
PASS();
|
||||
}
|
||||
|
||||
void test_dashboard_handles_zero_total_items() {
|
||||
TEST(dashboard_handles_zero_total_items);
|
||||
WorkflowState wf("empty");
|
||||
WorkflowProgress p(0);
|
||||
auto d = WorkflowProgressDashboard::build(wf, p, nullptr);
|
||||
CHECK(d.totalItems == 0, "total should be zero");
|
||||
CHECK(d.completionPercent == 0.0f, "completion should be zero");
|
||||
PASS();
|
||||
}
|
||||
|
||||
int main() {
|
||||
std::cout << "Step 431: Progress Dashboard Tests\n";
|
||||
|
||||
test_dashboard_copies_completion_metrics(); // 1
|
||||
test_dashboard_throughput_series_from_timeline(); // 2
|
||||
test_throughput_series_counts_completed_only(); // 3
|
||||
test_worker_breakdown_contains_all_workers(); // 4
|
||||
test_worker_breakdown_percent_sum_around_100(); // 5
|
||||
test_blocker_summary_reflects_snapshot(); // 6
|
||||
test_cost_fields_populated_with_tracker(); // 7
|
||||
test_remaining_cost_estimate_positive_when_items_left(); // 8
|
||||
test_costs_zero_without_tracker(); // 9
|
||||
test_empty_timeline_yields_empty_throughput(); // 10
|
||||
test_eta_passthrough_from_progress_snapshot(); // 11
|
||||
test_dashboard_handles_zero_total_items(); // 12
|
||||
|
||||
std::cout << "\nResults: " << passed << "/" << (passed + failed)
|
||||
<< " passed\n";
|
||||
return failed == 0 ? 0 : 1;
|
||||
}
|
||||
44
progress.md
44
progress.md
@@ -4900,6 +4900,50 @@ coding, critical-path highlighting, hover details, and click navigation IDs.
|
||||
- `editor/src/MCPServer.h` (`1940` > `600`)
|
||||
- `editor/src/HeadlessAgentRPCHandler.h` (`2768` > `600`)
|
||||
|
||||
### Step 431: Progress Dashboard
|
||||
**Status:** PASS (12/12 tests)
|
||||
|
||||
Added a progress-dashboard model adapter that composes workflow stats,
|
||||
progress/timeline metrics, blocker summaries, worker distribution, and cost
|
||||
tracking into a panel-ready snapshot.
|
||||
|
||||
**Files created:**
|
||||
- `editor/src/WorkflowProgressDashboard.h` — dashboard support:
|
||||
- completion metrics passthrough (percent, throughput, ETA)
|
||||
- throughput series derivation from timeline events
|
||||
- worker-breakdown slices with percentage normalization
|
||||
- blocker-summary projection for action surfaces
|
||||
- optional cost integration (`WorkflowCostTracker`) with remaining-cost estimate
|
||||
- `editor/tests/step431_test.cpp` — 12 tests covering:
|
||||
1. completion metric passthrough
|
||||
2. throughput-series length from timeline
|
||||
3. completed-count progression in throughput series
|
||||
4. worker-slice presence
|
||||
5. worker-percent normalization
|
||||
6. blocker-summary projection
|
||||
7. populated cost fields with tracker
|
||||
8. remaining-cost estimate path
|
||||
9. zero-cost behavior without tracker
|
||||
10. empty-timeline edge case
|
||||
11. ETA passthrough
|
||||
12. zero-total workflow edge case
|
||||
|
||||
**Files modified:**
|
||||
- `editor/CMakeLists.txt` — `step431_test` target
|
||||
|
||||
**Verification run:**
|
||||
- `step431_test` — PASS (12/12) new step coverage
|
||||
- `step430_test` — PASS (12/12) regression coverage
|
||||
- `step429_test` — PASS (12/12) regression coverage
|
||||
|
||||
**Architecture gate check:**
|
||||
- `editor/src/WorkflowProgressDashboard.h` within header-size limit (`112` <= `600`)
|
||||
- `editor/tests/step431_test.cpp` within test-file size guidance (`202` lines)
|
||||
- Legacy oversized headers persist:
|
||||
- `editor/src/ast/Serialization.h` (`1427` > `600`)
|
||||
- `editor/src/MCPServer.h` (`1940` > `600`)
|
||||
- `editor/src/HeadlessAgentRPCHandler.h` (`2768` > `600`)
|
||||
|
||||
# Roadmap Planning — Sprints 12-25+
|
||||
|
||||
## Status: Planning Complete (Sprints 12-19 detailed, 20-25 in roadmap.md)
|
||||
|
||||
Reference in New Issue
Block a user