Repository navigation
fix(workspace): the asks read pages past 200 and says its total (#3059) - #3077
Conversation
GET /api/enterprise/client-portal/asks stopped at 200 rows and returned no total. A viewer with more than 200 asks silently lost the oldest ended ones; past 200 pending, pending ones went too. Nothing on screen said the list was cut. Non-breaking: the body stays a list of WorkspaceAsk. Paging is opt-in: `limit` (1–200, default 200) plus an opaque `cursor`. Every response carries X-Total-Count, and X-Next-Cursor while more remain. A bad cursor is a named 422 invalid_cursor. The total is honest because every visibility filter now runs in SQL before the limit. The visible kinds (a new `types` filter) and the read-time roster re-check used to run in Python on the result, so a page of 200 rows could render fewer, and no SQL count could be the viewer's real total. The roster predicate itself is unchanged: `_on_roster` is asked once per agent the viewer's asks span, fails closed, and its answer becomes the SQL access set. db/operator_queue.py: - the filter conditions move into one `_list_conditions` helper shared by list_items and the new count_items / list_item_agent_names, so a page, its total and its agents can't drift apart; - `id` is the last sort key, so rows that tie page without overlap or skip. Pending asks still sort first, so none is dropped while ended ones are shown. Rule #1: requirements (security.md OPS-001 surfaces) and a new api-endpoints row are updated. Tests: test_3059_asks_paging covers: - 230 pending asks: 200 + 30, every ask visited exactly once; - invisible kinds, other addressees and off-roster agents neither take a page slot nor inflate the total; - pending before ended on page 1; - tie-break stability with identical timestamps; - the roster asked once per agent; - list_asks keeps its list contract; - the route's headers, the 422 cursors and the limit bounds. It was red before the change (13 of 14). The operator-queue and asks suites (53 files, 1877 tests) pass. The Inbox "Show more" and the removal of #3054's honest-cap footer belong to #3054, which can now read X-Total-Count / X-Next-Cursor. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
obasilakis
left a comment
There was a problem hiding this comment.
Approving. Validated locally: the new suite passes 14/14, and with the fix reverted 13 of 14 go red (the list-contract test is the expected survivor). Roster predicate is still asked once per agent and fails closed; Tier 1 CI green; security greps clean.
Two non-blocking warnings:
-
The tie-break test is vacuous on SQLite. With
operator_queue.c.idremoved from the ORDER BY,test_the_order_is_stable_when_timestamps_tiestill passes (4 runs): SQLite returns tied rows in insertion order anyway. Postgres guarantees no order for ties, which is where the key matters. Worth pinning the ORDER BY key directly or running this test on the PG lane. -
Offset cursor over a live set. Pending rows sort newest-first within priority, so an ask raised between two page loads repeats a row across pages, and an ask answered between loads skips one (a pending one, inside the pending block). "Visits every ask exactly once" holds only for a static set. #3054's "Show more" should dedupe by id; a keyset cursor is the upgrade if it ever matters.
Notes: "Related to #3059" carries no closing keyword, so #3059 will stay status-in-progress after merge. #3054 should carry Fixes #3059. CORS does not expose X-Total-Count / X-Next-Cursor, which is fine for the same-origin portal.
Summary
GET /api/enterprise/client-portal/asksstopped at 200 rows with no total. A viewer with more than 200 asks silently lost the oldest ended ones, and past 200 pending, pending ones too.WorkspaceAsk, so no current caller changes. Paging is opt-in withlimit(1–200, default 200) and an opaquecursor. Every response carriesX-Total-Count, plusX-Next-Cursorwhile more remain. An unreadable cursor is a named 422invalid_cursor.typesfilter) and the read-time roster re-check used to run in Python on the result, so a 200-row page could render fewer, and no SQL count could equal what the viewer actually sees. The roster predicate is unchanged:_on_rosteris asked once per agent the viewer's asks span, fails closed per agent, and its answer becomes the SQL access set.idis the last sort key, so rows that tie on everything else still page without overlap or skipping. Pending asks sort first, so none is dropped while ended ones are shown.Why headers, not an envelope
Andrii's #3049 (ready) and #3054 (the Inbox, draft) edit this same route, service and store. Keeping the body a list lets this land on
devwithout breaking or colliding with either. #3054's "Show more" readsX-Next-Cursor, and its honest-cap footer can go once it readsX-Total-Count. Those two AC items are handed to #3054.Changes
db/operator_queue.py:_list_conditionshelper shared bylist_itemsand the newcount_items/list_item_agent_names, so a page, its total and its agents can't be computed over different filters;typesfilter;idtie-break.database.py: facadescount_operator_queue_itemsandlist_operator_queue_agent_names.client_portal/asks/service.py:list_asks_page→AsksPage(items, total, next_cursor);list_askskeeps its list contract.client_portal/asks/router.py: thelimit/cursorquery and the headers.requirements/security.md(OPS-001 surfaces) and a newapi-endpoints.mdrow.Test Plan
tests/unit/test_3059_asks_paging.py, red before the change (13 of 14):list_askscontract;limitbounds.Conflict note: this touches
list_asksin the asks service and router, which #3049 also edits (fail-loud). The overlap is small; whichever lands second resolves it.Related to #3059 (the Inbox "Show more" and footer removal land with #3054)
🤖 Generated with Claude Code