Skip to content

fix(frontend): projects live 路径两个 shell 都只取第一页且丢掉 nextCursor(第 51 个项目起静默不显示)+ desktop 建/改项目失效的是一个从不存在的键(#2290) - #2299

Merged
DeliciousBuding merged 1 commit into
masterfrom
fix/projects-live-pagination
Sep 3, 2026

Conversation

@DeliciousBuding

Copy link
Copy Markdown
Collaborator

TL;DR

Projects 列表在两个 shell 的 live 数据路径上都没有分页:只发一次列表请求、拿第一页、丢掉 page.nextCursor。工作区项目数超过一页时,多出来的项目静默不显示——没有「加载更多」、没有截断提示、没有错误。顺带修掉同一批代码里第三个 live 缺陷:desktop 的 create/update mutation 失效的是一个从不存在的键,所以建/改项目后列表不刷新。

服务端一直支持分页:GET /web/projects 接受 pageSize(默认 50、上限 200 = config.MaxListPageSize)与 pageCursor,并回传 page.nextCursor / page.hasMorehandler/workspace.go:86-90)。唯一实现过游标推进的客户端代码是 #1546WorkbenchProjectsPort 链路,它在两个 shell 里结构性不可达、已删 ⇒ 分页能力从「有实现但永不执行」变成「明确没有实现」,缺口因此可见。

改动前的 live 实测

位置 事实
app/web/src/api/projectQueries.ts:37 client.listWorkspaceProjects({ pageSize: 50 }) —— 不读 nextCursor,无第二次请求
app/web/src/api/projectQueries.test.ts:49 ?pageSize=50 钉成契约 ⇒ 修前必先红这一条(已就地更新为 ?pageSize=200,见下)
app/desktop/src/api/hubQueries.ts:117 getHubClient().listWorkspaceProjects() —— 连 pageSize 都不传(= 服务端默认 50)
两端消费点 只取 data?.itemsuseWebWorkbenchModel.ts:339 / useDesktopWorkbenchModel.ts:420),page 整个丢弃 ⇒ 截断对用户完全不可见
app/desktop/src/api/hubQueries.ts:128,139 失效 ['hub','workspace-projects'],而集合实际键在 projects 家族(['hub','projects',…])⇒ 前缀匹配 0 命中,建/改项目后列表保持陈旧。与 #2252/#2261 同一故障模式,换了个家族

语义裁决(Decision Register / ADR-029 同批)

issue 给的两条路是「一次拉满(提到 200 并说明上限)」或「真分页(游标 + 加载更多/无限滚动)」。取第三条,也是本仓已有的那条:按游标走满 + 上限可见。

理由:仓库里已经有一个同形状先例——两个 shell 的 fetchExecutionTargets 都是 pageSize 50 × 10 页的游标循环,并在撞到上限时透传 hasMore。沿用同一 idiom(前端只留一种分页写法,不新增第二种),并把上限做成可见的:撞上限时 page.hasMore 保持 true,调用方仍能区分「截断」与「完整」。

不新造 UI(不引入 sentinel / load-more / IntersectionObserver):acceptance 只要求「第二页被取到」或「截断被暴露」,而 #2290 的负向约束明确禁止恢复第二条平行取数路径。复用先例的取数形状即满足,UI 层的无限滚动留作后续(若产品要)。

pageSize200(该端点自己声明并被查询层实际执行的天花板)而不是先例的 50:常见情况(≤200 个项目)一次请求即完成,往返少 4 倍;上限 5 页 = 1000 个项目。

改动

  • web/src/api/projectQueries.tsdesktop/src/api/hubQueries.ts:各一个 fetchWorkspaceProjects 游标循环(pageSize 200 × 最多 5 页),撞上限时透传 hasMore: true;服务端 hasMore=true 但不给 cursor 时停止(不空转、不死循环)。
  • desktop 集合键 projects.rootprojects.list('hub'),与 web 同形。root 是宽失效前缀、不该直接当查询键——这正是下面两条失效打不中的根因
  • desktop 两处失效 → hubQueryKeys.projects.root(它是所有 projects 键的前缀 ⇒ 同时命中列表与已缓存的 detail/threads)。
  • desktop 侧响应类型从 client 自身推导(Awaited<ReturnType<…>>),不新增 import 面、不可能与 client 漂移。

测试

新增 desktop/src/api/hubQueries.projects.test.tsx(6 例)+ web 2 例;web 原有 11 例全绿。

期望红一条既有测试并已就地更新projectQueries.test.ts:49?pageSize=50?pageSize=200。它钉的是旧契约本身,不是行为的旁证。

暗卷(mutation:只把两个源文件还原到 master,测试保留)

web  (src/api/projectQueries.test.ts)          Tests 3 failed | 8 passed (11)
  × lists Hub workspace projects …   expected '…/web/projects?pageSize=50' to be '…?pageSize=200'
  × walks cursor pages …             expected "vi.fn()" to be called 2 times, but got 1 times
  × reports hasMore=true …           expected "vi.fn()" to be called 5 times, but got 1 times

desktop (src/api/hubQueries.projects.test.tsx)  Tests 6 failed (6)
  × keys the collection off the shared projects family
      expected [ [ 'hub', 'projects' ] ] to deep equally contain [ 'hub', 'projects', 'hub' ]
  × the create mutation invalidates a key the list query really occupies
      expected "vi.fn()" to be called 2 times, but got 1 times     ← 幽灵失效的直接证据
  × the update mutation …            同上
  × walks cursor pages / reports hasMore / stops walking           均为 1 次调用

还原后两套全绿(11 + 6)。

本地门禁

Fixes #2290

@coderabbitai

coderabbitai Bot commented Sep 3, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Team

