feat(executions): GET /api/executions/timeline — bucketed fleet rollups (ent#326) - #1983
Conversation
|
Resolve by running |
…ps (ent#326) The backend half of ent#94's grid-widget foundation. Three tile sub-issues (#96 executions-by-trigger, #98 fleet cost, #101 fleet context) were blocked on an endpoint the epic listed but never filed; they now share one query instead of each growing their own. Time-series sibling of `/api/executions/stats`: same table, same access model, buckets instead of scalars. Read-only — no schema change, no migration, no MCP surface (Invariant #13's three-surface cost buys nothing for a dashboard read). **An analytics axis cannot degrade the way a filter can.** `/stats` and the list route coerce an unknown `hours` to 24. That is right for a filter — the worst case is more rows than you asked for — and wrong for a chart, where it silently redraws a window the caller never requested and gives them no way to tell. Both `group_by` and `hours` therefore 422 by name. `hours=0` is refused for `hour`/`day` specifically: an all-time axis emits one bucket per interval since the fleet's first execution, which is an unbounded response nobody asked for. It stays allowed for `trigger`/`agent`, which have no continuum and are bounded by the number of distinct values. Trigger folding happens in Python through `_TRIGGER_BUCKETS`, not a SQL CASE, so a newly-added trigger type lands in the explicit `Other` catch-all instead of vanishing from a chart the first time someone forgets the SQL. Buckets slice the stored ISO-Z `started_at` with `substr` rather than a date function — dialect-agnostic across SQLite and PostgreSQL, and the same UTC the row was written with (Invariant #16). **The token question ent#326 requires settling: option 1.** `schedule_executions` has no usage-token column — `output_tokens` lives only on `chat_messages`, which covers chat turns rather than fleet executions. So the endpoint reports context-window OCCUPANCY under the name `context_used`, and ent#94's #101 tile must be labelled to match. Presenting this as "tokens consumed" is exactly the liveness-vs-quality mislabel the issue warns against. A guard fails if such a column is ever added, so the schema-change option gets revisited deliberately rather than silently. That guard matches by explicit NAME, not substring: `claim_token` (the #1081 pull-lease CAS value) contains "token" and answers a completely different question — my first version of the check tripped on it. tests/unit/test_ent326_executions_timeline.py — 30 checks, including the dangerous access direction (an empty allow-list returns an empty series, never everything) and the DB layer driven against a real SQLite table. Related to Abilityai/trinity-enterprise#326 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
011bebf to
31787d6
Compare
|
Rebased onto current Conflicts were in Local verification after the rebase is in the individual runs; no source conflicts, only the registry. Ready for review. |
…ine (ent#326)
CI's regression diff caught this after the rebase: `test_database_facade_delegation`
landed on dev after this PR was written, and it fails here because
`routers/executions.py` calls `db.get_fleet_execution_timeline(...)` while
`DatabaseManager` has no such method. There is no `__getattr__` on the facade,
so that call raises AttributeError — the endpoint would have 500'd on every
request.
The PR's own 524-line suite passed throughout, because every mock of that seam
was written as:
monkeypatch.setattr(router_mod.db, "get_fleet_execution_timeline",
lambda *a, **k: [], raising=False)
`raising=False` opts out of monkeypatch's existence check, so the tests stubbed
a method that did not exist and never touched the real facade. Green tests over
a guaranteed 500.
Two changes: the pass-through on `DatabaseManager` (delegating to
`_schedule_ops`, the shape its `get_fleet_execution_stats` sibling already
uses), and all nine `raising=False` opt-outs removed so the mocks now assert
the seam exists.
Mutation-checked: with the facade method removed again, 9 of these tests fail —
before this commit they all passed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
The rebase earned its keep —
What makes it worth writing up is why the PR's own 524-line suite stayed green over it. Every mock of that seam was: monkeypatch.setattr(router_mod.db, "get_fleet_execution_timeline",
lambda *a, **k: [], raising=False)
Fixed both halves:
Mutation-checked rather than assumed: with the facade method removed again, 9 of these tests fail. Before this commit, all 33 passed. 111 passed locally across |
vybe
left a comment
There was a problem hiding this comment.
Small, read-only, well-tested, and the parts that usually go wrong are right: Invariant #16 is satisfied (iso_cutoff(hours) as a bound parameter, no datetime('now', ...)), and access control uses narrow_to_agent(accessible_agent_names(...)) identically to /stats and the list route, with agent_names == [] short-circuiting before any SQL. The test that pins the dangerous direction is the right test to have written.
Three things to settle before it lands — two mechanical, one that needs a decision.
1. Trigger-bucket ordering diverges from the established chart convention.
_fold_trigger_buckets ends with sorted(..., key=lambda b: b["bucket"]) — alphabetical. _BUCKET_ORDER in db/schedules/analytics.py exists precisely so "Other" sorts last and the legend/stack order is stable. Alphabetical puts Other sixth, between MCP and Public, so the #1107 Overview chart and this endpoint's tile will render the same buckets in different orders on the same page. _BUCKET_ORDER isn't currently exported from db/schedules/__init__.py — export it and use it.
2. Invariant #1: both Python-side transforms are re-implemented in the router.
db/schedules/analytics.py already owns _bucket_for_trigger() and a gap-filled continuous UTC-day axis. This PR reimplements both in routers/executions.py, inlining _TRIGGER_BUCKETS.get(..., "Other") with a literal "Other" rather than the _OTHER_BUCKET constant. That's domain mapping in a router plus a second copy of a mapping the codebase deliberately centralized. Both belong in ScheduleStatsMixin beside the query, matching the #1107 precedent.
3. Entitlement ruling needed. This lands ungated on the OSS routers/executions.py, visible to every authenticated user, but ent#326 is an enterprise-tracker issue — so per CLAUDE.md the default is gated unless explicitly ruled OSS-core. There's a plausible precedent (Dashboard Grid, ent#47, shipped OSS-core and is in the public architecture doc), but the PR never states the decision. Worth one line from @vybe either way so it isn't inferred later from the fact that it merged.
Worth knowing, not blocking:
- Legacy naive
started_atrows silently vanish from thehour/dayseries. Pre-#1474 scheduler rows are stored asYYYY-MM-DD HH:MM:SS(space, noZ). They pass the lexicographic cutoff, butsubstr(started_at,1,13)yields2026-08-06 10, which never matches the gap-fill axis key2026-08-06T10— so the bucket renders as a real zero whilegroup_by=trigger|agentand/statsstill count them. That is the exact "chart contradicts the stat card above it" failure the PR cites as its own rationale for countingerroras failed, arriving by a different route. Time-bounded (720h window vs 90-day retention) so it self-heals ~30 days after any deploy past #1474 — but it should be stated rather than discovered. - The Invariant #4 framing is decorative: there is no
GET /api/executions/{execution_id}route anywhere in the backend, and@router.get("")matches only the exact path. Ordering is right; the claimed collision risk doesn't currently exist. - Dead branch:
hours > _MAX_GAP_FILLED_HOURSis unreachable, since_VALID_HOURSmaxes at 720 and the membership check runs first. - Invariant #13: no MCP tool. Defensible for a UI-tile endpoint, but worth stating —
list_recent_executionsalready exists and an agent asking "what did the fleet cost this week" is a plausible consumer.
Tests are strong for the size — 422 naming, the hours=0 asymmetry, gap-fill continuity/ordering/dedup, the Other catch-all, empty-allow-list, a real-SQLite db-layer test, and a token-column guard matched by explicit name rather than substring. No coverage gap I'd hold on.
Also needs a rebase (tests/registry.json only).
Mechanical conflict resolution only: - tests/registry.json: rebuilt from index stages (dev entries deduped, PR entry kept) - docs/memory/*.md: union merge of two append-only additions No code changes.
|
Re-validated today as part of a merge sweep. Holding — head unchanged since my review; the only new commit is a Confirmed still present: Items 2 (Invariant #1 — both Python-side transforms re-implemented in the router, with a literal On item 3, here is the ruling so it isn't inferred later from the fact that it merged: ent#326 is enterprise-tracker, and per CLAUDE.md the default is entitlement-gated unless explicitly ruled otherwise. A fleet-wide execution-cost/throughput rollup is operational telemetry over data every authenticated user can already reach via The read-only scoping, Invariant #16 compliance, and the access-control reuse are all correct; it's the two mechanical items that block. |
…red bucket order (ent#326) Three review items. 1. Bucket ordering was alphabetical (`sorted(..., key=bucket)`), which puts `Other` sixth — between `MCP` and `Public`. `_BUCKET_ORDER` exists so `Other` sorts LAST and the legend/stack order is stable, and without it the #1107 Overview chart and this endpoint's tile render the same buckets in different orders on the same page. It is now exported from `db/schedules/__init__.py` (with `_OTHER_BUCKET` and `_bucket_for_trigger`) and used here; a label present in the map but missing from the order sorts after the known ones rather than being dropped. 2. Invariant #1: both Python-side transforms were re-implemented in the router, including `_TRIGGER_BUCKETS.get(..., "Other")` with a LITERAL fallback instead of `_OTHER_BUCKET`. Moved to `ScheduleStatsMixin` beside the query (the #1107 precedent), reached through the `DatabaseManager` facade as `db.shape_execution_timeline(...)` so the router imports no private db symbol. The router no longer mentions `_TRIGGER_BUCKETS` at all, and a test pins that. 3. The legacy-timestamp note is fixed rather than documented. Pre-#1474 scheduler rows are `YYYY-MM-DD HH:MM:SS`: they pass the cutoff and ARE counted by /stats and by group_by=trigger|agent, but `substr(started_at,1,13)` yields `2026-08-06 10`, which never matches the axis key `2026-08-06T10` — so the hour/day chart showed a real zero for executions the stat card above it counted. That is the exact 'chart contradicts the card' failure this endpoint counts `error` as failed to avoid, arriving by another route. Bucketing now normalises the separator with `replace(started_at, ' ', 'T')` (ANSI, present on both backends) instead of waiting ~30 days for retention to age the rows out. 4 tests added (Other-last, order-is-the-shared-constant, router-has-no-copy, legacy-timestamp bucketing). 34 pass. Still needs the entitlement ruling — asked on the PR. Related to trinity-enterprise#326 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts: # tests/registry.json
|
@vybe — items 1 and 2 fixed, plus the legacy-timestamp note fixed rather than documented. Item 3 needs your ruling; I've deliberately not decided it. 1. Bucket ordering — 2. Invariant #1 — both transforms moved to Legacy naive 3. Entitlement ruling — over to you. I've left it ungated (unchanged from the PR as filed) rather than guessing, because per CLAUDE.md the default for an enterprise-tracker feature is gated unless explicitly ruled OSS-core, and that's a monetization call, not mine. Both readings are defensible here: Dashboard Grid (ent#47) shipped OSS-core and is in the public architecture doc, and this endpoint is generic fleet telemetry over OSS tables — but "can build in OSS" ≠ "should". Say the word and I'll either add Not changed, and I think correctly: the dead 4 tests added; 34 pass. Merged latest |
# Conflicts: # tests/registry.json
The gating question was deferred to the maintainer rather than guessed, per CLAUDE.md: an enterprise-tracker feature is entitlement-gated by default unless explicitly ruled OSS-core, because monetization is not the implementer's call. Ruling: OSS-core, ungated. Recorded in architecture.md beside the endpoint so it reads as a decision rather than something inferred later from the fact that the PR merged ungated — which is exactly how an unstated default becomes an accidental precedent. Rationale: generic fleet telemetry over OSS tables, and its consumer (the Dashboard Grid, ent#47) already shipped OSS-core. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
vybe
left a comment
There was a problem hiding this comment.
Re-reviewed, and item 3 ruled: OSS-core, ungated.
You were right not to guess it — CLAUDE.md's default for an enterprise-tracker feature is gated unless explicitly ruled otherwise, and that's a monetization call. The ruling: leave it ungated. Generic fleet telemetry over OSS tables, and its consumer (Dashboard Grid, ent#47) already shipped OSS-core. I've recorded it in architecture.md beside the endpoint (edb277a) rather than leaving it implicit — an unstated default is exactly how "it merged ungated" becomes an accidental precedent nobody remembers deciding.
1. Bucket ordering — sharing _BUCKET_ORDER/_bucket_for_trigger from db/schedules/__init__.py is better than the fix I asked for: a label present in the map but missing from the order now sorts after the known ones instead of being dropped.
2. Invariant #1 — both transforms behind db.shape_execution_timeline(...), router imports no private db symbol, and the test pinning that routers/executions.py never mentions _TRIGGER_BUCKETS again is what stops it regressing.
Legacy naive started_at — thank you for fixing rather than documenting this. replace(started_at, ' ', 'T') is ANSI and works on both backends, and waiting ~30 days for retention to age the rows out would have meant every install deploying before then sees a chart contradicting the stat card above it.
Accepted as scoped: the dead hours > _MAX_GAP_FILLED_HOURS branch (cheap belt), the Invariant #4 framing, and no MCP tool — agreed that an agent-facing surface for a UI-tile endpoint is scope creep; file it if a caller appears.
34 tests pass against the merged tree.
…-timeline # Conflicts: # tests/registry.json
Implements
Abilityai/trinity-enterprise#326— the backend half of ent#94's grid-widget foundation.Verified absent from
dev(3f1d4c89) before starting: noexecutions/timelineroute, nothing indb/schedules/stats.py, no open PR.What it unblocks
Three tile sub-issues (#96 executions-by-trigger, #98 fleet cost, #101 fleet context) were blocked on an endpoint ent#94 listed in Scope but never filed. They now share one query instead of each growing their own — which was the point of filing it.
Independent of the frontend chassis (#325), so it lands without touching
FleetGrid.vue/gridLayout.js/stores/fleetGrid.js— the files ent#305's open PR #1918 is currently rewriting.The design call worth reviewing
An analytics axis cannot degrade the way a filter can.
/api/executions/statsand the list route coerce an unknownhoursto 24. That is correct for a filter — worst case you get more rows than you asked for. It is wrong for a chart: it silently redraws a window the caller never requested and gives them no way to tell. Sogroup_byandhoursboth 422 by name here, deliberately diverging from the siblings.hours=0is refused forhour/dayonly. An all-time axis emits one bucket per interval since the fleet's first execution — unbounded, and nobody asked for it. It stays allowed fortrigger/agent, which have no continuum and are bounded by the number of distinct values. Refusing it there would be arbitrary.The token question — settling ent#326's open AC as option 1
schedule_executionshas no usage-token column. It carriescost,context_used,context_max;output_tokensexists only onchat_messages, which covers chat turns rather than fleet executions.So the endpoint reports context-window occupancy, under the name
context_used, and #101's tile must be labelled to match. Presenting this as "tokens consumed" is precisely the liveness-vs-quality mislabel the issue names. Option 2 (add token columns) is a schema change → two migrations plus the write path, unestimated; option 3 drops a tile that context occupancy can serve honestly.A guard fails if a token column is ever added, so that decision gets revisited deliberately rather than silently inherited.
Other decisions
_TRIGGER_BUCKETShas an explicitOthercatch-all — the property that makes a newly-added trigger visible instead of vanishing. A SQLCASEwould drop that guarantee the first time someone adds a trigger and forgets to update it.substron the stored ISO-Zstarted_at, not a date function: dialect-agnostic across SQLite and PostgreSQL, and the same UTC the row was written with (Invariant #16).Verification
tests/unit/test_ent326_executions_timeline.py— 30 checks, all passing; 52 green across adjacent suites (test_models_centralizedfor Invariant #14,test_schedule_analytics,test_1115_schedules_summary).The access test worth pointing at is the dangerous direction: a user with no accessible agents must get an empty series, never the whole fleet — pinned directly against the db layer's short-circuit, not just via the router.
The DB layer is driven against a real SQLite table: agent scoping,
errorcounted as failed (so the chart cannot contradict the stat card directly above it), NULL cost/context coalesced rather than poisoning the SUM, window respected in both directions, and an unvalidatedgroup_byraising rather than reaching SQL (defence in depth behind the router's 422, since the key is interpolated intoGROUP BY).Two failures during development were both mine, and both recorded in the file: a 40-day seed row that was outside the 720h window too (so the test proved nothing), and the
claim_tokensubstring above.Acceptance criteria
hour|day|trigger|agent/api/executions, verified by testgroup_by/ out-of-range window → named 422, never 500 or a silently-empty series/timelinenever captured as an execution idarchitecture.mdunder ExecutionsRelated to Abilityai/trinity-enterprise#326 · unblocks #96, #98, #101
🤖 Generated with Claude Code
Fixes abilityai/trinity-enterprise#326