summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--internal/api/budget.go36
-rw-r--r--internal/api/budget_test.go68
-rw-r--r--internal/api/server.go2
-rw-r--r--internal/cli/serve.go3
-rw-r--r--web/app.js48
-rw-r--r--web/index.html1
-rw-r--r--web/style.css21
-rw-r--r--web/test/budget.test.mjs58
8 files changed, 237 insertions, 0 deletions
diff --git a/internal/api/budget.go b/internal/api/budget.go
new file mode 100644
index 0000000..eee5d09
--- /dev/null
+++ b/internal/api/budget.go
@@ -0,0 +1,36 @@
+package api
+
+import (
+ "net/http"
+
+ "github.com/thepeterstone/claudomator/internal/budget"
+)
+
+// budgetReporter exposes per-provider spend headroom. Satisfied by
+// *budget.Accountant; an interface keeps the handler testable.
+type budgetReporter interface {
+ All() ([]budget.Headroom, error)
+}
+
+// SetBudget wires the budget accountant so GET /api/budget can report headroom.
+func (s *Server) SetBudget(b budgetReporter) {
+ s.budget = b
+}
+
+// handleGetBudget returns per-provider rolling-window spend headroom. When no
+// budget is configured it returns an empty list so the UI simply shows nothing.
+func (s *Server) handleGetBudget(w http.ResponseWriter, _ *http.Request) {
+ if s.budget == nil {
+ writeJSON(w, http.StatusOK, []budget.Headroom{})
+ return
+ }
+ hs, err := s.budget.All()
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+ if hs == nil {
+ hs = []budget.Headroom{}
+ }
+ writeJSON(w, http.StatusOK, hs)
+}
diff --git a/internal/api/budget_test.go b/internal/api/budget_test.go
new file mode 100644
index 0000000..d59836d
--- /dev/null
+++ b/internal/api/budget_test.go
@@ -0,0 +1,68 @@
+package api
+
+import (
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/thepeterstone/claudomator/internal/budget"
+)
+
+type fakeBudget struct {
+ hs []budget.Headroom
+ err error
+}
+
+func (f fakeBudget) All() ([]budget.Headroom, error) { return f.hs, f.err }
+
+func TestHandleGetBudget_NoBudgetConfigured_ReturnsEmpty(t *testing.T) {
+ srv, _ := testServer(t)
+ req := httptest.NewRequest("GET", "/api/budget", nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status: want 200, got %d", w.Code)
+ }
+ var hs []budget.Headroom
+ if err := json.Unmarshal(w.Body.Bytes(), &hs); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if len(hs) != 0 {
+ t.Errorf("want empty list, got %+v", hs)
+ }
+}
+
+func TestHandleGetBudget_ReportsHeadroom(t *testing.T) {
+ srv, _ := testServer(t)
+ srv.SetBudget(fakeBudget{hs: []budget.Headroom{
+ {Provider: "claude", Limited: true, Limit: 10, Spent: 4, Remaining: 6, Fraction: 0.6},
+ }})
+ req := httptest.NewRequest("GET", "/api/budget", nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status: want 200, got %d", w.Code)
+ }
+ var hs []budget.Headroom
+ if err := json.Unmarshal(w.Body.Bytes(), &hs); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if len(hs) != 1 || hs[0].Provider != "claude" || hs[0].Remaining != 6 {
+ t.Errorf("unexpected headroom: %+v", hs)
+ }
+}
+
+func TestHandleGetBudget_ErrorReturns500(t *testing.T) {
+ srv, _ := testServer(t)
+ srv.SetBudget(fakeBudget{err: errors.New("db down")})
+ req := httptest.NewRequest("GET", "/api/budget", nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+ if w.Code != http.StatusInternalServerError {
+ t.Errorf("want 500, got %d", w.Code)
+ }
+}
diff --git a/internal/api/server.go b/internal/api/server.go
index a4b7ea1..ffa8cd4 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -61,6 +61,7 @@ type Server struct {
pushStore pushSubscriptionStore
dropsDir string
llm *llm.Client
+ budget budgetReporter // optional; per-provider spend headroom for GET /api/budget
}
// SetAPIToken configures a bearer token that must be supplied to access the API.
@@ -147,6 +148,7 @@ func (s *Server) routes() {
s.mux.HandleFunc("GET /api/tasks/{id}/events", s.handleListTaskEvents)
s.mux.HandleFunc("GET /api/executions", s.handleListRecentExecutions)
s.mux.HandleFunc("GET /api/stats", s.handleGetDashboardStats)
+ s.mux.HandleFunc("GET /api/budget", s.handleGetBudget)
s.mux.HandleFunc("GET /api/agents/status", s.handleGetAgentStatus)
s.mux.HandleFunc("GET /api/executions/{id}", s.handleGetExecution)
s.mux.HandleFunc("GET /api/executions/{id}/log", s.handleGetExecutionLog)
diff --git a/internal/cli/serve.go b/internal/cli/serve.go
index 55fdaf5..7afa678 100644
--- a/internal/cli/serve.go
+++ b/internal/cli/serve.go
@@ -171,6 +171,9 @@ func serve(addr string) error {
pool.RecoverStaleBlocked()
srv := api.NewServer(store, pool, agentRegistry, logger, cfg.ClaudeBinaryPath, cfg.GeminiBinaryPath)
+ if accountant != nil {
+ srv.SetBudget(accountant)
+ }
// Configure notifiers: combine webhook (if set) with web push.
notifiers := []notify.Notifier{}
diff --git a/web/app.js b/web/app.js
index 8a2662f..7f96e09 100644
--- a/web/app.js
+++ b/web/app.js
@@ -198,6 +198,53 @@ export async function fetchTaskEvents(taskId, fetchImpl = (typeof fetch !== 'und
}
}
+// ── Budget headroom ─────────────────────────────────────────────────────────
+// Renders the per-provider rolling-window spend headroom from GET /api/budget.
+
+// formatBudgetHeadroom turns one provider's headroom into a short label.
+// Returns '' for unlimited providers (nothing to show).
+export function formatBudgetHeadroom(h) {
+ if (!h || !h.limited) return '';
+ const pct = Math.round((h.fraction_remaining || 0) * 100);
+ const remaining = (h.remaining_usd || 0).toFixed(2);
+ const limit = (h.limit_usd || 0).toFixed(2);
+ const name = h.provider ? h.provider.charAt(0).toUpperCase() + h.provider.slice(1) : '?';
+ return `${name}: ${pct}% left ($${remaining} of $${limit})`;
+}
+
+// renderBudgetHeadroom builds a <span> chip per limited provider, flagging
+// providers under 20% remaining with a --low modifier. Returns the container.
+export function renderBudgetHeadroom(headrooms, doc = (typeof document !== 'undefined' ? document : null)) {
+ if (doc == null) return null;
+ const wrap = doc.createElement('div');
+ wrap.className = 'budget-bar';
+ for (const h of headrooms || []) {
+ if (!h || !h.limited) continue;
+ const chip = doc.createElement('span');
+ chip.className = 'budget-chip' + ((h.fraction_remaining || 0) < 0.2 ? ' budget-chip--low' : '');
+ chip.textContent = formatBudgetHeadroom(h);
+ wrap.appendChild(chip);
+ }
+ return wrap;
+}
+
+// loadBudget fetches headroom and injects chips into #budget-bar. Best-effort.
+async function loadBudget(fetchImpl = (typeof fetch !== 'undefined' ? fetch : null)) {
+ if (!fetchImpl || typeof document === 'undefined') return;
+ const slot = document.getElementById('budget-bar');
+ if (!slot) return;
+ try {
+ const resp = await fetchImpl(`${BASE_PATH}/api/budget`);
+ if (!resp.ok) return;
+ const headrooms = await resp.json();
+ const rendered = renderBudgetHeadroom(headrooms);
+ slot.innerHTML = '';
+ if (rendered) for (const c of rendered.children) slot.appendChild(c);
+ } catch {
+ // budget display is non-critical; ignore.
+ }
+}
+
function truncateToWordBoundary(text, maxLen = 120) {
if (!text || text.length <= maxLen) return text;
const cut = text.lastIndexOf(' ', maxLen);
@@ -1297,6 +1344,7 @@ function renderActiveTab(allTasks) {
async function poll() {
try {
+ loadBudget(); // fire-and-forget; budget can change independently of tasks
const health = await fetchHealth();
const serverUpdate = health.last_updated;
diff --git a/web/index.html b/web/index.html
index 8a705cc..5aa7b44 100644
--- a/web/index.html
+++ b/web/index.html
@@ -37,6 +37,7 @@
<button id="btn-notifications" class="btn-secondary" title="Enable push notifications">🔔</button>
<button id="btn-start-next" class="btn-secondary">Start Next</button>
</div>
+ <div id="budget-bar" class="budget-bar"></div>
</header>
<nav class="tab-bar">
<button class="tab active" data-tab="queue" title="Queue">⏳</button>
diff --git a/web/style.css b/web/style.css
index f4a9d91..b6f0484 100644
--- a/web/style.css
+++ b/web/style.css
@@ -2034,3 +2034,24 @@ dialog label select:focus {
font-size: 0.85rem;
padding: 6px 10px;
}
+
+/* Budget headroom chips */
+.budget-bar {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ align-items: center;
+}
+.budget-chip {
+ font-size: 0.72rem;
+ padding: 2px 8px;
+ border-radius: 999px;
+ background: var(--bg);
+ border: 1px solid var(--border);
+ color: var(--text-muted);
+ white-space: nowrap;
+}
+.budget-chip--low {
+ border-color: var(--state-budget-exceeded);
+ color: var(--state-budget-exceeded);
+}
diff --git a/web/test/budget.test.mjs b/web/test/budget.test.mjs
new file mode 100644
index 0000000..35d5256
--- /dev/null
+++ b/web/test/budget.test.mjs
@@ -0,0 +1,58 @@
+// budget.test.mjs — Unit tests for budget headroom display.
+//
+// Run with: node --test web/test/budget.test.mjs
+
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+import { formatBudgetHeadroom, renderBudgetHeadroom } from '../app.js';
+
+function makeDoc() {
+ return {
+ createElement(tag) {
+ return {
+ tag,
+ className: '',
+ textContent: '',
+ children: [],
+ appendChild(child) { this.children.push(child); return child; },
+ };
+ },
+ };
+}
+
+describe('formatBudgetHeadroom', () => {
+ it('formats a limited provider with percent and dollars', () => {
+ assert.equal(
+ formatBudgetHeadroom({ provider: 'claude', limited: true, limit_usd: 10, remaining_usd: 6, fraction_remaining: 0.6 }),
+ 'Claude: 60% left ($6.00 of $10.00)',
+ );
+ });
+
+ it('returns empty string for unlimited providers', () => {
+ assert.equal(formatBudgetHeadroom({ provider: 'local', limited: false }), '');
+ assert.equal(formatBudgetHeadroom(null), '');
+ });
+});
+
+describe('renderBudgetHeadroom', () => {
+ it('renders one chip per limited provider, skipping unlimited', () => {
+ const wrap = renderBudgetHeadroom([
+ { provider: 'claude', limited: true, limit_usd: 10, remaining_usd: 6, fraction_remaining: 0.6 },
+ { provider: 'local', limited: false },
+ ], makeDoc());
+ assert.equal(wrap.children.length, 1);
+ assert.equal(wrap.children[0].className, 'budget-chip');
+ assert.match(wrap.children[0].textContent, /Claude: 60% left/);
+ });
+
+ it('flags providers under 20% remaining with --low', () => {
+ const wrap = renderBudgetHeadroom([
+ { provider: 'gemini', limited: true, limit_usd: 5, remaining_usd: 0.5, fraction_remaining: 0.1 },
+ ], makeDoc());
+ assert.equal(wrap.children[0].className, 'budget-chip budget-chip--low');
+ });
+
+ it('returns null when doc is null', () => {
+ assert.equal(renderBudgetHeadroom([], null), null);
+ });
+});