Run ID: fdf2ebb0-104d-4725-9114-f7869fdd7fb8

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@DeliciousBuding
DeliciousBuding enabled auto-merge (squash) September 3, 2026 18:21
…ageSize=50、desktop 连 pageSize 都不传 ⇒ 第 51 个项目起静默不显示),且 desktop 的 create/update 失效的是一个从不存在的键 ['hub','workspace-projects'] ⇒ 建/改项目后列表不刷新(#2290)

服务端一直支持:GET /web/projects 接受 pageSize(默认 50、上限 200 =
config.MaxListPageSize)与 pageCursor,并回传 page.nextCursor / page.hasMore
(handler/workspace.go:86-90)。唯一实现过游标推进的客户端代码是 #1546 的
WorkbenchProjectsPort 链路,它在两个 shell 里结构性不可达、已删 ⇒ 分页能力从
「有实现但永不执行」变成「明确没有实现」,缺口因此可见。

live 实测(改动前):
- app/web/src/api/projectQueries.ts:37 `client.listWorkspaceProjects({ pageSize: 50 })`
  ——不读 nextCursor,无第二次请求,且 projectQueries.test.ts:49 把 ?pageSize=50
  钉成契约。
- app/desktop/src/api/hubQueries.ts:117 `getHubClient().listWorkspaceProjects()`
  ——连 pageSize 都不传(= 服务端默认 50)。
- 两处都只消费 data?.items(useWebWorkbenchModel.ts:339 /
  useDesktopWorkbenchModel.ts:420),page 直接丢弃 ⇒ 截断对用户完全不可见:
  没有「加载更多」、没有截断提示、没有错误。
- app/desktop/src/api/hubQueries.ts:128,139 失效 ['hub','workspace-projects'],
  而集合实际键是 projects 家族(['hub','projects',…])⇒ 前缀匹配 0 命中,
  建/改项目后列表保持陈旧。与 #2252/#2261 同一故障模式,换了个家族。

裁决(记入 round-73 Decision Register / ADR-029):语义取「按游标走满 + 上限可见」,
不取「一次拉满 200 并静默截断」,理由是本仓已有一个同形状先例——两个 shell 的
fetchExecutionTargets 都是 pageSize 50 × 10 页的游标循环并在撞到上限时透传
hasMore。沿用同一 idiom(前端只留一种分页写法),并把上限做成**可见**的:撞上限时
page.hasMore 保持 true,调用方仍能区分「截断」与「完整」。不新造 UI(不引入
sentinel / load-more),因为 acceptance 只要求「第二页被取到」或「截断被暴露」。

改动:
- web/desktop 各一个 fetchWorkspaceProjects 游标循环(pageSize=200 = 该端点自己的
  天花板,5 页上限 = 1000 个项目;比 50×10 少 4 倍往返,常见情况一次请求即完)。
- desktop 集合键 projects.root → projects.list('hub'),与 web 同形(root 是宽失效
  前缀,不该直接当查询键——正是下面两条失效打不中的根因);两处失效改为
  hubQueryKeys.projects.root(它是所有 projects 键的前缀 ⇒ 同时命中列表与已缓存的
  detail/threads)。
- desktop 侧类型从 client 自身推导(Awaited<ReturnType<…>>),不新增 import 面。

测试(新增 desktop/src/api/hubQueries.projects.test.tsx 6 例 + web 2 例,
web 原有 11 例全绿):
- 走满游标:断言两次调用的参数/URL 逐字(第二页带 pageCursor=cur-1)、两页 items
  都在结果里。
- 上限可见:撞 5 页上限后 hasMore=true。
- 服务端 hasMore=true 但不给 cursor 时**停止**(不空转、不死循环)。
- desktop 集合键从**真实 query cache** 推导后断言等于 projects.list('hub') 且不等于
  root(沿用 sessionQueries.test.tsx 的约定:不许用只存在于测试里的形状满足断言)。
- create / update 两个 mutation 各自断言**可观测的重取**(listWorkspaceProjects 调用
  次数 1 → 2)——打不中的失效与没有失效在缓存上不可区分,所以必须断言后果,这正是
  能抓住 ['hub','workspace-projects'] 的那种断言(#2252 的教训)。

期望红一条既有测试并已就地更新:projectQueries.test.ts:49 的 ?pageSize=50 →
?pageSize=200(它钉的是旧契约本身)。

本地门禁:web/desktop typecheck 干净;web src/api+src/platform 20 文件 198 例全绿;
desktop src/api+src/platform 24 文件 207 例全绿。

Co-authored-by: Cursor <cursor@vectorcontrol.tech>
@DeliciousBuding
DeliciousBuding force-pushed the fix/projects-live-pagination branch from 8df9c2f to 4965abb Compare September 3, 2026 18:36
@DeliciousBuding
DeliciousBuding merged commit d93c1d6 into master Sep 3, 2026
43 checks passed
@DeliciousBuding
DeliciousBuding deleted the fix/projects-live-pagination branch September 3, 2026 18:45
DeliciousBuding added a commit that referenced this pull request Sep 3, 2026
…nchRoutes 是静态 import),且它自己从来没赢过那场 5s 竞速(6 个 import 全部超时、随后 EnvironmentTeardownError),却给本包 169 个测试文件的每一个 beforeAll 平白加上 ~5s(#2251 slice 3 实测最长杆 7m20s 的 37~40%)

三条独立证据,主机全部实测(不是推断):

1. **前提是假的。** `workbench/src/WorkbenchRoutes.tsx:26` 原文:
   `// ── Static page imports (no React.lazy — lazy 在 jsdom 测试中无法同步解析) ──`
   ⇒ 生产代码早就把 React.lazy 换成了静态 import,而且注释写明换的原因正是
   jsdom 下 lazy 无法同步解析。setup.ts 里那段注释仍在声称「WorkbenchRoutes
   wraps every page component in React.lazy() + Suspense」,并据此预热 6 个
   `import('../pages/*')`。全仓复核:`workbench/src` 与 `shared/src` 非测试代码里
   `lazy(`/`Suspense` 只剩 `shared/src/ui/CodeBlock.tsx` 的高亮器(与这 6 个页面
   无关),`WorkbenchRoutes*` 只有 1 个文件、其中 0 处 lazy ⇒ 被预热的那个
   Suspense 边界不存在。属 #2246「注释声称被活代码推翻」同族。

2. **机制本身从来没生效。** 就地插桩(测量后已还原,`git status` 干净)跑最小
   测试文件,stderr 直出:
   ```
   [PRELOAD] ProjectsPage  TIMEOUT arm won at 5000ms
   [PRELOAD] ContactsPage  TIMEOUT arm won at 5000ms
   [PRELOAD] DocsPage      TIMEOUT arm won at 5000ms
   [PRELOAD] AgentsPage    TIMEOUT arm won at 5000ms
   [PRELOAD] TasksPage     TIMEOUT arm won at 5000ms
   [PRELOAD] SettingsPage  TIMEOUT arm won at 5000ms
   [PRELOAD] DocsPage     REJECTED in 5309ms: EnvironmentTeardownError: Cannot load '…/mdast-util-to-string/index.js' …
   [PRELOAD] ProjectsPage REJECTED in 5310ms: EnvironmentTeardownError: …
   ```
   6 个全部由计时器臂胜出,随后在 vitest 拆除环境时才以 EnvironmentTeardownError
   告终 ⇒ 它一个页面都没预热成功,`beforeAll` 的 30s 预算也从来没用上。

3. **代价是平的、可量的。** 同一个 38 行 / 2 例的最小文件:
   | | Duration | 其中 tests 桶 |
   |---|---|---|
   | 改动前 | 8.16s | **5.28s** |
   | 改动后 | **3.36s** | **0.32s** |
   `real` 9.4s → 4.5s。5.28s ≈ 那个 5s 计时器 + 收尾,与 CI 侧对 169 个逐文件
   耗时做 OLS 得到的 `sec ≈ 4.383 + 0.0327·n_tests`(93% 是每文件固定成本、
   1 例的文件也要 5.01~5.32s)两个互不相关的估计器互证。

安全性(不是「删了没红」而是「删了不可能改变行为」):既然 6 个 import 从来没有
在计时器之前完成,那么现有全绿套件本来就是在「预热无效」的状态下跑出来的;被删掉
的只是一段必然超时的等待。页面在测试里是通过静态 import 渲染的(如
`hubPages.test.tsx` 直接 import `AgentHubWorkbench`),没有任何用例在等一个 lazy
边界。

全量复跑(本机 4 核 ARM,无 coverage):**169 文件 / 1714 例全绿**,
`Duration 305.37s (transform 72.44s, setup 115.00s, import 591.46s, tests 159.96s,
environment 290.33s)`。对照 CI 侧同套件改动前的
`Duration 415.77s (… tests 796.77s …)`:**tests 聚合桶 796.77s → 159.96s**,
即 ~637s 的聚合计时器等待消失(本机 import/transform 比 CI 慢,故 wall 数字不与
CI 直接可比,桶差值才是可迁移的量;按 CI 实测并发度 3.86 折算 ≈ −165s wall)。

刻意没做:没有改成「不 await 只 fire」的折中形态。那种写法要成立,前提是真有一个
lazy 边界需要预热,而前提已被证据 1 否掉;留一个不 await 的 import 只会把一段死
机制换成一段更难解释的死机制。注释里写清了「若将来页面真的重新变 lazy,修法是在
渲染它的用例里 await(findBy*/waitFor),而不是加一个全局计时器」。

Refs #2251(slice 3:workbench coverage 是 frontend PR 的唯一长杆,#2299 的真实
run 里该 job = 7m20s,第二名 windows-frontend 3m45s)。本 PR 不动
`.github/workflows/checks.yml`、不动任何门禁阈值、不动 coverage.include ⇒ 门禁强度
不变,只是不再为一段死机制付 5s×169 的税。

Co-authored-by: Cursor <cursor@vectorcontrol.tech>
DeliciousBuding added a commit that referenced this pull request Sep 3, 2026
…nchRoutes 是静态 import),且它自己从来没赢过那场 5s 竞速(6 个 import 全部超时、随后 EnvironmentTeardownError),却给本包 169 个测试文件的每一个 beforeAll 平白加上 ~5s(#2251 slice 3 实测最长杆 7m20s 的 37~40%)

三条独立证据,主机全部实测(不是推断):

1. **前提是假的。** `workbench/src/WorkbenchRoutes.tsx:26` 原文:
   `// ── Static page imports (no React.lazy — lazy 在 jsdom 测试中无法同步解析) ──`
   ⇒ 生产代码早就把 React.lazy 换成了静态 import,而且注释写明换的原因正是
   jsdom 下 lazy 无法同步解析。setup.ts 里那段注释仍在声称「WorkbenchRoutes
   wraps every page component in React.lazy() + Suspense」,并据此预热 6 个
   `import('../pages/*')`。全仓复核:`workbench/src` 与 `shared/src` 非测试代码里
   `lazy(`/`Suspense` 只剩 `shared/src/ui/CodeBlock.tsx` 的高亮器(与这 6 个页面
   无关),`WorkbenchRoutes*` 只有 1 个文件、其中 0 处 lazy ⇒ 被预热的那个
   Suspense 边界不存在。属 #2246「注释声称被活代码推翻」同族。

2. **机制本身从来没生效。** 就地插桩(测量后已还原,`git status` 干净)跑最小
   测试文件,stderr 直出:
   ```
   [PRELOAD] ProjectsPage  TIMEOUT arm won at 5000ms
   [PRELOAD] ContactsPage  TIMEOUT arm won at 5000ms
   [PRELOAD] DocsPage      TIMEOUT arm won at 5000ms
   [PRELOAD] AgentsPage    TIMEOUT arm won at 5000ms
   [PRELOAD] TasksPage     TIMEOUT arm won at 5000ms
   [PRELOAD] SettingsPage  TIMEOUT arm won at 5000ms
   [PRELOAD] DocsPage     REJECTED in 5309ms: EnvironmentTeardownError: Cannot load '…/mdast-util-to-string/index.js' …
   [PRELOAD] ProjectsPage REJECTED in 5310ms: EnvironmentTeardownError: …
   ```
   6 个全部由计时器臂胜出,随后在 vitest 拆除环境时才以 EnvironmentTeardownError
   告终 ⇒ 它一个页面都没预热成功,`beforeAll` 的 30s 预算也从来没用上。

3. **代价是平的、可量的。** 同一个 38 行 / 2 例的最小文件:
   | | Duration | 其中 tests 桶 |
   |---|---|---|
   | 改动前 | 8.16s | **5.28s** |
   | 改动后 | **3.36s** | **0.32s** |
   `real` 9.4s → 4.5s。5.28s ≈ 那个 5s 计时器 + 收尾,与 CI 侧对 169 个逐文件
   耗时做 OLS 得到的 `sec ≈ 4.383 + 0.0327·n_tests`(93% 是每文件固定成本、
   1 例的文件也要 5.01~5.32s)两个互不相关的估计器互证。

安全性(不是「删了没红」而是「删了不可能改变行为」):既然 6 个 import 从来没有
在计时器之前完成,那么现有全绿套件本来就是在「预热无效」的状态下跑出来的;被删掉
的只是一段必然超时的等待。页面在测试里是通过静态 import 渲染的(如
`hubPages.test.tsx` 直接 import `AgentHubWorkbench`),没有任何用例在等一个 lazy
边界。

全量复跑(本机 4 核 ARM,无 coverage):**169 文件 / 1714 例全绿**,
`Duration 305.37s (transform 72.44s, setup 115.00s, import 591.46s, tests 159.96s,
environment 290.33s)`。对照 CI 侧同套件改动前的
`Duration 415.77s (… tests 796.77s …)`:**tests 聚合桶 796.77s → 159.96s**,
即 ~637s 的聚合计时器等待消失(本机 import/transform 比 CI 慢,故 wall 数字不与
CI 直接可比,桶差值才是可迁移的量;按 CI 实测并发度 3.86 折算 ≈ −165s wall)。

刻意没做:没有改成「不 await 只 fire」的折中形态。那种写法要成立,前提是真有一个
lazy 边界需要预热,而前提已被证据 1 否掉;留一个不 await 的 import 只会把一段死
机制换成一段更难解释的死机制。注释里写清了「若将来页面真的重新变 lazy,修法是在
渲染它的用例里 await(findBy*/waitFor),而不是加一个全局计时器」。

Refs #2251(slice 3:workbench coverage 是 frontend PR 的唯一长杆,#2299 的真实
run 里该 job = 7m20s,第二名 windows-frontend 3m45s)。本 PR 不动
`.github/workflows/checks.yml`、不动任何门禁阈值、不动 coverage.include ⇒ 门禁强度
不变,只是不再为一段死机制付 5s×169 的税。

Co-authored-by: Cursor <cursor@vectorcontrol.tech>
DeliciousBuding added a commit that referenced this pull request Sep 3, 2026
…限透传 hasMore),并修掉 agent profiles 在两个 shell 都只取第一页的同类缺陷——web 钉死 pageSize=50 丢 cursor、desktop 连参数都不传还把整个 page 对象扔掉 ⇒ 第 51 个 agent profile 静默不显示(#2290 同族)

为什么是抽公共契约而不是再抄一遍循环:本仓已经有 **4 份**手抄的同一个游标循环
(web/desktop 的 fetchExecutionTargets 各一份,加上上一发 #2299 给 projects 补的
web/desktop 各一份)。第 5、第 6 份正在路上——本次排查发现 agent profiles 两个
shell 也漏,public skills / public MCP servers / documents 同样只取第一页。
每份手抄都要各自记住「hasMore 撞上限时要透传」「没有 nextCursor 就不许自己编一个」,
而这正是缺陷的成因。所以把契约收进 `app/shared/src/hub/paginate.ts`:

- `HUB_LIST_PAGE_SIZE = 200`:通用游标列表端点自己声明、查询层实际执行的天花板
  (`config.MaxListPageSize`;`agent_profile.go:154`、`market.go:49`、
  `workspace.go:88` 都是 `ClampPageSize(..., MaxListPageSize, ...)`)。
- `HUB_LIST_MAX_PAGES = 5` ⇒ 1000 条上限;**撞上限时返回 `hasMore: true`**,
  让「被截断」与「本来就这么多」可区分。上限是明示的天花板,不是静默的。
- 只用服务端给的 `nextCursor` 续页;`hasMore=true` 但无 cursor 时**停止**,
  不自己编游标、不空转。
- 错误**不吞**:某一页失败就是请求失败,交给调用方的 react-query 处理重试与错误面。
  在这里吞掉会把 401 变成「列表只有 1 条」,正是本契约要消灭的故障形态。
- 端点天花板更低的(messages 家族 = 100)自己传显式值,不继承默认。

本次转换的 4 个 live 调用点(projects 两份由手抄循环改为调用契约,行为逐字不变;
agent profiles 两份是**修缺陷**):

| 调用点 | 改动前 | 改动后 |
|---|---|---|
| `web/src/api/projectQueries.ts` | 手抄循环(#2299) | `fetchAllPages(client.listWorkspaceProjects)` |
| `desktop/src/api/hubQueries.ts` | 手抄循环(#2299) | 同上 |
| `web/src/api/agentQueries.ts:301` | `listAgentProfiles({ pageSize: 50 })`,丢 cursor | `fetchAllPages(client.listAgentProfiles)` |
| `desktop/src/api/agentProfileQueries.ts:145` | `listAgentProfiles()` 无参 + `res.items ?? []`(整个 `page` 扔掉) | `fetchAllPages(...)`,hook 的 items-only 对外形状不变 |

测试:
- `shared/src/hub/paginate.test.ts` 9 例(契约本身):单页原样返回、按 cursor 走满
  三页并逐字断言每次入参、`hasMore=true` 但无 cursor 时只调一次、撞上限透传
  `hasMore`、默认值等于 200/5、显式更小天花板、`items`/`page` 缺失时不炸、
  **页错误必须冒泡**(`rejects.toBe(failure)`)、`maxPages<=0` 按一页处理不死循环。
- web `agentQueries.test.ts` 新增走页断言(两次调用的 URL 逐字:`?pageSize=200`、
  `?pageSize=200&pageCursor=cur-1`,两页 items 都在结果里)。
- desktop 新增 `agentProfileQueries.hub.test.tsx`:renderHook 驱动真 QueryClient,
  断言第 1 次调用**必须带 pageSize**(无参 = 服务端默认,正是藏住列表尾部的原因)、
  第 2 次带 cursor、hook 数据含两页。
- 期望红一条既有测试并已就地更新:`agentQueries.test.ts:96` 的 `?pageSize=50` →
  `?pageSize=200`(它钉的是旧契约本身)。

本地门禁:`@agenthub/shared` 全量 **151 文件 / 2755 例全绿**;web `src/api`+`src/platform`
**20/198**;desktop `src/api`+`src/platform` **24/207**(含新增两例);三个包
typecheck 干净。

**如实登记本次未修的同类残项**(已排查、已定位、有现成契约可用,不夹带进本 PR):
`listPublicSkills`(`web/src/App.tsx:115`、`desktop/src/App.tsx:235`)、
`listPublicMCPServers`(`web/src/App.tsx:124`、`desktop/src/App.tsx:244`)都是无参
调用 ⇒ 同样只取第一页;`listDocuments`(`web/src/platform/useWebWorkbenchModel.ts:327`
无参;desktop 侧传 params 待逐个核);`listAuditEvents` 生产侧 **0 调用点**(非 live
路径,不报);`searchMessages` 由 UI 显式传 params,属另一形态。两个 shell 的
`fetchExecutionTargets` 仍是手抄循环(行为正确,只是重复),按仓库纪律不在本 PR
顺手改(#2261 的负向约束也点名不许顺手动它)。

Refs #2290(同类残项已并入该 issue 登记)。

Co-authored-by: Cursor <cursor@vectorcontrol.tech>
DeliciousBuding added a commit that referenced this pull request Sep 3, 2026
…ecret 夹具精确 allowlist 形态、前端 query key 唯一形状、对外契约四条口径(owner 取值域 / phase 适用范围 / Mobile 口径 / AGENTS 下沉触发条件)、分页 clamp 保留不转 400 (#2303)

本文件的消费者是「下一次有人要拍同一个板」:四条都是此前明确标记为
operator decision、且各自阻塞多个后续 issue 的悬空裁决(#2295 阻塞脱敏测试可重构、
#2261 S1 阻塞 ~11 条键家族工作、#2258 四条阻塞对外文档一致性、#2243 残项阻塞分页
语义收尾)。每条都写了 choice + invariant + 被否决的选项 + 代价,而不是只写结论。

裁决依据全部来自 live tree 实测,不沿用 handoff 摘要:
- ADR-028:`check-secrets.sh:219/235/239` 已是 `(^|[^A-Za-z0-9])` 左边界形态
  (#2297 已合流),`find -name 'secret-fixture-allowlist*'` = 0 命中 ⇒ 第 2 项
  已完成、第 1 项待实施;13 个 `TestSanitizeSubAgentResult_*` 仍冻结在
  `process_executor_test.go`。
- ADR-029:`threads.detail` 的 useQuery 消费者 0、`hubEventBridge.ts` 失效点 9;
  desktop 私设 `['hub','sessions']` 8 处、`['hub','workspace-projects']` 2 处
  (后者由 #2299 修掉,前者待收敛);web `hub-messages` 字面量 37 处。
- ADR-030:owner 分布实测 `{Hub:203, Edge:77, Runner:4}`,4 个 Runner 全是
  `/v1/workspaces/**` 的 planned;phase 缺 166 = `/web 91 /client 64 /edge 5
  /v1 3 /health 1 /api 1 /cloud 1`(⇒「只标设计面」几乎成立,真正违反的只有 3 个
  `/v1`);`AGENTS.md` 285/300 行;`release.yml:383 build-mobile` 受
  `RELEASE_MOBILE_ENABLED` 门控。
- ADR-031:13 个 handler 回传 nextCursor/hasMore、两个 clamp 端点是 limit/offset
  形态(`notification.go` 已在代码内如实注明);`ClampPageSize` 的中间规则由
  `repository/pagination_clamp_test.go` 钉住。

`docs/decisions.md` 45 行(预算 80);`python3 scripts/verify/verify-doc-ssot.py`
全绿(含 AGENTS.md 98 条路径检查、29 个自测接线检查);两处被钉住的锚点句
(ADR-017 行、"Full ADR bodies are archived")未动。

Refs #2295 #2261 #2258 #2243

Co-authored-by: DeliciousBuding <DeliciousBuding@users.noreply.github.com>
Co-authored-by: Cursor <cursor@vectorcontrol.tech>
DeliciousBuding added a commit that referenced this pull request Sep 3, 2026
…nchRoutes 是静态 import),且它自己从来没赢过那场 5s 竞速(6 个 import 全部超时、随后 EnvironmentTeardownError),却给本包 169 个测试文件的每一个 beforeAll 平白加上 ~5s(#2251 slice 3 实测最长杆 7m20s 的 37~40%)

三条独立证据,主机全部实测(不是推断):

1. **前提是假的。** `workbench/src/WorkbenchRoutes.tsx:26` 原文:
   `// ── Static page imports (no React.lazy — lazy 在 jsdom 测试中无法同步解析) ──`
   ⇒ 生产代码早就把 React.lazy 换成了静态 import,而且注释写明换的原因正是
   jsdom 下 lazy 无法同步解析。setup.ts 里那段注释仍在声称「WorkbenchRoutes
   wraps every page component in React.lazy() + Suspense」,并据此预热 6 个
   `import('../pages/*')`。全仓复核:`workbench/src` 与 `shared/src` 非测试代码里
   `lazy(`/`Suspense` 只剩 `shared/src/ui/CodeBlock.tsx` 的高亮器(与这 6 个页面
   无关),`WorkbenchRoutes*` 只有 1 个文件、其中 0 处 lazy ⇒ 被预热的那个
   Suspense 边界不存在。属 #2246「注释声称被活代码推翻」同族。

2. **机制本身从来没生效。** 就地插桩(测量后已还原,`git status` 干净)跑最小
   测试文件,stderr 直出:
   ```
   [PRELOAD] ProjectsPage  TIMEOUT arm won at 5000ms
   [PRELOAD] ContactsPage  TIMEOUT arm won at 5000ms
   [PRELOAD] DocsPage      TIMEOUT arm won at 5000ms
   [PRELOAD] AgentsPage    TIMEOUT arm won at 5000ms
   [PRELOAD] TasksPage     TIMEOUT arm won at 5000ms
   [PRELOAD] SettingsPage  TIMEOUT arm won at 5000ms
   [PRELOAD] DocsPage     REJECTED in 5309ms: EnvironmentTeardownError: Cannot load '…/mdast-util-to-string/index.js' …
   [PRELOAD] ProjectsPage REJECTED in 5310ms: EnvironmentTeardownError: …
   ```
   6 个全部由计时器臂胜出,随后在 vitest 拆除环境时才以 EnvironmentTeardownError
   告终 ⇒ 它一个页面都没预热成功,`beforeAll` 的 30s 预算也从来没用上。

3. **代价是平的、可量的。** 同一个 38 行 / 2 例的最小文件:
   | | Duration | 其中 tests 桶 |
   |---|---|---|
   | 改动前 | 8.16s | **5.28s** |
   | 改动后 | **3.36s** | **0.32s** |
   `real` 9.4s → 4.5s。5.28s ≈ 那个 5s 计时器 + 收尾,与 CI 侧对 169 个逐文件
   耗时做 OLS 得到的 `sec ≈ 4.383 + 0.0327·n_tests`(93% 是每文件固定成本、
   1 例的文件也要 5.01~5.32s)两个互不相关的估计器互证。

安全性(不是「删了没红」而是「删了不可能改变行为」):既然 6 个 import 从来没有
在计时器之前完成,那么现有全绿套件本来就是在「预热无效」的状态下跑出来的;被删掉
的只是一段必然超时的等待。页面在测试里是通过静态 import 渲染的(如
`hubPages.test.tsx` 直接 import `AgentHubWorkbench`),没有任何用例在等一个 lazy
边界。

全量复跑(本机 4 核 ARM,无 coverage):**169 文件 / 1714 例全绿**,
`Duration 305.37s (transform 72.44s, setup 115.00s, import 591.46s, tests 159.96s,
environment 290.33s)`。对照 CI 侧同套件改动前的
`Duration 415.77s (… tests 796.77s …)`:**tests 聚合桶 796.77s → 159.96s**,
即 ~637s 的聚合计时器等待消失(本机 import/transform 比 CI 慢,故 wall 数字不与
CI 直接可比,桶差值才是可迁移的量;按 CI 实测并发度 3.86 折算 ≈ −165s wall)。

刻意没做:没有改成「不 await 只 fire」的折中形态。那种写法要成立,前提是真有一个
lazy 边界需要预热,而前提已被证据 1 否掉;留一个不 await 的 import 只会把一段死
机制换成一段更难解释的死机制。注释里写清了「若将来页面真的重新变 lazy,修法是在
渲染它的用例里 await(findBy*/waitFor),而不是加一个全局计时器」。

Refs #2251(slice 3:workbench coverage 是 frontend PR 的唯一长杆,#2299 的真实
run 里该 job = 7m20s,第二名 windows-frontend 3m45s)。本 PR 不动
`.github/workflows/checks.yml`、不动任何门禁阈值、不动 coverage.include ⇒ 门禁强度
不变,只是不再为一段死机制付 5s×169 的税。

Co-authored-by: Cursor <cursor@vectorcontrol.tech>
DeliciousBuding added a commit that referenced this pull request Sep 3, 2026
…限透传 hasMore),并修掉 agent profiles 在两个 shell 都只取第一页的同类缺陷——web 钉死 pageSize=50 丢 cursor、desktop 连参数都不传还把整个 page 对象扔掉 ⇒ 第 51 个 agent profile 静默不显示(#2290 同族)

为什么是抽公共契约而不是再抄一遍循环:本仓已经有 **4 份**手抄的同一个游标循环
(web/desktop 的 fetchExecutionTargets 各一份,加上上一发 #2299 给 projects 补的
web/desktop 各一份)。第 5、第 6 份正在路上——本次排查发现 agent profiles 两个
shell 也漏,public skills / public MCP servers / documents 同样只取第一页。
每份手抄都要各自记住「hasMore 撞上限时要透传」「没有 nextCursor 就不许自己编一个」,
而这正是缺陷的成因。所以把契约收进 `app/shared/src/hub/paginate.ts`:

- `HUB_LIST_PAGE_SIZE = 200`:通用游标列表端点自己声明、查询层实际执行的天花板
  (`config.MaxListPageSize`;`agent_profile.go:154`、`market.go:49`、
  `workspace.go:88` 都是 `ClampPageSize(..., MaxListPageSize, ...)`)。
- `HUB_LIST_MAX_PAGES = 5` ⇒ 1000 条上限;**撞上限时返回 `hasMore: true`**,
  让「被截断」与「本来就这么多」可区分。上限是明示的天花板,不是静默的。
- 只用服务端给的 `nextCursor` 续页;`hasMore=true` 但无 cursor 时**停止**,
  不自己编游标、不空转。
- 错误**不吞**:某一页失败就是请求失败,交给调用方的 react-query 处理重试与错误面。
  在这里吞掉会把 401 变成「列表只有 1 条」,正是本契约要消灭的故障形态。
- 端点天花板更低的(messages 家族 = 100)自己传显式值,不继承默认。

本次转换的 4 个 live 调用点(projects 两份由手抄循环改为调用契约,行为逐字不变;
agent profiles 两份是**修缺陷**):

| 调用点 | 改动前 | 改动后 |
|---|---|---|
| `web/src/api/projectQueries.ts` | 手抄循环(#2299) | `fetchAllPages(client.listWorkspaceProjects)` |
| `desktop/src/api/hubQueries.ts` | 手抄循环(#2299) | 同上 |
| `web/src/api/agentQueries.ts:301` | `listAgentProfiles({ pageSize: 50 })`,丢 cursor | `fetchAllPages(client.listAgentProfiles)` |
| `desktop/src/api/agentProfileQueries.ts:145` | `listAgentProfiles()` 无参 + `res.items ?? []`(整个 `page` 扔掉) | `fetchAllPages(...)`,hook 的 items-only 对外形状不变 |

测试:
- `shared/src/hub/paginate.test.ts` 9 例(契约本身):单页原样返回、按 cursor 走满
  三页并逐字断言每次入参、`hasMore=true` 但无 cursor 时只调一次、撞上限透传
  `hasMore`、默认值等于 200/5、显式更小天花板、`items`/`page` 缺失时不炸、
  **页错误必须冒泡**(`rejects.toBe(failure)`)、`maxPages<=0` 按一页处理不死循环。
- web `agentQueries.test.ts` 新增走页断言(两次调用的 URL 逐字:`?pageSize=200`、
  `?pageSize=200&pageCursor=cur-1`,两页 items 都在结果里)。
- desktop 新增 `agentProfileQueries.hub.test.tsx`:renderHook 驱动真 QueryClient,
  断言第 1 次调用**必须带 pageSize**(无参 = 服务端默认,正是藏住列表尾部的原因)、
  第 2 次带 cursor、hook 数据含两页。
- 期望红一条既有测试并已就地更新:`agentQueries.test.ts:96` 的 `?pageSize=50` →
  `?pageSize=200`(它钉的是旧契约本身)。

本地门禁:`@agenthub/shared` 全量 **151 文件 / 2755 例全绿**;web `src/api`+`src/platform`
**20/198**;desktop `src/api`+`src/platform` **24/207**(含新增两例);三个包
typecheck 干净。

**如实登记本次未修的同类残项**(已排查、已定位、有现成契约可用,不夹带进本 PR):
`listPublicSkills`(`web/src/App.tsx:115`、`desktop/src/App.tsx:235`)、
`listPublicMCPServers`(`web/src/App.tsx:124`、`desktop/src/App.tsx:244`)都是无参
调用 ⇒ 同样只取第一页;`listDocuments`(`web/src/platform/useWebWorkbenchModel.ts:327`
无参;desktop 侧传 params 待逐个核);`listAuditEvents` 生产侧 **0 调用点**(非 live
路径,不报);`searchMessages` 由 UI 显式传 params,属另一形态。两个 shell 的
`fetchExecutionTargets` 仍是手抄循环(行为正确,只是重复),按仓库纪律不在本 PR
顺手改(#2261 的负向约束也点名不许顺手动它)。

Refs #2290(同类残项已并入该 issue 登记)。

Co-authored-by: Cursor <cursor@vectorcontrol.tech>
DeliciousBuding added a commit that referenced this pull request Sep 3, 2026
…nchRoutes 是静态 import),且它自己从来没赢过那场 5s 竞速(6 个 import 全部超时、随后 EnvironmentTeardownError),却给本包 169 个测试文件的每一个 beforeAll 平白加上 ~5s(#2251 slice 3 实测最长杆 7m20s 的 37~40%) (#2300)

三条独立证据,主机全部实测(不是推断):

1. **前提是假的。** `workbench/src/WorkbenchRoutes.tsx:26` 原文:
   `// ── Static page imports (no React.lazy — lazy 在 jsdom 测试中无法同步解析) ──`
   ⇒ 生产代码早就把 React.lazy 换成了静态 import,而且注释写明换的原因正是
   jsdom 下 lazy 无法同步解析。setup.ts 里那段注释仍在声称「WorkbenchRoutes
   wraps every page component in React.lazy() + Suspense」,并据此预热 6 个
   `import('../pages/*')`。全仓复核:`workbench/src` 与 `shared/src` 非测试代码里
   `lazy(`/`Suspense` 只剩 `shared/src/ui/CodeBlock.tsx` 的高亮器(与这 6 个页面
   无关),`WorkbenchRoutes*` 只有 1 个文件、其中 0 处 lazy ⇒ 被预热的那个
   Suspense 边界不存在。属 #2246「注释声称被活代码推翻」同族。

2. **机制本身从来没生效。** 就地插桩(测量后已还原,`git status` 干净)跑最小
   测试文件,stderr 直出:
   ```
   [PRELOAD] ProjectsPage  TIMEOUT arm won at 5000ms
   [PRELOAD] ContactsPage  TIMEOUT arm won at 5000ms
   [PRELOAD] DocsPage      TIMEOUT arm won at 5000ms
   [PRELOAD] AgentsPage    TIMEOUT arm won at 5000ms
   [PRELOAD] TasksPage     TIMEOUT arm won at 5000ms
   [PRELOAD] SettingsPage  TIMEOUT arm won at 5000ms
   [PRELOAD] DocsPage     REJECTED in 5309ms: EnvironmentTeardownError: Cannot load '…/mdast-util-to-string/index.js' …
   [PRELOAD] ProjectsPage REJECTED in 5310ms: EnvironmentTeardownError: …
   ```
   6 个全部由计时器臂胜出,随后在 vitest 拆除环境时才以 EnvironmentTeardownError
   告终 ⇒ 它一个页面都没预热成功,`beforeAll` 的 30s 预算也从来没用上。

3. **代价是平的、可量的。** 同一个 38 行 / 2 例的最小文件:
   | | Duration | 其中 tests 桶 |
   |---|---|---|
   | 改动前 | 8.16s | **5.28s** |
   | 改动后 | **3.36s** | **0.32s** |
   `real` 9.4s → 4.5s。5.28s ≈ 那个 5s 计时器 + 收尾,与 CI 侧对 169 个逐文件
   耗时做 OLS 得到的 `sec ≈ 4.383 + 0.0327·n_tests`(93% 是每文件固定成本、
   1 例的文件也要 5.01~5.32s)两个互不相关的估计器互证。

安全性(不是「删了没红」而是「删了不可能改变行为」):既然 6 个 import 从来没有
在计时器之前完成,那么现有全绿套件本来就是在「预热无效」的状态下跑出来的;被删掉
的只是一段必然超时的等待。页面在测试里是通过静态 import 渲染的(如
`hubPages.test.tsx` 直接 import `AgentHubWorkbench`),没有任何用例在等一个 lazy
边界。

全量复跑(本机 4 核 ARM,无 coverage):**169 文件 / 1714 例全绿**,
`Duration 305.37s (transform 72.44s, setup 115.00s, import 591.46s, tests 159.96s,
environment 290.33s)`。对照 CI 侧同套件改动前的
`Duration 415.77s (… tests 796.77s …)`:**tests 聚合桶 796.77s → 159.96s**,
即 ~637s 的聚合计时器等待消失(本机 import/transform 比 CI 慢,故 wall 数字不与
CI 直接可比,桶差值才是可迁移的量;按 CI 实测并发度 3.86 折算 ≈ −165s wall)。

刻意没做:没有改成「不 await 只 fire」的折中形态。那种写法要成立,前提是真有一个
lazy 边界需要预热,而前提已被证据 1 否掉;留一个不 await 的 import 只会把一段死
机制换成一段更难解释的死机制。注释里写清了「若将来页面真的重新变 lazy,修法是在
渲染它的用例里 await(findBy*/waitFor),而不是加一个全局计时器」。

Refs #2251(slice 3:workbench coverage 是 frontend PR 的唯一长杆,#2299 的真实
run 里该 job = 7m20s,第二名 windows-frontend 3m45s)。本 PR 不动
`.github/workflows/checks.yml`、不动任何门禁阈值、不动 coverage.include ⇒ 门禁强度
不变,只是不再为一段死机制付 5s×169 的税。

Co-authored-by: DeliciousBuding <DeliciousBuding@users.noreply.github.com>
Co-authored-by: Cursor <cursor@vectorcontrol.tech>
DeliciousBuding added a commit that referenced this pull request Sep 3, 2026
…限透传 hasMore),并修掉 agent profiles 在两个 shell 都只取第一页的同类缺陷——web 钉死 pageSize=50 丢 cursor、desktop 连参数都不传还把整个 page 对象扔掉 ⇒ 第 51 个 agent profile 静默不显示(#2290 同族)

为什么是抽公共契约而不是再抄一遍循环:本仓已经有 **4 份**手抄的同一个游标循环
(web/desktop 的 fetchExecutionTargets 各一份,加上上一发 #2299 给 projects 补的
web/desktop 各一份)。第 5、第 6 份正在路上——本次排查发现 agent profiles 两个
shell 也漏,public skills / public MCP servers / documents 同样只取第一页。
每份手抄都要各自记住「hasMore 撞上限时要透传」「没有 nextCursor 就不许自己编一个」,
而这正是缺陷的成因。所以把契约收进 `app/shared/src/hub/paginate.ts`:

- `HUB_LIST_PAGE_SIZE = 200`:通用游标列表端点自己声明、查询层实际执行的天花板
  (`config.MaxListPageSize`;`agent_profile.go:154`、`market.go:49`、
  `workspace.go:88` 都是 `ClampPageSize(..., MaxListPageSize, ...)`)。
- `HUB_LIST_MAX_PAGES = 5` ⇒ 1000 条上限;**撞上限时返回 `hasMore: true`**,
  让「被截断」与「本来就这么多」可区分。上限是明示的天花板,不是静默的。
- 只用服务端给的 `nextCursor` 续页;`hasMore=true` 但无 cursor 时**停止**,
  不自己编游标、不空转。
- 错误**不吞**:某一页失败就是请求失败,交给调用方的 react-query 处理重试与错误面。
  在这里吞掉会把 401 变成「列表只有 1 条」,正是本契约要消灭的故障形态。
- 端点天花板更低的(messages 家族 = 100)自己传显式值,不继承默认。

本次转换的 4 个 live 调用点(projects 两份由手抄循环改为调用契约,行为逐字不变;
agent profiles 两份是**修缺陷**):

| 调用点 | 改动前 | 改动后 |
|---|---|---|
| `web/src/api/projectQueries.ts` | 手抄循环(#2299) | `fetchAllPages(client.listWorkspaceProjects)` |
| `desktop/src/api/hubQueries.ts` | 手抄循环(#2299) | 同上 |
| `web/src/api/agentQueries.ts:301` | `listAgentProfiles({ pageSize: 50 })`,丢 cursor | `fetchAllPages(client.listAgentProfiles)` |
| `desktop/src/api/agentProfileQueries.ts:145` | `listAgentProfiles()` 无参 + `res.items ?? []`(整个 `page` 扔掉) | `fetchAllPages(...)`,hook 的 items-only 对外形状不变 |

测试:
- `shared/src/hub/paginate.test.ts` 9 例(契约本身):单页原样返回、按 cursor 走满
  三页并逐字断言每次入参、`hasMore=true` 但无 cursor 时只调一次、撞上限透传
  `hasMore`、默认值等于 200/5、显式更小天花板、`items`/`page` 缺失时不炸、
  **页错误必须冒泡**(`rejects.toBe(failure)`)、`maxPages<=0` 按一页处理不死循环。
- web `agentQueries.test.ts` 新增走页断言(两次调用的 URL 逐字:`?pageSize=200`、
  `?pageSize=200&pageCursor=cur-1`,两页 items 都在结果里)。
- desktop 新增 `agentProfileQueries.hub.test.tsx`:renderHook 驱动真 QueryClient,
  断言第 1 次调用**必须带 pageSize**(无参 = 服务端默认,正是藏住列表尾部的原因)、
  第 2 次带 cursor、hook 数据含两页。
- 期望红一条既有测试并已就地更新:`agentQueries.test.ts:96` 的 `?pageSize=50` →
  `?pageSize=200`(它钉的是旧契约本身)。

本地门禁:`@agenthub/shared` 全量 **151 文件 / 2755 例全绿**;web `src/api`+`src/platform`
**20/198**;desktop `src/api`+`src/platform` **24/207**(含新增两例);三个包
typecheck 干净。

**如实登记本次未修的同类残项**(已排查、已定位、有现成契约可用,不夹带进本 PR):
`listPublicSkills`(`web/src/App.tsx:115`、`desktop/src/App.tsx:235`)、
`listPublicMCPServers`(`web/src/App.tsx:124`、`desktop/src/App.tsx:244`)都是无参
调用 ⇒ 同样只取第一页;`listDocuments`(`web/src/platform/useWebWorkbenchModel.ts:327`
无参;desktop 侧传 params 待逐个核);`listAuditEvents` 生产侧 **0 调用点**(非 live
路径,不报);`searchMessages` 由 UI 显式传 params,属另一形态。两个 shell 的
`fetchExecutionTargets` 仍是手抄循环(行为正确,只是重复),按仓库纪律不在本 PR
顺手改(#2261 的负向约束也点名不许顺手动它)。

Refs #2290(同类残项已并入该 issue 登记)。

Co-authored-by: Cursor <cursor@vectorcontrol.tech>
DeliciousBuding added a commit that referenced this pull request Sep 3, 2026
… agent profiles 两个 shell 都只取第一页(第 51 个 profile 静默不显示)(#2290 同族) (#2302)

* fix(frontend): 把 Hub 游标分页收成一个共享契约 fetchAllPages(pageSize 200 × 5 页、撞上限透传 hasMore),并修掉 agent profiles 在两个 shell 都只取第一页的同类缺陷——web 钉死 pageSize=50 丢 cursor、desktop 连参数都不传还把整个 page 对象扔掉 ⇒ 第 51 个 agent profile 静默不显示(#2290 同族)

为什么是抽公共契约而不是再抄一遍循环:本仓已经有 **4 份**手抄的同一个游标循环
(web/desktop 的 fetchExecutionTargets 各一份,加上上一发 #2299 给 projects 补的
web/desktop 各一份)。第 5、第 6 份正在路上——本次排查发现 agent profiles 两个
shell 也漏,public skills / public MCP servers / documents 同样只取第一页。
每份手抄都要各自记住「hasMore 撞上限时要透传」「没有 nextCursor 就不许自己编一个」,
而这正是缺陷的成因。所以把契约收进 `app/shared/src/hub/paginate.ts`:

- `HUB_LIST_PAGE_SIZE = 200`:通用游标列表端点自己声明、查询层实际执行的天花板
  (`config.MaxListPageSize`;`agent_profile.go:154`、`market.go:49`、
  `workspace.go:88` 都是 `ClampPageSize(..., MaxListPageSize, ...)`)。
- `HUB_LIST_MAX_PAGES = 5` ⇒ 1000 条上限;**撞上限时返回 `hasMore: true`**,
  让「被截断」与「本来就这么多」可区分。上限是明示的天花板,不是静默的。
- 只用服务端给的 `nextCursor` 续页;`hasMore=true` 但无 cursor 时**停止**,
  不自己编游标、不空转。
- 错误**不吞**:某一页失败就是请求失败,交给调用方的 react-query 处理重试与错误面。
  在这里吞掉会把 401 变成「列表只有 1 条」,正是本契约要消灭的故障形态。
- 端点天花板更低的(messages 家族 = 100)自己传显式值,不继承默认。

本次转换的 4 个 live 调用点(projects 两份由手抄循环改为调用契约,行为逐字不变;
agent profiles 两份是**修缺陷**):

| 调用点 | 改动前 | 改动后 |
|---|---|---|
| `web/src/api/projectQueries.ts` | 手抄循环(#2299) | `fetchAllPages(client.listWorkspaceProjects)` |
| `desktop/src/api/hubQueries.ts` | 手抄循环(#2299) | 同上 |
| `web/src/api/agentQueries.ts:301` | `listAgentProfiles({ pageSize: 50 })`,丢 cursor | `fetchAllPages(client.listAgentProfiles)` |
| `desktop/src/api/agentProfileQueries.ts:145` | `listAgentProfiles()` 无参 + `res.items ?? []`(整个 `page` 扔掉) | `fetchAllPages(...)`,hook 的 items-only 对外形状不变 |

测试:
- `shared/src/hub/paginate.test.ts` 9 例(契约本身):单页原样返回、按 cursor 走满
  三页并逐字断言每次入参、`hasMore=true` 但无 cursor 时只调一次、撞上限透传
  `hasMore`、默认值等于 200/5、显式更小天花板、`items`/`page` 缺失时不炸、
  **页错误必须冒泡**(`rejects.toBe(failure)`)、`maxPages<=0` 按一页处理不死循环。
- web `agentQueries.test.ts` 新增走页断言(两次调用的 URL 逐字:`?pageSize=200`、
  `?pageSize=200&pageCursor=cur-1`,两页 items 都在结果里)。
- desktop 新增 `agentProfileQueries.hub.test.tsx`:renderHook 驱动真 QueryClient,
  断言第 1 次调用**必须带 pageSize**(无参 = 服务端默认,正是藏住列表尾部的原因)、
  第 2 次带 cursor、hook 数据含两页。
- 期望红一条既有测试并已就地更新:`agentQueries.test.ts:96` 的 `?pageSize=50` →
  `?pageSize=200`(它钉的是旧契约本身)。

本地门禁:`@agenthub/shared` 全量 **151 文件 / 2755 例全绿**;web `src/api`+`src/platform`
**20/198**;desktop `src/api`+`src/platform` **24/207**(含新增两例);三个包
typecheck 干净。

**如实登记本次未修的同类残项**(已排查、已定位、有现成契约可用,不夹带进本 PR):
`listPublicSkills`(`web/src/App.tsx:115`、`desktop/src/App.tsx:235`)、
`listPublicMCPServers`(`web/src/App.tsx:124`、`desktop/src/App.tsx:244`)都是无参
调用 ⇒ 同样只取第一页;`listDocuments`(`web/src/platform/useWebWorkbenchModel.ts:327`
无参;desktop 侧传 params 待逐个核);`listAuditEvents` 生产侧 **0 调用点**(非 live
路径,不报);`searchMessages` 由 UI 显式传 params,属另一形态。两个 shell 的
`fetchExecutionTargets` 仍是手抄循环(行为正确,只是重复),按仓库纪律不在本 PR
顺手改(#2261 的负向约束也点名不许顺手动它)。

Refs #2290(同类残项已并入该 issue 登记)。

Co-authored-by: Cursor <cursor@vectorcontrol.tech>

* fix(frontend): 补齐同族残项——public skills / public MCP servers / documents 三处 live 路径同样只取第一页并丢掉 nextCursor(4 个调用点、两个 shell),改用 fetchAllPages;desktop 的 useDocumentList 保留调用方过滤器(#2290 残项)

上一发把游标分页收成了共享契约 `@shared/hub/paginate`,并在 issue 里枚举了同族残项。
本发把枚举到的**全部 live 调用点**转换完,Objective「消费端必须真正把所有页走通」
在 Hub 游标列表这一族上闭合。

逐个调用点的改动前实况(都是无参或只传过滤器 ⇒ 服务端默认页 + cursor 丢弃):

| 调用点 | 改动前 | 消费方 |
|---|---|---|
| `web/src/App.tsx:115` | `hubClientForConversations.listPublicSkills()` | `skillMarketQuery.data?.items` |
| `web/src/App.tsx:124` | `…listPublicMCPServers()` | `mcpMarketQuery.data?.items` |
| `desktop/src/App.tsx:235` | `hubClient.listPublicSkills()` | 同上(desktop) |
| `desktop/src/App.tsx:244` | `hubClient.listPublicMCPServers()` | 同上(desktop) |
| `web/src/platform/useWebWorkbenchModel.ts:327` | `hubClient.listDocuments()` | Documents 页(#2154 刚接上的真实数据面) |
| `desktop/src/api/documentQueries.ts:24` | `listDocuments(params)`,唯一调用方 `desktop/src/App.tsx:198` 传 `undefined` | `documentListData.items` |

四个客户端方法的签名都本来就支持游标(`shared/src/hub/hubClient.ts:248-265` 的
`listPublicSkills` / `listPublicMCPServers` 各带 `pageCursor`+`pageSize`,
`hubClientApiExtended.ts:219-225` 的 `listDocuments` 同),返回类型都是
`HubListResponse<T> = { items, page: HubPageInfo }` ⇒ 转换后消费方的 `.items`
读取路径逐字不变,只是多了走页;`page.hasMore` 现在也是**诚实**的(撞上限时保持
true,调用方将来要做截断提示已有信号)。

`useDocumentList` 的形态需要特别处理:它的 `params` 是调用方过滤器
(status/source/tag),且 `queryKey` 含 params ⇒ 不能直接换成无参的
`fetchAllPages(client.listDocuments)`,而是
`fetchAllPages((page) => client.listDocuments({ ...params, ...page }))`:
过滤器保留、走页的 `pageSize`/`pageCursor` 覆盖在其上,参数化形态继续可用。

测试(新增 `desktop/src/api/documentQueries.test.tsx` 3 例):
- 走满游标:两次调用的入参逐字(第 1 次**必须带 pageSize**,无参 = 服务端默认,
  正是藏住列表尾部的原因;第 2 次带 `pageCursor: 'cur-1'`),两页 items 都在结果里,
  `page.hasMore` 为 false。
- 过滤器保真:`{status:'active', tag:'runbook'}` 与走页参数合并后逐字断言。
- 上限可见:服务端一直 `hasMore:true` 时恰好调 5 次(= `HUB_LIST_MAX_PAGES`)、
  items 5 条、`page.hasMore` 仍为 true。

**如实登记测试覆盖的边界**:web/desktop 两个 `App.tsx` 里的 4 个 market/documents
queryFn 是**组件内联**的,没有专属单测;要钉住它们要么把取数抽到 api 层(本发不扩大
写集),要么渲染整个 App(既有 App 测试用模块级 mock 替换了这些 hook,不经过真
queryFn)。因此这 4 处的保证来自:① 共享契约自身 9 例(`paginate.test.ts`);
② 与已钉住的 `useDocumentList` / projects / agent profiles 完全同形的一行调用;
③ 本发暗卷。抽到 api 层以便直接测试,记为后续(纯结构,不改行为)。

本地门禁:web + desktop typecheck 干净;web `src/platform`+`src/api` **20 文件/199 例**
全绿;desktop `src/api`+`src/__tests__` **52 文件/524 例**全绿(含新增 3 例)。

Refs #2290(同族残项据此关闭)

Co-authored-by: Cursor <cursor@vectorcontrol.tech>

---------

Co-authored-by: DeliciousBuding <DeliciousBuding@users.noreply.github.com>
Co-authored-by: Cursor <cursor@vectorcontrol.tech>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant