Skip to content

fix(core): ActionRunner.execute 的 disabled 门改问「有没有条件」,空谓词不再拦掉执行 (#3848) - #3873

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-3848-actionrunner-disabled-gate
Aug 8, 2026
Merged

fix(core): ActionRunner.execute 的 disabled 门改问「有没有条件」,空谓词不再拦掉执行 (#3848)#3873
yinlianghui merged 1 commit into
mainfrom
claude/issue-3848-actionrunner-disabled-gate

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes #3848

基线 origin/main = f0a625aa7b00c93a48329e1c456b1aeacfceba43(即 #3849 / PR #3861 落地那一提交)。文件面仅 packages/core

机理

ActionRunner.execute 的执行门问的是 action.disabled != null && action.disabled !== false,然后把原始值直接交给 evaluateCondition —— 而那个函数对「这里没有条件」只有一个默认答案:返回 true,含义是 visible/enabled。在 disabled 这个键上,true 的意思恰好相反,是「已禁用」。于是任何空谓词都被判成禁用:handler 一次不跑,调用方收到一个元数据从未声明过的 { success: false, error: 'Action is disabled' }

#3842 / #3849 修完渲染器两侧之后,这已经是活的用户可见行为:按钮能点了,点下去收到 Action is disabled。同一个谓词值,渲染器与执行入口给出相反答案 —— #3314 已经付过一次代价的形状。

逐形状新旧对照(计数 handler,同一张探针表跑基线与改后)

「拦」= handler 未被调用且返回 Action is disabled;「跑」= handler 被调用、success: true

disabled 基线(旧门) 改后 动作面渲染器(#3848 正文表)
'' 可点
' '(纯空白) 可点
{ dialect: 'cel', source: '' } 置灰(#3850 归属)
未声明 可点
false 可点
true 置灰
裸 CEL 取真 user.role == "admin" 置灰
裸 CEL 取假 user.role == "guest" 可点
{ dialect: 'cel', source: 'true' } 置灰
{ dialect: 'cel', source: 'false' } 可点
'${user.role === "admin"}'(模板取真) 置灰(#3871)
'${user.role === "guest"}'(模板取假) 置灰(#3871,该行渲染器错)
0(非谓词) 置灰(#3871 同族)
{}(非谓词) 置灰(#3871 同族)

变化只有加粗那五行,方向一律是「拦 → 放行」。

修法

新门先问「有没有条件可判」,再求值。「没有条件」的判定取自 core 唯一的谓词归一器 toPredicateInput(把 ''/null/空 source envelope/非谓词值映射为 undefined),外加纯空白字符串 —— 归一器对它是包裹而非折叠,而 evaluateCondition 自己就把空白 trimmed 叫做「没有条件」,evalRowPredicate(evaluator/listConditional.ts)用的也是这条 blank-source 规则。catch { isDisabled = false } 失败放行姿态原样保留。

为什么求值仍读原始值,而不是归一结果

派发时的优先形状是「先 toPredicateInput 归一,再据归一结果求值」。逐形状实测发现这个复合对已经是 ${…} 模板的字符串不成立:toPredicateInput 假定入参是裸表达式、无条件包裹,'${x}' 变成 '${${x}}',单模板快路 /^\$\{([^}]+)\}$/ 不再匹配,内层解析失败后 catch 里原样退回,Boolean(非空串) 恒为 true。也就是说那样求值的模板谓词永远「已禁用」,与表达式取值无关。实测:disabled: '${user.role === "guest"}'(admin context,取假)基线放行 → 归一求值后被拦。

这是本 PR 唯一偏离派发形状的地方:归一只用于判门,verdict 仍读原始值,于是每个非空形状的 verdict 逐一不变,本改动只可能少拦、不可能多拦。归一器的二次包裹缺陷另立 #3871(动作面渲染器与 ActionEngine 都以该复合书写,因此都中招;而 SchemaRendererpage:header 读原始值、各自有钉子钉住正确 verdict)。钉子里留了指向 #3871 的 tripwire,该单修好那天它会红,提示可以让本门改读归一结果。

仍然不一致的两行都写明归属,不在本单顺手修:空 source envelope 的渲染器半边归 #3850(「空谓词」范围裁决,在队),${…} 拼法归 #3871。⛔ 未碰 components/visibility-gate.ts、未搬 helper 进 core、未碰 packages/react(#3862)。

钉子

packages/core/src/actions/__tests__/ActionRunner.disabledGate.test.ts(表驱动,形状抄 PR #3851 / #3861 的钉子风格):14 形状逐一断言 handler 跑没跑;一条断言空谓词根本不该到达求值器(门决定,不是 verdict 决定);一条断言「已声明 parity 的每一行,执行侧 verdict 等于渲染器侧 verdict」(渲染器列是从 #3848 正文表转录的证据 —— core 是渲染器包的被依赖方,不能反向 import,活的渲染器钉子在 #3842/#3861 的测试里),并把两行故意不声明 parity 的形状连同归属单号一起钉住;另有机理钉:evaluateCondition(undefined) === true 这条反向默认值,以及 #3871 的 tripwire。

反向验证(方向先判后跑,预判写在钉子头注释里)

还原旧门(!= null && !== false)、求值不动 → 预判「恰好五行『没有条件』变红并各自点名形状,非空形状全绿」。实跑:

 ❯ packages/core/src/actions/__tests__/ActionRunner.disabledGate.test.ts (20 tests | 6 failed)
     × 'disabled: \'\' (empty predicate)'
     × 'disabled: \'   \' (whitespace-only pr…'
     × 'disabled: { dialect: \'cel\', source:…'
     × 'disabled: 0 (not a predicate)'
     × 'disabled: {} (not a predicate)'
     × an empty predicate does not even reach the evaluator (the gate decides, not the verdict)
      Tests  6 failed | 14 passed (20)

与预判一致(第六条红的是那条「不该到达求值器」的钉子,同属这条门)。

验证

pnpm exec vitest run packages/core/src/actions/__tests__/ActionRunner.disabledGate.test.ts --maxWorkers=2
  → Test Files 1 passed (1) | Tests 20 passed (20)

pnpm exec vitest run packages/core/src/actions packages/core/src/evaluator --maxWorkers=2   # 消费半径
  → Test Files 25 passed (25) | Tests 596 passed (596)

pnpm exec vitest run packages/core --maxWorkers=2
  → Test Files 68 passed (68) | Tests 1489 passed (1489)

pnpm exec vitest run packages/react/src/hooks/__tests__/actionPredicate.parity.test.tsx --maxWorkers=2
  → Test Files 1 passed (1) | Tests 11 passed (11)

pnpm exec turbo run type-check --concurrency=2
  → Tasks: 78 successful, 78 total

node scripts/check-control-bytes.mjs
  → OK (scanned 3768 tracked text file(s))

全仓扫过执行门的消费半径:Action is disabled 的断言只在 packages/core;components / plugin-detail 里的 disabled: '' 用例全是渲染器侧钉子,不经 ActionRunner.execute,未受影响。

顺手发现(已另立,未在本 PR 修)


Generated by Claude Code

执行门原来问 `action.disabled != null && action.disabled !== false`,然后把原始值
直接交给 `evaluateCondition` —— 而那个函数对「这里没有条件」只有一个默认答案:
返回 `true`(即 visible/enabled)。在 `disabled` 这个键上,`true` 的意思是「已禁用」。
于是任何空谓词都被判成禁用:handler 一次不跑,调用方收到一个元数据从未声明过的
`{ success: false, error: 'Action is disabled' }`。

实测(计数 handler,基线 origin/main @ f0a625a):

    disabled ''                        | handler ran: false | Action is disabled
    disabled '   '                     | handler ran: false | Action is disabled
    disabled {dialect:'cel',source:''} | handler ran: false | Action is disabled
    disabled absent / false            | handler ran: true  | success

#3842 / #3849 修完渲染器两侧之后,这已经是活的用户可见行为:按钮能点了,点下去
收到 `Action is disabled` —— 渲染器与执行入口对同一个谓词值给出相反答案,正是
#3314 已经付过一次代价的形状。

新门先问「有没有条件可判」再求值:「没有条件」的判定取自 core 唯一的谓词归一器
(`toPredicateInput`,把 `''`/`null`/空 `source` envelope/非谓词值映射为
`undefined`),外加纯空白字符串 —— 归一器对它是包裹而非折叠,而 `evaluateCondition`
自己就把空白 `trimmed` 叫做「没有条件」(`evalRowPredicate` 用的也是这条 blank-source
规则)。

行为变更面单向且刻意窄:只有「没有东西可求值」的形状变化,且一律从「拦」变「放行」
—— `''`、纯空白、空 `source` envelope,以及非谓词垃圾值(`0`、`{}`,原先被强制成
「已禁用」)。`disabled: true`、取真表达式、取真 CEL envelope 仍拦;`disabled: false`
与未声明仍放行;任何表达式/envelope 的 verdict 一律不变。`catch { isDisabled = false }`
失败放行姿态原样保留。

交给求值器的值刻意仍是**原始值**,不是归一结果:对已经是 `${…}` 模板的字符串,
`toPredicateInput` 会二次包裹成 `'${${x}}'`,解析失败后原样退回,`Boolean(非空串)`
恒为 `true` —— 那样求值的模板谓词永远「已禁用」。该归一器缺陷另立 #3871(动作面
渲染器与 ActionEngine 都中招,而 SchemaRenderer 与 page:header 读原始值、钉的是正确
verdict),钉子旁留了 tripwire,#3871 修好那天它会红。因此仍有两行两侧不一致,各自
写明归属:空 envelope(渲染器半边归 #3850 的范围裁决)与 `${…}` 拼法(#3871)。

反向验证(方向先判后跑):还原旧门、求值不动 → 恰好五行「没有条件」钉子变红
(`''`、`'   '`、空 envelope、`0`、`{}`)并各自点名形状,外加「空谓词根本不该到达
求值器」那条;`absent`/`false`/`true`/两个表达式行/两个非空 envelope 行全绿
(6 failed | 14 passed),与预判一致。

Co-authored-by: Claude <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectui Ignored Ignored Aug 8, 2026 10:55pm

Request Review

@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 28.1 KB 350 KB
Entry file index-DUd7fvOy.js
Status PASS

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 8.66KB 3.13KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 7.57KB 2.97KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 22.10KB 4.37KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.13KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.64KB 2.21KB
auth (SocialSignInButtons.js) 9.60KB 3.89KB
auth (UserMenu.js) 3.40KB 1.22KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 35.76KB 9.11KB
auth (createAuthenticatedFetch.js) 4.37KB 1.69KB
auth (index.js) 2.35KB 1.07KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 4.91KB 0.87KB
auth (useIsWorkspaceAdmin.js) 1.61KB 0.85KB
collaboration (CommentThread.js) 26.07KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.65KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 482.39KB 106.34KB
core (index.js) 2.96KB 1.13KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 139.61KB 35.99KB
fields (index.js) 230.82KB 56.70KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.32KB 1.77KB
i18n (index.js) 2.65KB 1.06KB
i18n (pickLocalized.js) 1.70KB 0.83KB
i18n (provider.js) 9.48KB 3.27KB
i18n (useObjectLabel.js) 27.59KB 6.63KB
i18n (useSafeTranslation.js) 4.52KB 1.96KB
layout (index.js) 38.53KB 10.71KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.74KB
mobile (index.js) 1.50KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.71KB 0.42KB
mobile (useResponsiveConfig.js) 1.36KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 8.75KB 3.06KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 3.67KB 1.12KB
permissions (evaluator.js) 4.41KB 1.44KB
permissions (index.js) 0.91KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.52KB
permissions (usePermissions.js) 1.55KB 0.71KB
plugin-ai (index.js) 15.71KB 3.79KB
plugin-calendar (index.js) 44.98KB 12.37KB
plugin-charts (index.js) 61.04KB 17.31KB
plugin-chatbot (index.js) 180.33KB 42.79KB
plugin-dashboard (index.js) 117.21KB 30.27KB
plugin-designer (index.js) 210.51KB 42.51KB
plugin-detail (index.js) 236.17KB 58.82KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 112.10KB 27.10KB
plugin-gantt (index.js) 162.55KB 39.57KB
plugin-grid (index.js) 187.63KB 49.66KB
plugin-kanban (index.js) 48.30KB 13.28KB
plugin-list (index.js) 105.12KB 25.48KB
plugin-map (index.js) 16.81KB 5.24KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 40.58KB 10.58KB
plugin-timeline (index.js) 25.76KB 7.33KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.03KB 20.55KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.71KB 3.53KB
providers (index.js) 0.44KB 0.22KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.67KB 2.37KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 19.28KB 6.38KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.02KB 0.55KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 4.09KB 1.74KB
sdui-parser (index.js) 4.47KB 2.03KB
sdui-parser (parse.js) 10.04KB 2.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 4.69KB 1.48KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-retry.js) 4.32KB 2.02KB
types (index.js) 2.71KB 1.34KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Copy link
Copy Markdown
Collaborator Author

✅ 验收(PM,session session_01GTRjn8xBqp75dk7kFupVRt)—— 形状偏离采纳

实物核验:头 7a4cf2765,3 文件 +391/−1 全在 packages/core(+changeset);执行门 diff 全读 —— hasDisabledCondition module-private、catch { isDisabled = false } 失败放行原样保留、#3850 边界注释与「为何不复用 hasDeclaredVisibilityGate」写透;trailer 0;与在飞 #3546s7/#3844 零相交。
CI 终态(独立复核):20 检查全部 completed(含新 Skill Guide Path Check),18 success + 2 skipped,零失败。

偏离裁定:派发词的优先形状(求值归一结果)被 dev 逐形状实测证伪 —— toPredicateInput 对已是 ${…} 模板的字符串二次包裹致恒真,disabled: '${取假}' 会从放行变误拦,且该拼法的正确 verdict 在 SchemaRenderer 与 page:header 各有绿钉子。采纳落地形状:归一只用于判门、verdict 读原始值 —— 逐形状对照表 14 行中恰好 5 行变化且方向一律「拦→放行」(含 0/{} 两个垃圾形状,与 fail-open 一致、changeset 已声明),行为变更面与派发声明完全重合、只可能少拦不可能多拦。这正是派发词「实测复现不了家族语义就停手报告」意图的正确执行:不硬套、不即兴跨包,证据链齐全。

转 ready 并挂 auto-merge。#3871(归一器二次包裹,A 修生产端→B spec 校验的裁决建议)与 #3872(condition 门真值判定过度放行)归分诊席定级;#3871#3850/#3862 同族(空谓词/定义层三联),建议分诊席并案裁。#3850 落地后本门收敛共享定义(dev 荐 A)记入该单待办。


Generated by Claude Code

@yinlianghui
yinlianghui marked this pull request as ready for review August 8, 2026 23:10
@yinlianghui
yinlianghui added this pull request to the merge queue Aug 8, 2026
Merged via the queue into main with commit 56ff091 Aug 8, 2026
21 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-3848-actionrunner-disabled-gate branch August 8, 2026 23:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ActionRunner.execute 的 disabled 门把「空谓词」当已禁用,拦掉执行(实测 handler 不跑),且与渲染器判定不一致

2 participants