Skip to content

fix(workspace): the asks read pages past 200 and says its total (#3059) - #3077

Merged
dolho merged 1 commit into
devfrom
fix/3059-asks-total-paging
Sep 29, 2026
Merged

dolho merged 1 commit into
devfrom
fix/3059-asks-total-paging

Conversation

@dolho

@dolho dolho commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • GET /api/enterprise/client-portal/asks stopped 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.
  • Non-breaking contract: the body stays a list of WorkspaceAsk, so no current caller changes. Paging is opt-in with limit (1–200, default 200) and an opaque cursor. Every response carries X-Total-Count, plus X-Next-Cursor while more remain. An unreadable cursor is a named 422 invalid_cursor.
  • The total is honest. Every visibility filter now runs in SQL before the limit. The visible kinds (new types filter) 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_roster is asked once per agent the viewer's asks span, fails closed per agent, and its answer becomes the SQL access set.
  • Stable order: id is 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 dev without breaking or colliding with either. #3054's "Show more" reads X-Next-Cursor, and its honest-cap footer can go once it reads X-Total-Count. Those two AC items are handed to #3054.

Changes

  • db/operator_queue.py:
    • 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 be computed over different filters;
    • a types filter;
    • the id tie-break.
  • database.py: facades count_operator_queue_items and list_operator_queue_agent_names.
  • client_portal/asks/service.py: list_asks_page → AsksPage(items, total, next_cursor); list_asks keeps its list contract.
  • client_portal/asks/router.py: the limit / cursor query and the headers.
  • Rule Fix: Add missing Docker labels to system agent container #1: requirements/security.md (OPS-001 surfaces) and a new api-endpoints.md row.

Test Plan

  • tests/unit/test_3059_asks_paging.py, red before the change (13 of 14):
    • 230 pending asks page as 200 + 30, visiting every ask exactly once;
    • invisible kinds, other addressees and off-roster agents neither take a slot nor inflate the total;
    • pending before ended on page 1;
    • tie-break stability with identical timestamps;
    • the roster asked once per agent (ent#428's property kept);
    • the list_asks contract;
    • the route's headers, the 422 cursors and the limit bounds.
  • Every operator-queue and asks suite (53 files): 1877 passed, 3 skipped on seeds 12345 and 99999.

Conflict note: this touches list_asks in 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

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 obasilakis left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. The tie-break test is vacuous on SQLite. With operator_queue.c.id removed from the ORDER BY, test_the_order_is_stable_when_timestamps_tie still 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.

  2. 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.

@dolho
dolho merged commit 0e0aafd into dev Sep 29, 2026
27 of 28 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants