Skip to content

多行选择从未投递进 action input:REST 无任何合法形状携带 selectedIds,console 工具栏在客户端就拦下多行(17.0.0-rc.2) #5568

Description

@yinlianghui

Part of objectstack-ai/hotcrm#508

现象

一个在列表视图上按 bulk 语义接线的 script action(hotcrm 的 mass_update_stage,opportunity 列表),在 17.0.0-rc.2 上不存在任何能把多行选择送达 action input 的派发路径。input.selectedIds 在所有已知形状下都为空。

证据(hotcrm@0899b4f + @objectstack/* 17.0.0-rc.2,独立 dev server、全新种子库、admin token)

# A. 顶层 selectedIds(2 行)
POST /api/v1/actions/crm_opportunity/mass_update_stage
     {"selectedIds":["22EA…","3DD_…"],"params":{"stage":"needs_analysis"}}
→ 400 {"code":"VALIDATION_ERROR","message":"mass_update_stage: no opportunity selected"}
     (顶层键未投递进 body 的 input,action 自己的「未选中」校验命中)

# B. params.selectedIds
→ 400 "Invalid action params: Unknown action param \"selectedIds\" — not declared on this action"
     (新参数校验器按声明拒绝,选择集不属于 declared params——它本该走 input,而 input 收不到)

# C. Console 工具栏,选中 2 行后填参提交
→ 客户端红 toast「This action runs on a single record — select exactly one row.」,零网络请求
     (选择集根本不出客户端)

每次尝试后 re-GET 记录:stage 保持不变。失败均为显式 400 / 显式 toast,rc.0 时代的假成功路径已不存在(那半已修,好)。

对照组:单行 {"recordId":…,"params":{…}} 派发能到达服务器并执行到 action body(hotcrm 侧 body 内另有一个与本单无关的签名缺陷,hotcrm 自修)。即 recordId 通道是通的,缺的只是多行选择这一通道。

期望

以 bulk 语义接线的 action 至少有一条有文档的派发形状能把当前选择集送达 action input(例如 input.selectedIds):REST 一条 + console 选择栏/工具栏一条。

关联

  • hotcrm 侧的选择栏 bulk 按钮在 rc.0 假成功时代被 app 侧摘除(hotcrm#588);平台交付多选投递后,hotcrm 会恢复 opportunity.view.ts 的 bulkActions 一行并以「选择栏 POST 携带 selectedIds」为验收条件(记录在 hotcrm#508)。
  • 三轮 rc.2 复核证据(API 全形状 + 浏览器走查 + 截图/抓包文件)见 hotcrm#508 的 2026-08-05 评论。

Activity

  1. os-zhuang commented on Aug 5, 2026

    @os-zhuang
    Contributor

    分诊:保留 pm:queue,补 domain:cli,范围重框为验证单 —— 过时前提检查发现标题主张与 17.0.0-rc.2 已声明的契约冲突。

    • 「REST 无任何合法形状携带 selectedIds」与 rc.2 spec 不符:声明的多选投递形状是 params._selectedIds(带下划线的内建键),由列表视图的 bulkActions / bulkActionDefs 驱动 —— execution: 'aggregate' 的 def 一次调用携带整个选择集,渲染器注入、参数门放行:
      • packages/spec/src/ui/action-params.zod.ts:63-70(ACTION_PARAM_BUILTIN_KEYS 含 _selectedIds)、bulk-action.zod.ts:205、view.zod.ts:1193,均在 rc.2 CHANGELOG 节内(objectui#3139 配套);
      • liveness 台账 packages/spec/liveness/view.json:155:objectui 侧 useBulkExecutor 默认 per-record fan-out + ObjectGrid.runBulkActionAggregate 的 aggregate 分支(verified objectui@4bf612c)。
    • 三条证据测的都是未声明形状:A 顶层 selectedIds;B params.selectedIds(无下划线 —— ADR-0104 严格参数门按声明拒绝,该门在正常工作);C 的单行 toast 与 hotcrm#588 摘除自家 bulkActions 行自洽 —— 多选工具栏本就由视图的 bulkActions 驱动(action.zod.ts POST /data/sharing/rules 绕过 SharingRuleSchema:criteria 缺失或拼错静默变成"共享该对象全部记录",与 ADR-0049 "never seeded as a permissive match-all" 直接冲突 #3896 tombstone 亦写明)。
    • 重框后的派发范围:在 rc.2 pin 下端到端验证声明路径 —— 视图挂 bulkActionDefs(execution: 'aggregate')→ console 多选 → 一次 POST 携带 params._selectedIds → action handler 从 ctx.params._selectedIds 读到。绿 → 以证据关单,并在来源单指路声明形状(app 侧恢复 bulkActions 行、handler 改读 params._selectedIds);红 → 按实测落点修(若落 console 渲染器侧,上报改路由 repo:objectui)。⛔ 勿另造新的 selectedIds 通道 —— 契约已有一条。

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  2. self-assigned this
    on Aug 5, 2026
  3. baozhoutao commented on Aug 5, 2026

    @baozhoutao
    Contributor

    认领(PM 会话 session_016FNvXhtSdnEGEfLEsMmvxh,cli 车道,v17-rc2 优先,按分诊 19:59Z 重框为验证单派发)

    • 分支:claude/issue-5568-selectedids-declared-path(仅在需要修复时使用)
    • 工作树:../objectstack-5568(os-dev 自建)
    • 范围锚定分诊:在 rc.2 pin 下端到端验证声明通道——视图挂 bulkActionDefs(execution: 'aggregate')→ console 多选 → 单次 POST 携带 params._selectedIds → handler 从 ctx.params._selectedIds 读到。绿 → 以证据关单并在 hotcrm#508 指路声明形状;红 → 按实测落点修,console 渲染器侧则上报改路由 objectui。⛔ 勿另造新通道。

    Generated by Claude Code

  4. baozhoutao commented on Aug 5, 2026

    @baozhoutao
    Contributor

    开工(os-dev,PM 会话 session_016FNvXhtSdnEGEfLEsMmvxh 派发,cli 车道)

    • 工作树:../objectstack-5568,分支 claude/issue-5568-selectedids-declared-path(验证单,仅在实测红且落点在本仓时才提交代码)
    • 按分诊 19:59Z 重框执行:先证后修。在 rc.2 语义下端到端验证声明通道 params._selectedIds(内建下划线键,packages/spec/src/ui/action-params.zod.ts:63-70),REST 半边为主、console 半边尽力驱动。
    • 结论无论绿红都回评完整证据链;不关单,留 PM 复核。

    Generated by Claude Code

  5. baozhoutao commented on Aug 5, 2026

    @baozhoutao
    Contributor

    结论:绿。前提不成立 —— 声明通道 params._selectedIds 在 rc.2 上工作正常,报告的三条证据测的都是未声明形状。不写平台代码。

    分诊 19:59Z 的重框经真机端到端验证成立。以下是完整证据链。

    验证环境

    • @objectstack/spec@17.0.0-rc.2,worktree 基于 origin/main @ a7b854f19
    • 真机:pnpm dev:crm -- --fresh -p 41973 --seed-admin(全新种子库、独立端口、admin token)
    • fixture:在 app-crm 上逐字复刻 hotcrm 的形状 —— crm_opportunity 上一个 type: 'script' 的 action(声明 params: [probability],body 读 input._selectedIds 并逐个 update),列表视图挂 bulkActionDefs: [{ name, operation: 'custom', execution: 'aggregate' }]。fixture 为临时探针,验证后已撤销,工作树干净。
    • 写入字段选 probability 而非 stage:opp_stage_transitions 是 stage 上的 state_machine 校验规则,非法跳转会因为与本单无关的理由拒绝写入,会污染证据。probability 是普通 percent 字段,写入落地即为无歧义证据。

    REST 半边 —— 四种形状同一 action、同一会话对照

    基线(两行 probability 均为 100):

    1ByPWM74NPmmpfhx  Acme — Support Tier Upgrade      probability=100
    1PmVs6D5fMin_XT2  Globex — Year-Ago Implementation probability=100
    

    A. 顶层 selectedIds(报告形状 A)

    POST /api/v1/actions/crm_opportunity/crm_mass_update_stage
    {"selectedIds":["1ByPWM74NPmmpfhx","1PmVs6D5fMin_XT2"],"params":{"probability":42}}
    → HTTP 400
    {"success":false,"error":{"code":"VALIDATION_ERROR","message":"mass_update_stage: no opportunity selected","httpStatus":400}}
    

    顶层键不进 params bag,body 自己的「未选中」守卫命中 —— 与报告的 A 条一字不差地复现了。

    B. params.selectedIds,无下划线(报告形状 B)

    {"params":{"selectedIds":[...],"probability":42}}
    → HTTP 400
    {"success":false,"error":{"code":"VALIDATION_ERROR","message":"Invalid action params: Unknown action param \"selectedIds\" — not declared on this action","httpStatus":400}}
    

    同样逐字复现。ADR-0104 严格参数门按声明拒绝未声明键,该门在正常工作 —— selectedIds(无下划线)不是内建键。

    D. 声明通道 params._selectedIds

    {"params":{"_selectedIds":["1ByPWM74NPmmpfhx","1PmVs6D5fMin_XT2"],"probability":42}}
    → HTTP 200
    {"success":true,"data":{"ok":true,
      "seenSelectedIds":["1ByPWM74NPmmpfhx","1PmVs6D5fMin_XT2"],
      "updated":2,
      "touched":["1ByPWM74NPmmpfhx","1PmVs6D5fMin_XT2"]}}
    

    参数门放行内建键(_selectedIds 与已声明的 probability 同时在 bag 里,无冲突),handler 读到了两个 id。

    写入生效 —— re-GET 两行:

    {"id":"1ByPWM74NPmmpfhx","probability":42,"updated_at":"2026-08-05T20:40:42.784Z"}
    {"id":"1PmVs6D5fMin_XT2","probability":42,"updated_at":"2026-08-05T20:40:42.805Z"}
    

    100 → 42,两行都写进去了。

    「单次调用」的服务端佐证 —— 整段验证服务端只打出 2 条 action-audit 行(A 条到达 handler 后被 body 守卫抛出;B 条在参数门就被拒,未到 handler;D 条 1 条):

    [action-audit] REST action 'crm_opportunity/crm_mass_update_stage' — body executes TRUSTED ...
    [action-audit] REST action 'crm_opportunity/crm_mass_update_stage' — body executes TRUSTED ...
    

    即 D 条是一次派发、两行写入,符合 aggregate 契约。

    平台侧代码路径(file:line)

    • packages/spec/src/ui/action-params.zod.ts:70 — ACTION_PARAM_BUILTIN_KEYS = ['recordId','objectName','_selectedIds'],validateActionParams 的 allow 集合放行之。
    • packages/runtime/src/domains/actions.ts:291 — 参数门 enforceActionParams 在 handler 前执行;:346 — params: { ...reqParams, recordId, objectName },即 _selectedIds 原样进入 ctx.params。
    • packages/runtime/src/sandbox/body-runner.ts:338 — input: unwrapProxyToPlain(actionCtx?.params ?? {})。这条值得单独点出:script body 里的 input 就是 params bag 本身,所以声明键在 body 内的读法是 input._selectedIds(等价于 ctx.params._selectedIds)。报告里「input.selectedIds 在所有已知形状下都为空」——input 的确是对的容器,少的只是那个下划线。

    console 半边 —— 说明:未浏览器驱动,以 objectui 源码 + 专用回归用例为证

    本工作树与共享 checkout 均无 packages/console/dist(启动日志:No console dist … skipping SHA check),objectui 亦未安装依赖,构建 + 浏览器驱动成本过高,故如实说明:console 半边未真机驱动。以下为源码级直接证据(objectui@68b6a28,读取未执行):

    • packages/plugin-grid/src/ObjectGrid.tsx:1979 — params: { ...params, _selectedIds: ids },渲染器注入点。
    • packages/plugin-grid/src/hooks/useBulkExecutor.ts:112,261 — aggregate 分支走单次 bulkCall。
    • 报告形状 C 的成因就在这里 —— packages/core/src/actions/serverActionHandler.ts:169:
      const isAggregateDispatch = Array.isArray(params._selectedIds);
      if (recordId == null && !isAggregateDispatch) { … selected.length > 1 →
          return { error: 'This action runs on a single record — select exactly one row.' };
      
      多选守卫在 _selectedIds 存在时被显式绕过。报告 C 条弹出该 toast,恰恰证明 _selectedIds 没有被注入 —— 而它没被注入,是因为视图上没有 bulkActions / bulkActionDefs 声明(hotcrm#588 已把那一行摘除),按钮退化成普通单记录 action,于是被这个本就为它设计的守卫拦在客户端。零网络请求是正确行为,不是通道缺失。
    • 两条专用回归用例已钉死该形状:useConsoleActionRuntime.test.tsx:958(aggregate 派发通过多选守卫且不带 recordId)、objectBulkActionDispatch.test.tsx:239(「issues exactly ONE request carrying every selected id under _selectedIds」)。
    • liveness 台账 packages/spec/liveness/view.json bulkActionDefs 行:status: live,Verified objectui@4bf612c。

    因果链复盘

    三条证据可由同一个原因完全解释,且都不是平台缺陷:

    1. hotcrm#588 摘除 opportunity.view.ts 的 bulk 声明行 →
    2. 渲染器无 aggregate def,不注入 params._selectedIds →
    3. 工具栏按钮成为单记录 action,多选被客户端守卫拦下(证据 C) →
    4. 转而手工试 REST,试的是顶层 selectedIds(证据 A)与无下划线 params.selectedIds(证据 B),二者都不是声明形状 → 两个正确的 400。

    hotcrm#508 指路(三处改动,均在 app 侧)

    1. 视图:opportunity.view.ts 恢复 bulk 声明。整个选择集一次送达,用 aggregate def:
      bulkActionDefs: [
        { name: 'mass_update_stage', operation: 'custom', execution: 'aggregate' },
      ]
      注意 operation: 'custom' 若不带 execution: 'aggregate',parse 阶段即被拒([spec] bulkActionDefs is z.record(z.any()) — type the def shape (ADR-0018 second-vocabulary smell) and cover it in reference-integrity lint #4457)—— 那种形状历史上是「N 个绿勾、零工作」的静默 no-op。若想要的是逐行派发,则改用 bulkActions: ['mass_update_stage'] 裸字符串形式。
    2. handler:改读 ctx.params._selectedIds(script body 内即 input._selectedIds),带前导下划线;不要试图把它声明进 action 的 params[],它是渲染器注入的内建键,声明不了也不需要声明。
    3. REST 调用(如脚本/集成侧直接打):{"params":{"_selectedIds":[id1,id2], …}},不要用顶层 selectedIds,也不要用无下划线的 params.selectedIds。

    另注:aggregate 结果是 all-or-nothing —— handler 覆盖不了整个选择集时应当整体拒绝,没有逐行重试。

    界外发现

    处置

    无平台代码改动,无 PR,工作树已撤销 fixture 并保持干净,dev server(:41973)已拆除。按派发约定不关单,留 PM 复核后关闭。


    Generated by Claude Code

  6. baozhoutao commented on Aug 5, 2026

    @baozhoutao
    Contributor

    验证结论:声明通道在 17.0.0-rc.2 上完全工作,标题主张不成立,关单(PM 会话 session_016FNvXhtSdnEGEfLEsMmvxh,cli 车道复核)

    真机端到端(app-crm 逐字复刻 hotcrm mass_update_stage 形状,crm_opportunity 列表视图挂 bulkActionDefs + execution:'aggregate'):

    形状 结果
    A 顶层 selectedIds(报告用) 400 VALIDATION_ERROR——逐字复现报告,未声明形状,正确拒绝
    B params.selectedIds 无下划线(报告用) 400 Unknown action param——ADR-0104 参数门正常工作
    D params._selectedIds(声明通道) 200,handler 读到两个 id,两行 probability 100→42 真实写入,服务端 action-audit 仅 1 条 = 单次派发双行写

    形状 C(console 单行 toast)成因同样定位:objectui serverActionHandler.ts:169 的多选守卫在 _selectedIds 存在时显式绕过——toast 弹出恰证明 _selectedIds 未被注入,而未注入是因为 hotcrm#588 摘除了视图的 bulk 声明行。三条证据一个 app 侧原因,均非平台缺陷。

    给 hotcrm#508 的指路(最有用的一行:script body 里 input 就是 params 袋,body-runner.ts:338,所以原写法只差一个下划线):

    1. opportunity.view.ts 恢复 bulkActions 行并挂 bulkActionDefs(execution: 'aggregate');
    2. handler 读 input._selectedIds;
    3. REST 直调用 params: { _selectedIds: [...] }。

    如实说明:console 半边未浏览器实跑(objectui 未安装/未构建,成本高),以源码锚点(objectui@68b6a28 ObjectGrid.tsx:1979 注入 / useBulkExecutor.ts:112,261 单次调用)+ objectui 两条专用回归用例 + liveness 台账 view.json status:live 为证。若需执行级验证,另立单拍成本。

    界外产出 #5622(交分诊):参数门拒绝「只差一个前导下划线」的内建键时不提示 _selectedIds 存在——报告者正是卡在这条消息上才判定通道不存在,纯消息层 DX 改进。

    平台侧无事可修,关单(works as declared)。感谢报告——它顺带暴露了 #5622 这个真实的指路缺口。


    Generated by Claude Code

  7. removed their assignment
    on Aug 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions