Multi-select async owner typeahead filter

Task 9403 · Done · Coder · created · updated

Part of epic Task 9387 "Operator credentials roster UX fixes at scale". PRD = Task 9385 and architecture = Task 9386, both settled. IDs AC-x.y and D0–D9 are the PRD's; A1–A22 are the architecture's. Everything this child needs is restated here. Base: `develop` already contains Task 9378; never re-implement it. Original work item (verbatim): > Follow-up to Task 9378 (operator credentials page UX). That task shipped server-side sort (Created/Name/Type/Org), a "1–25 of N" count, and shows the tenant once per row. These items remain for an operator working across all tenants' credentials, up to millions of rows: > 1. **Secrets filter scales.** `?secrets=` decrypts every row in scope on each request (`apps/api/app/routes/credentials/reads.py::_secrets_filter`) and puts every id into an `IN` list (Postgres allows at most 65535). Store a "has stored secret" boolean when the credential is written, so the filter becomes an indexed predicate. > 2. **Paging without full counts.** Replace exact `COUNT(*)` + OFFSET with keyset (cursor) paging and a capped/estimated total ("10,000+"). Add indexes behind each sort order (`lower(name)`, `type`, org name). > 3. **Search that finds things at scale.** Add a pg_trgm index on name/description. For operators, also match org name/slug and owner email. > 4. **Owner picker as an async typeahead.** A native select cannot hold thousands of owners across tenants. Make it multi-select. > 5. **Tree mode beyond 200 rows.** Build tree mode from a server-side folder listing (folders with counts, children loaded on open) instead of one 200-row page. > 6. **Bulk actions.** Let operators select rows (per page, plus "all N matching") and then delete, change org or move them to a folder. > 7. **Health columns and filters.** Add last used, last test result, and expired/expiring/never used, each also available as a filter. > > Out of scope: the three items Task 9378 shipped; credential editor pages; runner credential resolution. > > Done means: > - Each item above is shipped or explicitly declined with a reason. > - With 1M credentials in a dev DB, the roster's first page with any filter or sort loads in under 1s (EXPLAIN shows an index scan). > - An operator can bulk-delete 500 matching credentials in one action. Shared rules: - D1: "operator" means a principal on the operator plane holding cross-tenant `credentials:view`. - No scope widens. - No secret or config value appears in any response. - UI copy follows `docs/standards/ui-copy-standards.md`; layout follows `docs/standards/ui-style-guide.md`. ## This child (architecture C4): owner typeahead, multi-select Covers item 4. It needs repeatable `owner_id` on the roster, from the "Keyset roster query builder and API" child. Approach (settled decisions): - **A13 — owners endpoint** `GET /api/credentials/owners?q=&limit=1..50&ids=uuid…`, default limit 20. - Matches name or email through the trigram indexes (`ix_admin_users_email_trgm` and the name index). - Restricted with `EXISTS`: the owner must own a credential within the caller's roster scope, the same scope as today's `list_credential_owners` in `reads.py`. - Order: prefix matches first, then name, then id. This deviates from AC-4.1's "ordered by credential count", because a GROUP BY over 1M rows on every keystroke is unaffordable. - `credential_count` is returned only for the ≤20 owners shown, as a LATERAL capped count (cap 10,001, shown "10,000+"), with a new `count_capped: bool`. - `ids=` resolves stored selections to names. - With no `q` and no `ids`, it returns today's full list unchanged, so existing callers keep working. - **A21 — the picker.** Add `apps/web/src/ui/AsyncMultiLookup.tsx` beside `AsyncLookup.tsx`. - Factor the shared fetch/debounce/abort machinery into a hook, `useAsyncOptions`, that both components use. - Build it on MUI `Autocomplete multiple` with chips (pattern: `components/tasks/TaskTags.tsx`). - **Wiring.** - `CredentialFilterStrip.tsx` swaps the native owner `<select>` for the new picker. - Selections persist as ids through `credentialFilterStorage.ts` and are resolved to names via `ids=` on load. - Keyless callers keep the 403 path and see no picker. - **Explain case.** Add the owner-lookup case (operator, 3-char `q` on the 1M seed) to `tools/perf/credentials_roster_explain.py`. Credentials & reach (architecture §9, verbatim; "C1's script" = the foundation child's seed script): - "Unit/API tests for every AC | Nothing beyond the worktree (`./dc test`). | verified — same." - "AC-P (1M rows, EXPLAIN < 1 s) | Nothing beyond the worktree: the slot's local compose DB (`./dc psql`), seeded by C1's script. | verified — needs only the worktree; the stack is started by the coder (`./dc up`)." Out of scope: - MUST NOT re-implement or change Task 9378's sort control, "1–25 of N" count format (other than the capped variant) or once-per-row tenant display. - MUST NOT change credential editor pages: `apps/web/src/pages/credentials/CredentialEditPage.tsx`, `AccountEditPage.tsx`, `credentialForm.tsx`, nor the Accounts page (`Accounts.tsx`). - MUST NOT change runner credential resolution: what `routes/agent/task_secrets.py` and the credential-get path return, who may call them, or path resolution (`runner_path`). Only the additive best-effort `last_used_at` stamp (D6) is allowed. - MUST NOT change the sealing scheme (`app/credential_crypto.py`) or put config into SQL predicates. - MUST NOT make health columns sortable, add "jump to page N", or add bulk verbs beyond delete / change org / move to folder. - MUST NOT change `CredentialSelect` or other `page`-based pickers' behavior. - Do NOT touch the paging, search or health UI, the tree, or bulk actions (sibling children). Change the roster query only to consume `owner_id`. Done means (PRD criteria verbatim with their oracles; the A13 deviation is marked): - **4.1** The Owner filter is an async typeahead (reuse the `apps/web/src/ui/AsyncLookup.tsx` pattern): typing ≥1 char queries the server (`/api/credentials/owners?q=&limit=`), matching name or email, at most 20 results, ordered **prefix match first, then name** (A13 deviation from "credential count then name"), scoped exactly as today. → verify: API test for `q` + limit + scope + order; web test types a name and sees server results. - **4.2** Multiple owners can be chosen; chips show each owner's name (never a raw id), each removable; the roster filters to the union. → verify: a web test selects two owners, removes one, and asserts the request's `owner_id` values. - **4.3** Selected owners survive reload (existing filter persistence, `apps/web/src/lib/credentialFilterStorage.ts`), and a stored owner whose name must be shown is resolved by id. → verify: a web test reloads and sees names, not ids. - **4.4** Keyless callers still get no owner roster (403 path) and the picker stays hidden. → verify: existing test passes. - **4.5** Usable at phone width: chips wrap, no horizontal page scroll. → verify: e2e at the narrowest supported width. - Tightened: chips show full names, ellipsized with a tooltip only when longer than the field. The input stays usable with three chips selected. - Attach the screenshot to the PR. - The explain script's owner-lookup case runs in under 1000 ms with no `Seq Scan on credentials` on the 1M seed. The output goes in the PR. - `./dc check quick` is green.

Outcome

Owner filter is now a multi-select async typeahead. /owners gained q, limit and ids, with prefix-first order, capped counts and a name trigram index. Explain run on 1M rows: 27 ms. Quick gate green; review lane passed. PR #6755 merged 06ce983; its deploy was superseded by a newer one. Memory: proposed mem-24743.

Pull request

Artifacts

Activity

  • Draft
  • Ready
  • Runner started
  • Runner ended (done)