From 287d5223180f2b3f65f7e284a3a7d8776902fbfd Mon Sep 17 00:00:00 2001 From: Federico De Ponte Date: Mon, 20 Jul 2026 11:45:49 +0200 Subject: [PATCH 1/4] feat(cost): expose workspace + per-worker spend visibility (#1201) Ships a first honest cut of cost visibility: a "who pays for this" test user asked twice why Floom might cost a lot and wanted visible spend against a cap, like Claude Code's statusline. - GET /workspace/spend: purpose-built read of workspace day/month spend-to-date against the configured daily/monthly caps. Reuses the existing services.run_cost aggregation (the same functions that already gate the spend-cap enforcement), never writes anything. - GET /workers/{id}/spend: a single worker's month-to-date spend + its configured monthly cap, 404s for a worker the caller can't see. - Fixes GET /workspace/settings: its existing current_day_spend_usd / current_month_spend_usd mirrors were computed without passing repos/user_id, so on any deployment with a non-sqlite Repositories backend they silently fell back to the engine's local (empty) sqlite file and always read $0.00 regardless of real spend. - Settings page: renders "$X of $Y spent this month" next to the existing Monthly spend cap field, sourced from the new endpoint. - Fixes a pre-existing date-dependent flaky test in test_797_workspace_defaults_enforcement.py (asserted day-spend against a hardcoded day-05 seed, so it failed after the 5th of any month). Deferred (see PR body): per-worker spend UI on the worker detail page, and the #1183 "Not reported" cost root-cause investigation. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/routers/worker_admin.py | 36 ++++++ apps/api/routers/workspace.py | 49 ++++++- .../tests/test_1201_worker_spend_endpoint.py | 120 ++++++++++++++++++ ...test_797_workspace_defaults_enforcement.py | 56 +++++++- apps/web/app/settings/page.tsx | 23 ++++ apps/web/lib/api.ts | 7 + apps/web/lib/types.ts | 15 +++ .../web/tests/model-defaults-797.dom.test.tsx | 11 +- .../model-defaults-spend-1201.dom.test.tsx | 72 +++++++++++ docs/API.md | 10 ++ 10 files changed, 392 insertions(+), 7 deletions(-) create mode 100644 apps/api/tests/test_1201_worker_spend_endpoint.py create mode 100644 apps/web/tests/model-defaults-spend-1201.dom.test.tsx diff --git a/apps/api/routers/worker_admin.py b/apps/api/routers/worker_admin.py index a3cac4ef0..b80fd6aa0 100644 --- a/apps/api/routers/worker_admin.py +++ b/apps/api/routers/worker_admin.py @@ -273,6 +273,42 @@ def list_worker_edit_requests( return [dict(r) for r in rows] +class WorkerSpendResponse(BaseModel): + """#1201: a single worker's month-to-date spend + its configured monthly + cap (if any), so an operator can see which worker is driving cost.""" + + worker_id: str + month_spend_usd: float + monthly_cap_usd: Optional[float] = None + + +@worker_admin_router.get("/workers/{worker_id}/spend", response_model=WorkerSpendResponse) +def get_worker_spend( + worker_id: str, + auth: AuthContext = Depends(get_auth_context), + repos: Repositories = Depends(get_repos), +) -> WorkerSpendResponse: + """#1201: worker month-to-date spend, read-only. Reuses the same + aggregation + cap resolution that already gates worker-level spend caps + (services.run_cost / run_service.get_worker_config_for_run) so the number + shown here always agrees with what actually blocks a run. 404s for a + worker the caller can't see (same visibility check as GET + /workers/{worker_id}).""" + worker_id = _canonical_worker_id(worker_id) + worker = _get_visible_worker(worker_id, user_id=auth.user_id, repos=repos) + if not worker: + raise HTTPException(status_code=404, detail="Worker not found") + + from run_service import get_worker_config_for_run + from services.run_cost import _spend_cap_for_config, _worker_month_to_date_cost_usd + + owner_id = str(worker.get("owner_id") or auth.user_id) + spend = _worker_month_to_date_cost_usd(worker_id, repos=repos, user_id=owner_id) + config = get_worker_config_for_run(worker_id, repos=repos, user_id=owner_id) + cap = _spend_cap_for_config(config) + return WorkerSpendResponse(worker_id=worker_id, month_spend_usd=spend, monthly_cap_usd=cap) + + @worker_admin_router.get("/workers/{worker_id}/bundle.zip") def download_worker_bundle( worker_id: str, diff --git a/apps/api/routers/workspace.py b/apps/api/routers/workspace.py index 3a8fdff31..d9c75dbcc 100644 --- a/apps/api/routers/workspace.py +++ b/apps/api/routers/workspace.py @@ -1365,6 +1365,7 @@ def delete_workspace_secret( def get_workspace_settings( request: Request, auth: AuthContext = Depends(get_auth_context), + repos: Repositories = Depends(get_repos), ) -> Dict[str, str]: """#794/#797: workspace behaviour toggles + model defaults (key→value map). @@ -1382,13 +1383,55 @@ def get_workspace_settings( try: from run_service import _workspace_day_to_date_cost_usd, _workspace_month_to_date_cost_usd - out["current_day_spend_usd"] = f"{_workspace_day_to_date_cost_usd():.4f}" - out["current_month_spend_usd"] = f"{_workspace_month_to_date_cost_usd():.4f}" + # #1201: pass repos + the caller's user_id so a hosted deployment + # sums cost via its own Repositories backend (e.g. Supabase). Without + # these, the aggregation silently fell back to the engine's local + # sqlite file, which is empty on a hosted deployment, so cloud always + # rendered $0.00 regardless of real spend. + out["current_day_spend_usd"] = f"{_workspace_day_to_date_cost_usd(repos=repos, user_id=auth.user_id):.4f}" + out["current_month_spend_usd"] = f"{_workspace_month_to_date_cost_usd(repos=repos, user_id=auth.user_id):.4f}" except Exception: - pass + logger.debug("workspace settings: current spend lookup failed", exc_info=True) return out +class WorkspaceSpendResponse(BaseModel): + """#1201: 'who pays for this, doesn't this cost a lot' — a purpose-built, + read-only readout of workspace spend-to-date against its configured caps. + Reuses the same aggregation the spend-cap enforcement already runs + (services.run_cost); never writes anything.""" + + day_spend_usd: float + month_spend_usd: float + daily_cap_usd: Optional[float] = None + monthly_cap_usd: Optional[float] = None + + +@workspace_router.get("/workspace/spend", response_model=WorkspaceSpendResponse) +def get_workspace_spend( + auth: AuthContext = Depends(get_auth_context), + repos: Repositories = Depends(get_repos), +) -> WorkspaceSpendResponse: + """#1201: workspace month/day spend-to-date + the configured caps, scoped + to the caller's active workspace via the request-bound Repositories (no + cross-workspace params to accept, so there is nothing to authorize beyond + a valid session). Any member can view; only admins can change the cap + (PUT /workspace/settings/{key}, #804).""" + from run_service import ( + _workspace_day_to_date_cost_usd, + _workspace_daily_spend_cap_usd, + _workspace_month_to_date_cost_usd, + _workspace_monthly_spend_cap_usd, + ) + + return WorkspaceSpendResponse( + day_spend_usd=_workspace_day_to_date_cost_usd(repos=repos, user_id=auth.user_id), + month_spend_usd=_workspace_month_to_date_cost_usd(repos=repos, user_id=auth.user_id), + daily_cap_usd=_workspace_daily_spend_cap_usd(), + monthly_cap_usd=_workspace_monthly_spend_cap_usd(), + ) + + @workspace_router.put("/workspace/settings/{key}", status_code=204, response_class=Response) def put_workspace_setting( key: str, diff --git a/apps/api/tests/test_1201_worker_spend_endpoint.py b/apps/api/tests/test_1201_worker_spend_endpoint.py new file mode 100644 index 000000000..4acad84a3 --- /dev/null +++ b/apps/api/tests/test_1201_worker_spend_endpoint.py @@ -0,0 +1,120 @@ +"""#1201 — GET /workers/{worker_id}/spend: a single worker's month-to-date +spend + its configured monthly cap, read-only. + +Run: cd apps/api && python -m pytest tests/test_1201_worker_spend_endpoint.py -q +""" +from __future__ import annotations + +import importlib +import sys +import textwrap +import types +from pathlib import Path + +import pytest + +API_DIR = Path(__file__).resolve().parents[1] +if str(API_DIR) not in sys.path: + sys.path.insert(0, str(API_DIR)) + +SECRET = "test-secret-1201" + + +def _yml(worker_id: str, *, monthly_cost_cap: float | None = None) -> str: + base = textwrap.dedent( + f""" + schema_version: "0.3" + id: "{worker_id}" + name: "{worker_id}" + title: t + description: d + version: "0.1.0" + exec: + entry: run.py + runtime: python311 + runner: e2b + command: python run.py + inputs: [] + outputs: [] + trigger: + type: manual + connections: [] + """ + ).strip() + "\n" + if monthly_cost_cap is not None: + base += f"limits:\n max_monthly_cost_usd: {monthly_cost_cap}\n" + return base + + +@pytest.fixture +def client_main(monkeypatch, tmp_path): + (tmp_path / "workers").mkdir() + monkeypatch.setenv("FLOOM_DB", str(tmp_path / "floom.db")) + monkeypatch.setenv("WORKEROS_DB", str(tmp_path / "floom.db")) + monkeypatch.setenv("FLOOM_WORKERS_DIR", str(tmp_path / "workers")) + monkeypatch.setenv("FLOOM_ARTIFACTS_DIR", str(tmp_path / "artifacts")) + monkeypatch.setenv("FLOOM_BLOBS_DIR", str(tmp_path / "blobs")) + monkeypatch.setenv("WORKEROS_API_ENV_FILE", str(tmp_path / "api.env")) + monkeypatch.setenv("WORKEROS_WORKSPACE_DIR", str(tmp_path)) + monkeypatch.setenv("FLOOM_SECRET", SECRET) + monkeypatch.setenv("WORKEROS_SHARED_SECRET_ROLE", "admin") + monkeypatch.setenv("WORKEROS_DEPLOY", "local") + for name in list(sys.modules): + if name in ("main", "models", "worker_registry", "run_service", "chat_service") or name.startswith(("routers", "services", "core", "db", "auth", "contexts", "runner_sandbox")): + sys.modules.pop(name, None) + sys.modules["scheduler"] = types.SimpleNamespace(start_scheduler=lambda: None, stop_scheduler=lambda: None) + main = importlib.import_module("main") + main.start_run = lambda *a, **k: None + import run_service + run_service.start_run = main.start_run + from fastapi.testclient import TestClient + + client = TestClient(main.app, headers={"x-floom-secret": SECRET}, raise_server_exceptions=False) + return client, main + + +def _seed_cost(worker_id, cost, created_at): + from db import get_db + + with get_db() as conn: + conn.execute( + "INSERT INTO runs (id, worker_id, status, trigger_source, runner, created_at, total_cost_usd) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + (f"r_{worker_id}_{int(cost*100)}", worker_id, "completed", "manual", "e2b", created_at, cost), + ) + + +class TestWorkerSpendEndpoint: + def test_returns_month_to_date_spend_and_cap(self, client_main): + from datetime import datetime, timezone + + client, _ = client_main + assert client.post( + "/workers", json={"worker_yml": _yml("spendworkeralpha", monthly_cost_cap=25.0), "run_py": "print(1)"} + ).status_code == 200 + this_month = datetime.now(timezone.utc).strftime("%Y-%m-05T00:00:00+00:00") + _seed_cost("spendworkeralpha", 7.5, this_month) + + resp = client.get("/workers/spendworkeralpha/spend") + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["worker_id"] == "spendworkeralpha" + assert body["month_spend_usd"] >= 7.5 + assert body["monthly_cap_usd"] == 25.0 + + def test_worker_with_no_cap_returns_null_cap(self, client_main): + client, _ = client_main + assert client.post( + "/workers", json={"worker_yml": _yml("spendworkerbeta"), "run_py": "print(1)"} + ).status_code == 200 + + resp = client.get("/workers/spendworkerbeta/spend") + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["month_spend_usd"] == 0.0 + assert body["monthly_cap_usd"] is None + + def test_unknown_worker_404s(self, client_main): + client, _ = client_main + resp = client.get("/workers/does-not-exist-at-all/spend") + assert resp.status_code == 404, resp.text diff --git a/apps/api/tests/test_797_workspace_defaults_enforcement.py b/apps/api/tests/test_797_workspace_defaults_enforcement.py index ef7fb87f0..84915a402 100644 --- a/apps/api/tests/test_797_workspace_defaults_enforcement.py +++ b/apps/api/tests/test_797_workspace_defaults_enforcement.py @@ -374,10 +374,62 @@ def test_settings_returns_current_month_spend(self, client_main): client, _ = client_main assert client.post("/workers", json={"worker_yml": _yml("capworkerdelta"), "run_py": "print(1)"}).status_code == 200 - this_month = datetime.now(timezone.utc).strftime("%Y-%m-05T00:00:00+00:00") - _seed_cost("capworkerdelta", 4.25, this_month) + # #1201: seed a run for "today" (not a hardcoded day-05), so the + # day-spend assertion below isn't date-dependent flaky past the 5th + # of any given month. + today = datetime.now(timezone.utc).strftime("%Y-%m-%dT01:00:00+00:00") + _seed_cost("capworkerdelta", 4.25, today) settings = client.get("/workspace/settings").json() assert "current_day_spend_usd" in settings assert "current_month_spend_usd" in settings assert float(settings["current_day_spend_usd"]) >= 4.25 assert float(settings["current_month_spend_usd"]) >= 4.25 + + def test_workspace_spend_endpoint_returns_current_spend_and_caps(self, client_main): + """#1201: GET /workspace/spend is the purpose-built readout next to + the spend-cap setting (settings-page stat + any other consumer).""" + from datetime import datetime, timezone + + client, _ = client_main + assert client.post("/workers", json={"worker_yml": _yml("spendendpointalpha"), "run_py": "print(1)"}).status_code == 200 + _set(client, "monthly_spend_cap_usd", "50.0") + _set(client, "daily_spend_cap_usd", "10.0") + today = datetime.now(timezone.utc).strftime("%Y-%m-%dT01:00:00+00:00") + _seed_cost("spendendpointalpha", 6.5, today) + + resp = client.get("/workspace/spend") + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["day_spend_usd"] >= 6.5 + assert body["month_spend_usd"] >= 6.5 + assert body["daily_cap_usd"] == 10.0 + assert body["monthly_cap_usd"] == 50.0 + + def test_workspace_spend_endpoint_uses_repo_backend_when_available(self, client_main): + """#1201: regression guard for the exact bug this PR fixes — the + workspace spend read must go through Repositories.runs.cost_total_usd + (workspace_scoped=True) when the deploy provides one, not silently + fall back to the engine's local sqlite (empty on a hosted deploy).""" + import run_service + + client, main = client_main + calls = [] + + class _Runs: + def cost_total_usd(self, **kwargs): + calls.append(kwargs) + return 3.25 + + class _FakeRepos: + runs = _Runs() + + main.app.dependency_overrides[main.get_repos] = lambda: _FakeRepos() + try: + resp = client.get("/workspace/spend") + finally: + main.app.dependency_overrides.pop(main.get_repos, None) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["day_spend_usd"] == 3.25 + assert body["month_spend_usd"] == 3.25 + assert any(call.get("workspace_scoped") is True for call in calls) diff --git a/apps/web/app/settings/page.tsx b/apps/web/app/settings/page.tsx index 3ddb892e9..1f07b0f61 100644 --- a/apps/web/app/settings/page.tsx +++ b/apps/web/app/settings/page.tsx @@ -1627,9 +1627,16 @@ export function WorkspaceInfoSettings({ canEdit = true }: { canEdit?: boolean }) export function ModelDefaults({ canEdit = true }: { canEdit?: boolean }) { const [values, setValues] = useState | null>(null); + // #1201: "who pays for this, doesn't this cost a lot" — spend-to-date next + // to the cap itself, so the number that matters sits beside the control + // that changes it. Fetched from the purpose-built GET /workspace/spend + // (not parsed out of getSettings) so it stays correct if that endpoint's + // shape evolves independently of the settings KV map. + const [spend, setSpend] = useState(null); useEffect(() => { api.workspace.getSettings().then(setValues).catch(() => setValues({})); + api.workspace.getSpend().then(setSpend).catch(() => setSpend(null)); }, []); const save = (key: string, value: string) => { @@ -1637,6 +1644,9 @@ export function ModelDefaults({ canEdit = true }: { canEdit?: boolean }) { api.workspace.setSetting(key, value).catch((err) => { toast.error((err as Error).message || "Could not save setting"); }); + if (key === "monthly_spend_cap_usd") { + api.workspace.getSpend().then(setSpend).catch(() => {}); + } }; if (values === null) return ; @@ -1673,6 +1683,19 @@ export function ModelDefaults({ canEdit = true }: { canEdit?: boolean }) { }} />

{f.hint}

+ {f.key === "monthly_spend_cap_usd" && spend && ( +

+ ${spend.month_spend_usd.toFixed(2)} + {spend.monthly_cap_usd != null ? ( + <> of ${spend.monthly_cap_usd.toFixed(2)} spent this month + ) : ( + <> spent this month (no cap set) + )} + {spend.daily_cap_usd != null && ( + <> · ${spend.day_spend_usd.toFixed(2)} of ${spend.daily_cap_usd.toFixed(2)} today + )} +

+ )} ) ))} diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts index 5d3ffa28b..6078c42f3 100644 --- a/apps/web/lib/api.ts +++ b/apps/web/lib/api.ts @@ -316,6 +316,10 @@ export const api = { duplicate: (id: string) => fetchJson(`/workers/${id}/duplicate`, { method: "POST" }), sampleInput: (id: string) => fetchJson>(`/workers/${id}/sample-input`), + // #1201: month-to-date spend for this one worker + its configured monthly + // cap (null when uncapped). Read-only. + getSpend: (id: string) => + fetchJson(`/workers/${encodeURIComponent(id)}/spend`), restore: (id: string) => fetchJson(`/workers/${id}/restore`, { method: "POST" }), archive: async (id: string) => { const worker = await fetchJson(`/workers/${id}/archive`, { method: "POST" }); @@ -1424,6 +1428,9 @@ export const api = { }), // #794/#797: workspace behaviour toggles + model defaults (admin-only PUT). getSettings: () => fetchJson>("/workspace/settings"), + // #1201: purpose-built spend-to-date + caps readout, next to the cap + // setting in Settings. Any member can view. + getSpend: () => fetchJson("/workspace/spend"), setSetting: async (key: string, value: string) => { const result = await fetchJson(`/workspace/settings/${encodeURIComponent(key)}`, { method: "PUT", diff --git a/apps/web/lib/types.ts b/apps/web/lib/types.ts index 34036b953..9e0ba8922 100644 --- a/apps/web/lib/types.ts +++ b/apps/web/lib/types.ts @@ -1072,6 +1072,21 @@ export interface LocalWorkspace { created_at: string; } +// #1201: GET /workspace/spend response. +export interface WorkspaceSpend { + day_spend_usd: number; + month_spend_usd: number; + daily_cap_usd: number | null; + monthly_cap_usd: number | null; +} + +// #1201: GET /workers/{id}/spend response. +export interface WorkerSpend { + worker_id: string; + month_spend_usd: number; + monthly_cap_usd: number | null; +} + export interface LocalWorkspaceListResponse { workspaces: LocalWorkspace[]; active_id: string; diff --git a/apps/web/tests/model-defaults-797.dom.test.tsx b/apps/web/tests/model-defaults-797.dom.test.tsx index f8f33e087..35a095e51 100644 --- a/apps/web/tests/model-defaults-797.dom.test.tsx +++ b/apps/web/tests/model-defaults-797.dom.test.tsx @@ -4,9 +4,10 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react"; // #797: ModelDefaults loads workspace settings, prefills fields, and persists // edits on blur via api.workspace.setSetting. -const { getSettings, setSetting } = vi.hoisted(() => ({ +const { getSettings, setSetting, getSpend } = vi.hoisted(() => ({ getSettings: vi.fn(), setSetting: vi.fn(), + getSpend: vi.fn(), })); vi.mock("next/navigation", () => ({ @@ -14,12 +15,18 @@ vi.mock("next/navigation", () => ({ useSearchParams: () => new URLSearchParams(), usePathname: () => "/settings", })); -vi.mock("@/lib/api", () => ({ API_BASE: "/api/proxy", api: { workspace: { getSettings, setSetting } } })); +vi.mock("@/lib/api", () => ({ + API_BASE: "/api/proxy", + api: { workspace: { getSettings, setSetting, getSpend } }, +})); beforeEach(() => { vi.clearAllMocks(); getSettings.mockResolvedValue({ default_model: "claude-opus-4-8" }); setSetting.mockResolvedValue(null); + // #1201: ModelDefaults also fetches workspace spend-to-date; unrelated to + // this test's cap-save assertions, so resolve it away. + getSpend.mockResolvedValue({ day_spend_usd: 0, month_spend_usd: 0, daily_cap_usd: null, monthly_cap_usd: null }); }); describe("ModelDefaults (#797)", () => { diff --git a/apps/web/tests/model-defaults-spend-1201.dom.test.tsx b/apps/web/tests/model-defaults-spend-1201.dom.test.tsx new file mode 100644 index 000000000..a481e9a36 --- /dev/null +++ b/apps/web/tests/model-defaults-spend-1201.dom.test.tsx @@ -0,0 +1,72 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; + +// #1201: ModelDefaults renders workspace spend-to-date next to the monthly +// spend cap setting, sourced from the purpose-built GET /workspace/spend +// (api.workspace.getSpend), not parsed out of getSettings. + +const { getSettings, setSetting, getSpend } = vi.hoisted(() => ({ + getSettings: vi.fn(), + setSetting: vi.fn(), + getSpend: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + useSearchParams: () => new URLSearchParams(), + usePathname: () => "/settings", +})); +vi.mock("@/lib/api", () => ({ + API_BASE: "/api/proxy", + api: { workspace: { getSettings, setSetting, getSpend } }, +})); + +beforeEach(() => { + vi.clearAllMocks(); + getSettings.mockResolvedValue({ monthly_spend_cap_usd: "50" }); + setSetting.mockResolvedValue(null); +}); + +describe("ModelDefaults spend readout (#1201)", () => { + it("renders spend-to-date against the configured caps", async () => { + getSpend.mockResolvedValue({ + day_spend_usd: 3.5, + month_spend_usd: 12.75, + daily_cap_usd: 5, + monthly_cap_usd: 50, + }); + const { ModelDefaults } = await import("@/app/settings/page"); + render(); + + const readout = await screen.findByTestId("workspace-spend-readout"); + expect(readout.textContent).toContain("$12.75"); + expect(readout.textContent).toContain("$50.00"); + expect(readout.textContent).toContain("$3.50"); + expect(readout.textContent).toContain("$5.00"); + expect(getSpend).toHaveBeenCalledTimes(1); + }); + + it("shows 'no cap set' when the workspace has not configured a monthly cap", async () => { + getSpend.mockResolvedValue({ + day_spend_usd: 0, + month_spend_usd: 0, + daily_cap_usd: null, + monthly_cap_usd: null, + }); + const { ModelDefaults } = await import("@/app/settings/page"); + render(); + + const readout = await screen.findByTestId("workspace-spend-readout"); + expect(readout.textContent).toContain("no cap set"); + }); + + it("does not render the readout while spend is still loading or failed", async () => { + getSpend.mockRejectedValue(new Error("boom")); + const { ModelDefaults } = await import("@/app/settings/page"); + render(); + + // Fields still render even if the spend fetch fails. + expect(await screen.findByText("Monthly spend cap (USD)")).toBeInTheDocument(); + expect(screen.queryByTestId("workspace-spend-readout")).not.toBeInTheDocument(); + }); +}); diff --git a/docs/API.md b/docs/API.md index 41e82ae11..83b64da62 100644 --- a/docs/API.md +++ b/docs/API.md @@ -30,6 +30,7 @@ the generated OpenAPI docs. | `/workers/reload` | POST | Reload workers from disk | | `/workers/{id}/runs` | POST | Trigger a run | | `/workers/import-from-share` | POST | Import a worker from a public share token | +| `/workers/{id}/spend` | GET | This worker's month-to-date spend + its configured monthly cap, if any | ### Runs and approvals @@ -97,6 +98,15 @@ cloud bundle flow preserve them automatically. The curated `/workspace/export` + written under `issues/` in the zip and restored into `.floom/issues/` on import (existing ids are never clobbered). +### Spend and limits + +| Endpoint | Method | Description | +|---|---|---| +| `/workspace/settings` | GET | Workspace behaviour + model-default settings, plus read-only `current_day_spend_usd`/`current_month_spend_usd` mirrors | +| `/workspace/settings/{key}` | PUT | Update a workspace setting, including `daily_spend_cap_usd`/`monthly_spend_cap_usd` (admin-only) | +| `/workspace/spend` | GET | Purpose-built workspace spend-to-date (day + month) against the configured caps | +| `/workers/{id}/spend` | GET | A single worker's month-to-date spend + its configured monthly cap | + ### Auth and system | Endpoint | Method | Description | From 84744c10a28edc580ce68e93db84421307677ca6 Mon Sep 17 00:00:00 2001 From: Federico De Ponte Date: Mon, 20 Jul 2026 12:11:41 +0200 Subject: [PATCH 2/4] feat(cost): surface per-worker spend on the worker detail Overview tab (#1201) Adds a "Spend (mo)" stat to the worker-detail Overview summary row (useWorkerSpendQuery, GET /workers/{id}/spend), next to Last run / Runs / Success / Schedule. Cache-first via the existing TanStack Query conventions in lib/query/hooks.ts; fails soft (the stat is simply omitted if the fetch errors, same pattern as every other stat on that row). This was called out as deferred in the original PR body; wiring it in turned out to be a small, low-risk addition once the endpoint existed, so shipping it now rather than leaving a stub follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/app/workers/WorkersCollection.tsx | 16 +++ apps/web/lib/query/hooks.ts | 15 +++ .../worker-detail-spend-1201.dom.test.tsx | 111 ++++++++++++++++++ 3 files changed, 142 insertions(+) create mode 100644 apps/web/tests/worker-detail-spend-1201.dom.test.tsx diff --git a/apps/web/app/workers/WorkersCollection.tsx b/apps/web/app/workers/WorkersCollection.tsx index 1681b2931..13e9fca3d 100644 --- a/apps/web/app/workers/WorkersCollection.tsx +++ b/apps/web/app/workers/WorkersCollection.tsx @@ -13,6 +13,7 @@ import { useWorkerDetailQuery, useWorkerRunsQuery, useWorkerVersionsQuery, + useWorkerSpendQuery, workerDetailQueryOptions, workerRunsQueryOptions, workerVersionsQueryOptions, @@ -423,6 +424,12 @@ function OverviewTab({ w }: { w: WorkerSummary }) { const stats = d?.recent_stats ?? w.recent_stats; const lastRun = d?.last_run ?? w.last_run; const scheduleState = scheduleStateLabel(w, d); + // #1201: month-to-date spend for this worker, next to the other at-a-glance + // stats. Fetched separately (not part of WorkerDetail) so it stays cheap + // and cache-first; omitted from the row entirely until it resolves rather + // than showing a placeholder "Loading" tile. + const spendQuery = useWorkerSpendQuery(w.id); + const spend = spendQuery.data; const summaryItems = [ { key: "last-run", @@ -442,6 +449,15 @@ function OverviewTab({ w }: { w: WorkerSummary }) { : "Not set", }, ...(scheduleState ? [{ key: "schedule", label: "Schedule", value: scheduleState }] : []), + ...(spend + ? [{ + key: "spend", + label: "Spend (mo)", + value: spend.monthly_cap_usd != null + ? `$${spend.month_spend_usd.toFixed(2)} / $${spend.monthly_cap_usd.toFixed(2)}` + : `$${spend.month_spend_usd.toFixed(2)}`, + }] + : []), ]; return (
diff --git a/apps/web/lib/query/hooks.ts b/apps/web/lib/query/hooks.ts index caf0d9b02..c79834b7f 100644 --- a/apps/web/lib/query/hooks.ts +++ b/apps/web/lib/query/hooks.ts @@ -10,6 +10,7 @@ import type { SystemOverview, WorkerSummary, WorkerDetail, + WorkerSpend, ConnectionItem, SecretItem, RunSummary, @@ -66,6 +67,8 @@ export const qk = { ["worker-detail", id, workspaceId || workspaceScope()] as const, workerRuns: (workerId: string, limit = 20) => ["worker-runs", workerId, limit] as const, workerVersions: (workerId: string) => ["worker-versions", workerId] as const, + // #1201: this worker's month-to-date spend + configured cap. + workerSpend: (workerId: string) => ["worker-spend", workerId] as const, }; // Each hook is cache-first (see QueryProvider defaults: staleTime 30s, @@ -221,6 +224,18 @@ export function useWorkerDetailQuery(id: string, workspaceId?: string | null) { }); } +// #1201: month-to-date spend + configured cap for one worker. Cache-first, +// same defaults as the rest of the detail pane (see QueryProvider) — failing +// soft (react-query surfaces isError, no thrown render) is fine here since +// this is a supplementary stat, not blocking detail render. +export function useWorkerSpendQuery(id: string) { + return useQuery({ + queryKey: qk.workerSpend(id), + queryFn: () => api.workers.getSpend(id), + enabled: Boolean(id), + }); +} + export function workerRunsQueryOptions(workerId: string, limit = 20) { return { queryKey: qk.workerRuns(workerId, limit), diff --git a/apps/web/tests/worker-detail-spend-1201.dom.test.tsx b/apps/web/tests/worker-detail-spend-1201.dom.test.tsx new file mode 100644 index 000000000..d7f06c1dd --- /dev/null +++ b/apps/web/tests/worker-detail-spend-1201.dom.test.tsx @@ -0,0 +1,111 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { QueryProvider } from "@/components/providers/QueryProvider"; + +// #1201: the worker-detail Overview tab renders a "Spend (mo)" stat sourced +// from GET /workers/{id}/spend (api.workers.getSpend), alongside the existing +// Last run / Runs / Success stats. + +vi.mock("next/navigation", () => ({ + useSearchParams: () => new URLSearchParams(), + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/", +})); + +const WORKER_ID = "spend-stat-worker"; +const worker = { + id: WORKER_ID, + name: "Spend Stat Worker", + description: "d", + tags: [], + status: "healthy", + trigger_type: "manual", + runner: "e2b", + triggers: [], + triggers_spec: [], + connections: [], + inputs: [], + enabled: true, + stage: "live", + visibility: "private", + permissions: { can_edit: true, can_run: true, can_delete: true, can_share: true }, + recent_stats: { last_run_at: "2026-06-16T00:00:00Z", runs_7d: 4 }, +}; +const workerDetail = { + ...worker, + config: { + id: WORKER_ID, + name: worker.name, + trigger: { type: "manual" }, + runtime: { type: "python311", entrypoint: "run.py", runner: "e2b", mode: "pure-script" }, + inputs: [], + outputs: [], + contexts: [], + connections: [], + secrets: [], + }, + files: [{ path: "worker.yml", content: "name: Spend Stat Worker\n" }], + recent_runs: [], +}; + +const { getSpend } = vi.hoisted(() => ({ getSpend: vi.fn() })); + +vi.mock("@/lib/api", () => ({ + getPersistedActiveWorkspaceId: vi.fn(() => "local-default"), + api: { + workers: { + list: vi.fn().mockResolvedValue([worker]), + get: vi.fn().mockResolvedValue(workerDetail), + listVersions: vi.fn().mockResolvedValue([]), + getSpend, + feedback: { list: vi.fn().mockResolvedValue([]), create: vi.fn(), delete: vi.fn() }, + }, + contexts: { list: vi.fn().mockResolvedValue([]) }, + }, +})); + +vi.mock("@/lib/useApprovalsSync", () => ({ + notifyApprovalsChanged: vi.fn(), + useApprovalsListSync: vi.fn(), +})); + +beforeEach(() => { + vi.clearAllMocks(); + window.localStorage.clear(); +}); + +async function openDetail() { + const { default: WorkersCollection } = await import("@/app/workers/WorkersCollection"); + render( + + + , + ); + fireEvent.click(await screen.findByRole("button", { name: /Spend Stat Worker/i })); + await waitFor(() => expect(document.querySelector(".c-dtabs")).toBeTruthy()); +} + +describe("worker-detail spend stat (#1201)", () => { + it("renders spend against the worker's configured cap", async () => { + getSpend.mockResolvedValue({ worker_id: WORKER_ID, month_spend_usd: 4.2, monthly_cap_usd: 25 }); + await openDetail(); + expect(await screen.findByText("Spend (mo)")).toBeInTheDocument(); + expect(await screen.findByText("$4.20 / $25.00")).toBeInTheDocument(); + expect(getSpend).toHaveBeenCalledWith(WORKER_ID); + }); + + it("renders spend alone when the worker has no configured cap", async () => { + getSpend.mockResolvedValue({ worker_id: WORKER_ID, month_spend_usd: 0, monthly_cap_usd: null }); + await openDetail(); + expect(await screen.findByText("Spend (mo)")).toBeInTheDocument(); + expect(await screen.findByText("$0.00")).toBeInTheDocument(); + }); + + it("omits the spend stat entirely if the fetch fails, without crashing", async () => { + getSpend.mockRejectedValue(new Error("boom")); + await openDetail(); + // Other stats still render. + expect(await screen.findByText("Last run")).toBeInTheDocument(); + expect(screen.queryByText("Spend (mo)")).not.toBeInTheDocument(); + }); +}); From a4c19784056943881c2f9c1a93c9241c434848d9 Mon Sep 17 00:00:00 2001 From: Federico De Ponte Date: Mon, 20 Jul 2026 12:28:50 +0200 Subject: [PATCH 3/4] style: remove em dashes from new code comments (#1201) --- apps/api/routers/workspace.py | 2 +- apps/api/tests/test_1201_worker_spend_endpoint.py | 2 +- apps/api/tests/test_797_workspace_defaults_enforcement.py | 2 +- apps/web/app/settings/page.tsx | 2 +- apps/web/lib/query/hooks.ts | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/api/routers/workspace.py b/apps/api/routers/workspace.py index d9c75dbcc..807277db6 100644 --- a/apps/api/routers/workspace.py +++ b/apps/api/routers/workspace.py @@ -1396,7 +1396,7 @@ def get_workspace_settings( class WorkspaceSpendResponse(BaseModel): - """#1201: 'who pays for this, doesn't this cost a lot' — a purpose-built, + """#1201: 'who pays for this, doesn't this cost a lot', a purpose-built, read-only readout of workspace spend-to-date against its configured caps. Reuses the same aggregation the spend-cap enforcement already runs (services.run_cost); never writes anything.""" diff --git a/apps/api/tests/test_1201_worker_spend_endpoint.py b/apps/api/tests/test_1201_worker_spend_endpoint.py index 4acad84a3..b57fddb0d 100644 --- a/apps/api/tests/test_1201_worker_spend_endpoint.py +++ b/apps/api/tests/test_1201_worker_spend_endpoint.py @@ -1,4 +1,4 @@ -"""#1201 — GET /workers/{worker_id}/spend: a single worker's month-to-date +"""#1201: GET /workers/{worker_id}/spend, a single worker's month-to-date spend + its configured monthly cap, read-only. Run: cd apps/api && python -m pytest tests/test_1201_worker_spend_endpoint.py -q diff --git a/apps/api/tests/test_797_workspace_defaults_enforcement.py b/apps/api/tests/test_797_workspace_defaults_enforcement.py index 84915a402..bff902e15 100644 --- a/apps/api/tests/test_797_workspace_defaults_enforcement.py +++ b/apps/api/tests/test_797_workspace_defaults_enforcement.py @@ -406,7 +406,7 @@ def test_workspace_spend_endpoint_returns_current_spend_and_caps(self, client_ma assert body["monthly_cap_usd"] == 50.0 def test_workspace_spend_endpoint_uses_repo_backend_when_available(self, client_main): - """#1201: regression guard for the exact bug this PR fixes — the + """#1201: regression guard for the exact bug this PR fixes: the workspace spend read must go through Repositories.runs.cost_total_usd (workspace_scoped=True) when the deploy provides one, not silently fall back to the engine's local sqlite (empty on a hosted deploy).""" diff --git a/apps/web/app/settings/page.tsx b/apps/web/app/settings/page.tsx index 1f07b0f61..d8d4f499d 100644 --- a/apps/web/app/settings/page.tsx +++ b/apps/web/app/settings/page.tsx @@ -1627,7 +1627,7 @@ export function WorkspaceInfoSettings({ canEdit = true }: { canEdit?: boolean }) export function ModelDefaults({ canEdit = true }: { canEdit?: boolean }) { const [values, setValues] = useState | null>(null); - // #1201: "who pays for this, doesn't this cost a lot" — spend-to-date next + // #1201: "who pays for this, doesn't this cost a lot", spend-to-date next // to the cap itself, so the number that matters sits beside the control // that changes it. Fetched from the purpose-built GET /workspace/spend // (not parsed out of getSettings) so it stays correct if that endpoint's diff --git a/apps/web/lib/query/hooks.ts b/apps/web/lib/query/hooks.ts index c79834b7f..fc90ec81a 100644 --- a/apps/web/lib/query/hooks.ts +++ b/apps/web/lib/query/hooks.ts @@ -225,7 +225,7 @@ export function useWorkerDetailQuery(id: string, workspaceId?: string | null) { } // #1201: month-to-date spend + configured cap for one worker. Cache-first, -// same defaults as the rest of the detail pane (see QueryProvider) — failing +// same defaults as the rest of the detail pane (see QueryProvider); failing // soft (react-query surfaces isError, no thrown render) is fine here since // this is a supplementary stat, not blocking detail render. export function useWorkerSpendQuery(id: string) { From 93f265696e995cb11d914b5217b00f5c51e8b335 Mon Sep 17 00:00:00 2001 From: Federico De Ponte Date: Mon, 20 Jul 2026 18:51:13 +0200 Subject: [PATCH 4/4] fix(cost): read caps from active workspace --- apps/api/routers/workspace.py | 6 +++-- apps/api/services/run_cost.py | 9 +++---- ...test_797_workspace_defaults_enforcement.py | 27 +++++++++++++++++++ 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/apps/api/routers/workspace.py b/apps/api/routers/workspace.py index 807277db6..ed364527d 100644 --- a/apps/api/routers/workspace.py +++ b/apps/api/routers/workspace.py @@ -1409,6 +1409,7 @@ class WorkspaceSpendResponse(BaseModel): @workspace_router.get("/workspace/spend", response_model=WorkspaceSpendResponse) def get_workspace_spend( + request: Request, auth: AuthContext = Depends(get_auth_context), repos: Repositories = Depends(get_repos), ) -> WorkspaceSpendResponse: @@ -1423,12 +1424,13 @@ def get_workspace_spend( _workspace_month_to_date_cost_usd, _workspace_monthly_spend_cap_usd, ) + workspace_id = _active_workspace_id(request) return WorkspaceSpendResponse( day_spend_usd=_workspace_day_to_date_cost_usd(repos=repos, user_id=auth.user_id), month_spend_usd=_workspace_month_to_date_cost_usd(repos=repos, user_id=auth.user_id), - daily_cap_usd=_workspace_daily_spend_cap_usd(), - monthly_cap_usd=_workspace_monthly_spend_cap_usd(), + daily_cap_usd=_workspace_daily_spend_cap_usd(workspace_id=workspace_id), + monthly_cap_usd=_workspace_monthly_spend_cap_usd(workspace_id=workspace_id), ) diff --git a/apps/api/services/run_cost.py b/apps/api/services/run_cost.py index 7511c9365..4d5e743ea 100644 --- a/apps/api/services/run_cost.py +++ b/apps/api/services/run_cost.py @@ -149,10 +149,10 @@ def _spend_cap_for_config(config: Any) -> Optional[float]: return float(cap) if cap is not None else None -def _workspace_monthly_spend_cap_usd() -> Optional[float]: +def _workspace_monthly_spend_cap_usd(*, workspace_id: str = "local-default") -> Optional[float]: """#797: the workspace-level monthly spend cap from settings, then env default.""" from run_service import _workspace_setting - raw = (_workspace_setting("monthly_spend_cap_usd") or "").strip() + raw = (_workspace_setting("monthly_spend_cap_usd", workspace_id=workspace_id) or "").strip() if not raw: return _default_spend_cap_usd("WORKEROS_DEFAULT_MONTHLY_SPEND_CAP_USD", "25") try: @@ -162,10 +162,10 @@ def _workspace_monthly_spend_cap_usd() -> Optional[float]: return _default_spend_cap_usd("WORKEROS_DEFAULT_MONTHLY_SPEND_CAP_USD", "25") -def _workspace_daily_spend_cap_usd() -> Optional[float]: +def _workspace_daily_spend_cap_usd(*, workspace_id: str = "local-default") -> Optional[float]: """Workspace-level daily spend cap from settings, then env default.""" from run_service import _workspace_setting - raw = (_workspace_setting("daily_spend_cap_usd") or "").strip() + raw = (_workspace_setting("daily_spend_cap_usd", workspace_id=workspace_id) or "").strip() if not raw: return _default_spend_cap_usd("WORKEROS_DEFAULT_DAILY_SPEND_CAP_USD", "5") try: @@ -315,4 +315,3 @@ def _user_day_to_date_cost_usd( except Exception: logger.debug("user day-to-date cost lookup failed for %s", user_id, exc_info=True) return 0.0 - diff --git a/apps/api/tests/test_797_workspace_defaults_enforcement.py b/apps/api/tests/test_797_workspace_defaults_enforcement.py index bff902e15..224c32a9c 100644 --- a/apps/api/tests/test_797_workspace_defaults_enforcement.py +++ b/apps/api/tests/test_797_workspace_defaults_enforcement.py @@ -405,6 +405,33 @@ def test_workspace_spend_endpoint_returns_current_spend_and_caps(self, client_ma assert body["daily_cap_usd"] == 10.0 assert body["monthly_cap_usd"] == 50.0 + def test_workspace_spend_endpoint_returns_active_workspace_caps(self, client_main): + """The cap read uses the request's active workspace, not local-default.""" + client, _ = client_main + _set(client, "daily_spend_cap_usd", "10.0") + _set(client, "monthly_spend_cap_usd", "50.0") + + created = client.post("/workspaces", json={"name": "Spend visibility"}) + assert created.status_code == 200, created.text + workspace_id = created.json()["id"] + headers = {"x-floom-workspace": workspace_id} + assert client.put( + "/workspace/settings/daily_spend_cap_usd", + json={"value": "21.0"}, + headers=headers, + ).status_code in (200, 204) + assert client.put( + "/workspace/settings/monthly_spend_cap_usd", + json={"value": "84.0"}, + headers=headers, + ).status_code in (200, 204) + + resp = client.get("/workspace/spend", headers=headers) + + assert resp.status_code == 200, resp.text + assert resp.json()["daily_cap_usd"] == 21.0 + assert resp.json()["monthly_cap_usd"] == 84.0 + def test_workspace_spend_endpoint_uses_repo_backend_when_available(self, client_main): """#1201: regression guard for the exact bug this PR fixes: the workspace spend read must go through Repositories.runs.cost_total_usd