-
Notifications
You must be signed in to change notification settings - Fork 2
plat 111
PLAT-111 — Cost Analysis blocks first paint on an unbounded all-history scan and per-run log fan-out
| Field | Value |
|---|---|
| Status |
implemented_pending_live_reverify — bounded ledger summary, cursor pagination, and removal of the initial log fan-out are implemented; restart and live UI timing remain |
| Priority | P1 |
| Owner | Cost Analysis query boundary, cost-ledger rollups, and CostsPopup lazy detail loading |
| Reported | 2026-08-16 |
| Evidence |
frontend/src/components/workflow/CostsPopup.tsx, frontend/src/services/api.ts, cmd/server/workflow.go, cmd/server/workflow_review_data.go, cmd/server/cost_storage.go, pkg/costledger/sqlite.go
|
| Related | PLAT-081), PLAT-090) |
Opening Cost Analysis can leave the whole pane on Loading cost data...
for a long time. The initial view should need only totals and a small recent
daily window, but its current request loads and expands the workflow's complete
retained cost and timing history before the loading state clears.
This is an unbounded scaling defect rather than a slow machine or a rendering problem. Every additional day, run, evaluation, and timing file makes the next open slower. The screenshot that triggered this ticket shows an account with 2.7 billion displayed tokens; this is precisely the history size at which an all-history implementation becomes unusable.
CostsPopup.loadAllCosts makes one unbounded
GET /api/workflow/costs?workspace_path=... request and then, before it clears
loading, loops over every returned run serially. For each run it calls
GET /api/workflow/logs only to obtain step titles for a breakdown that the
user has not opened yet.
Consequences:
- initial request count is
1 + historical run count; - the log calls are sequential, so their latencies add together;
- collapsed per-run detail is fetched eagerly;
- the loading effect depends on
runFolders, so unrelated run-list changes can repeat the full load even when the cost store did not change; -
/api/workflow/costsis not deduplicated, abortable, or revision-cached.
handleGetCosts accepts only workspace_path. It has no date range, page,
cursor, summary/detail mode, or response limit. loadWorkflowCosts then does
all of the following synchronously for every open or refresh:
-
SummarizeWorkflowselects every matchingcost_eventsrow and aggregates it in Go. The SQLite workflow/scope/date index can locate the rows, but the all-time query still reads and decodes the full workflow history. - It recursively reads every phase daily cost JSON.
- It recursively reads every execution daily cost JSON to build run totals.
- It recursively reads every evaluation daily cost JSON to build evaluation totals.
-
readWorkflowRunDailyCoststraverses and parses the execution and evaluation daily trees again to build the daily projection. -
loadWorkflowActivityTimingloads all Pulse metrics and walks every retained workflow and evaluation*-timing.jsonfile.
Thus one response combines an all-time ledger query, repeated cost-file tree walks, and an all-time timing-file walk. The browser then adds the per-run logs fan-out on top.
The problem is already material in normal workspaces:
| Workflow | Cost JSON files read by the unbounded path | Cost JSON bytes | Workflow timing JSON files |
|---|---|---|---|
| Upwork | 435 | 2,819,253 | 52 |
| Social Media | 311 | 2,861,619 | 120 |
| 280 | 1,632,731 | 55 | |
| 194 | 1,413,545 | 235 | |
| Build in Public | 153 | 485,860 | 20 |
| RTS Latency | 146 | 702,766 | 77 |
These counts exclude the additional evaluation timing walk and the frontend's one-log-request-per-run work. Latency and payload grow with retained history even when the user only wants today's summary.
Replace the monolithic response contract with a bounded query, for example:
- a compact summary containing authoritative all-time totals, recent daily totals (default 30 days), available-date bounds, and a next cursor; and
- an explicit detail query for one date or execution when the user expands it.
The UI should render the summary immediately and offer Load older days. The initial response size and work must not grow with total retained history.
All-time headline totals must come from a maintained aggregate or a SQL aggregate/rollup query, not by returning every historical event to Go and reconstructing the total on every open.
Do not call /api/workflow/logs while constructing the collapsed list. Fetch
one run's step detail only when that run is expanded, cache it by immutable
execution identity, and cancel or ignore it if the workflow changes.
If step labels are needed in the compact response, return one small batched
step_id → title map from the plan rather than fetching every run's complete
logs. Bounded parallelism is not a substitute for lazy loading; it would still
make initial work proportional to history.
Use the SQLite cost ledger as the authoritative read model for this UI and maintain/query rollups keyed by workflow, UTC date, scope, execution, and model. Cost JSON files can remain durable compatibility artifacts, but the initial UI must not recursively scan them.
If a transitional file reader remains, it must parse each scope once per revision and derive run-total and daily projections from the same in-memory result. It must not independently traverse the execution and evaluation trees twice.
Timing must follow the same boundary: recent aggregate timing in the summary, one execution's timing on expansion. Do not walk every retained run directory when the popup opens.
Return an ETag or monotonic ledger revision and cache the compact response per
workflow. Reopening an unchanged view should render cached data immediately
and revalidate cheaply. Invalidate on a cost/timing append, not because the
React runFolders array changed identity. Deduplicate concurrent requests and
use an abort/generation guard when switching workflows.
Build a realistic fixture with at least 365 daily shards, 10,000 ledger events, 1,000 executions, and retained timing files. The test must exercise the real HTTP handler and frontend loading contract, not only helper functions.
- Opening Cost Analysis makes no
/api/workflow/logsrequest before a run is expanded. - The initial API response contains only the bounded recent window and a cursor/date bound; its row count and payload do not grow when history grows from 30 to 365 to 1,000 days.
- Warm initial load is below 500 ms and cold initial load below 1 second on the fixture, with the threshold measured in CI on a controlled local server.
- All-time headline cost/tokens and recent daily totals exactly match an unbounded oracle over the same ledger.
- Expanding one run performs at most one detail request, returns that run's step/model breakdown, and re-expanding it uses the cache.
- Switching workflows aborts or ignores the old response; it cannot replace the new workflow's costs.
- Reopening an unchanged workflow paints cached summary data immediately and a revision/ETag revalidation returns without rebuilding history.
- Cost Analysis shows totals and recent days quickly regardless of how many historical days are retained.
- Older days and per-run details remain available on demand without changing accounting semantics.
- Initial loading performs bounded database work, no recursive all-history timing/cost-file walk, and no per-run log fan-out.
- All-time totals remain exact and reconcile with the canonical ledger.
- The P0 scale test prevents an apparently harmless history expansion from restoring the current linear slowdown.
The initial Cost Analysis path now requests
GET /api/workflow/costs?view=summary&days=30.
- SQLite calculates exact all-time headline totals with one grouped aggregate query instead of decoding every historical event into Go.
- Only the requested recent date window retains per-day and per-execution
detail. The response returns
history.next_beforewhen older events exist. - The frontend offers Load older days, merges that bounded page into the daily table, and ignores a response from a workflow that is no longer selected.
- The authoritative ledger path does not walk cost/timing artifact trees and
does not call
/api/workflow/logsfor historical runs. - Time is displayed from the ledger's canonical
llm_generation_duration_ms, labelled LLM time rather than presenting it as wall-clock agent time. - Workspaces without canonical scoped ledger data keep the previous artifact reader as a compatibility fallback; it is not used by Social Media.
- Social Media's old page remained loading beyond 27.8 seconds and hit the frontend's 15-second timeout.
- Its canonical ledger currently contains 2,570 events across five product scopes. The new all-time grouped SQL aggregate completes in approximately 0.01 seconds on the retained database.
- Cost-ledger overview tests verify exact all-time totals, bounded daily detail, cursor detection, scope/execution attribution, duration, and billing-basis totals.
- Server tests exercise both the summary loader and real HTTP handler contract.
-
go build ./..., the focused Go tests, TypeScript compilation, and the Cost popup unit tests pass.
The existing ETag/revision cache and large controlled P0 fixture remain useful hardening work, but they no longer block first paint: the live request is now bounded and avoids both recursive artifact walks and the N+1 log calls.
Auto-synced from docs/ on main. Edit there, not here.