Skip to content

feat(channels): add Web Shell management support for GitHub and GitLab - #8310

Merged
wenshao merged 16 commits into
QwenLM:mainfrom
OrbitZore:feat/webshell-github-gitlab-management
Aug 2, 2026
Merged

feat(channels): add Web Shell management support for GitHub and GitLab#8310
wenshao merged 16 commits into
QwenLM:mainfrom
OrbitZore:feat/webshell-github-gitlab-management

Conversation

@OrbitZore

@OrbitZore OrbitZore commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

Adds full Web Shell management support for GitHub and GitLab channel instances. Users can now create, edit, and configure GitHub/GitLab channels entirely from the Web Shell UI instead of hand-editing settings.json.

The change spans three layers:

Plugin descriptors — Both the GitHub and GitLab plugins now declare a complete management descriptor exposing all user-facing configuration fields: token (secret), baseUrl (string), groupPolicy (enum), senderPolicy (enum), allowedUsers (string-list), and adapter-specific fields — GitHub adds reasonFilter (string-list), GitLab adds action_prompt_template (record with declared option keys).

Frontend editor — The channel editor dialog renders these fields generically from the descriptor. Two new field kinds (string-list rendered as comma-separated input, record rendered as one labelled input per declared option key) are supported alongside the existing string/secret/boolean/number/enum kinds. Field descriptions display below inputs. The senderPolicy selector is descriptor-driven for these types (supporting allowlist/pairing/open), while the legacy hardcoded pairing/open radio group remains for DingTalk/WeCom/Feishu. The Access section is hidden entirely when it would render empty.

Daemon store validationassertDescriptorValue in the channel settings store now validates string-list (array of strings) and record (string-valued object) so upsert requests containing these field kinds succeed rather than returning HTTP 400. Option-key enforcement is left to the editor (which validates string-list tokens) and the adapters at connect time (e.g. GitHub rejects unrecognized reasonFilter values); the store deliberately accepts undeclared record keys so hand-written and forward-compatible configs survive an editor round-trip.

Additional fixes: editing an existing channel whose config lacks a required enum field no longer silently writes the first option (the field shows empty, forcing explicit choice); the record validation path in isMissingField guards against non-string values from hand-edited configs.

Why it's needed

GitHub and GitLab channels are functionally complete adapters, but the Web Shell management UI only supported DingTalk, WeCom, and Feishu. Users had to configure GitHub/GitLab channels manually in settings.json, which is inconsistent with the other managed channels and error-prone — misconfiguration (e.g. missing groupPolicy) silently produces a dead channel that consumes notifications without dispatching them.

Reviewer Test Plan

How to verify

  1. Run the frontend state tests: cd packages/web-shell && npx vitest run client/components/channels/ — 54 tests covering draft creation, validation, upsert shape, enum defaults, string-list/record round-trip, and descriptor-driven senderPolicy.
  2. Run the store validation tests: cd packages/cli && npx vitest run src/serve/channel-settings-store.test.ts — 38 tests including acceptance and rejection of string-list and record field kinds.
  3. Run the catalog test: cd packages/cli && npx vitest run src/commands/channel/channel-registry.test.ts — confirms github and gitlab are manageable with correct field descriptor shapes.
  4. Verify the descriptor shapes in packages/channels/github/src/index.ts and packages/channels/gitlab/src/index.ts match what the adapters actually consume.

Evidence (Before & After)

N/A (management UI rendering depends on live daemon integration; logic verified via unit tests)

Tested on

OS Status
🍏 macOS ⚠️
🪟 Windows ⚠️
🐧 Linux

Environment (optional)

Unit tests only (vitest).

Risk & Scope

  • Main risk or tradeoff: the two new field kinds (string-list, record) extend the descriptor type system — future plugins can use them, but the daemon store and frontend editor must stay in sync on supported kinds.
  • Not validated / out of scope: end-to-end Web Shell UI rendering with a live daemon; the pairing flow posting public comments on GitHub/GitLab (functional but noisy — worth revisiting separately).
  • Breaking changes / migration notes: none. Existing settings.json configs are unaffected; the editor now surfaces fields that were previously only configurable by hand.

Linked Issues

Ref: #7862 (comment)

中文说明

本 PR 做了什么

为 GitHub 和 GitLab 频道实例添加完整的 Web Shell 管理支持。用户现在可以完全通过 Web Shell UI 创建、编辑和配置 GitHub/GitLab 频道,无需手动编辑 settings.json

变更跨越三层:

插件描述符 — GitHub 和 GitLab 插件均声明了完整的 management 描述符,暴露所有面向用户的配置字段:token(secret)、baseUrl(string)、groupPolicy(enum)、senderPolicy(enum)、allowedUsers(string-list),以及适配器特有字段——GitHub 增加 reasonFilter(string-list),GitLab 增加 action_prompt_template(record,带声明的 option keys)。

前端编辑器 — 频道编辑对话框从描述符通用渲染这些字段。支持两种新字段类型(string-list 渲染为逗号分隔输入框,record 渲染为每个声明的 option key 一个带标签输入框),与现有的 string/secret/boolean/number/enum 类型并列。字段描述显示在输入框下方。senderPolicy 选择器对这些类型由描述符驱动(支持 allowlist/pairing/open),而 DingTalk/WeCom/Feishu 保留原有的硬编码 pairing/open 单选组。当 Access 区域无内容时整体隐藏。

守护进程存储验证 — 频道设置存储中的 assertDescriptorValue 现在验证 string-list(字符串数组)和 record(字符串值对象),使包含这些字段类型的 upsert 请求成功而非返回 HTTP 400。option key 的约束交由编辑器(校验 string-list 取值)和适配器在 connect 阶段(如 GitHub 拒绝未识别的 reasonFilter 值)负责;存储层有意接受未声明的 record key,使手写配置和前向兼容配置能在编辑器往返后保留。

额外修复:编辑配置中缺少必填 enum 字段的现有频道时,不再静默写入第一个选项(字段显示为空,强制用户显式选择);isMissingField 的 record 验证路径对手动编辑配置中的非字符串值做了防护。

为什么需要

GitHub 和 GitLab 频道是功能完整的适配器,但 Web Shell 管理 UI 仅支持 DingTalk、WeCom 和 Feishu。用户必须在 settings.json 中手动配置 GitHub/GitLab 频道,与其他已托管频道不一致且容易出错——配置错误(如缺少 groupPolicy)会静默产生一个消耗通知但不派发消息的死频道。

审阅者测试计划

如何验证

  1. 运行前端状态测试:cd packages/web-shell && npx vitest run client/components/channels/ — 54 个测试覆盖草稿创建、验证、upsert 结构、enum 默认值、string-list/record 往返、描述符驱动的 senderPolicy。
  2. 运行存储验证测试:cd packages/cli && npx vitest run src/serve/channel-settings-store.test.ts — 38 个测试,包含 string-listrecord 字段类型的接受和拒绝。
  3. 运行目录测试:cd packages/cli && npx vitest run src/commands/channel/channel-registry.test.ts — 确认 github 和 gitlab 标记为可管理且字段描述符结构正确。
  4. 验证 packages/channels/github/src/index.tspackages/channels/gitlab/src/index.ts 中的描述符结构与适配器实际消费的配置一致。

证据(前后对比)

N/A(管理 UI 渲染依赖 daemon 集成;逻辑通过单元测试验证)

测试环境

OS 状态
🍏 macOS ⚠️
🪟 Windows ⚠️
🐧 Linux

环境(可选)

仅单元测试(vitest)。

风险与范围

  • 主要风险或权衡:两种新字段类型(string-listrecord)扩展了描述符类型系统——未来插件可以使用它们,但 daemon 存储和前端编辑器必须保持对支持类型的同步。
  • 未验证 / 不在范围内:与 live daemon 的端到端 Web Shell UI 渲染;pairing 流程在 GitHub/GitLab 上发布公开评论(功能正常但噪音大——值得单独讨论)。
  • 破坏性变更 / 迁移说明:无。现有 settings.json 配置不受影响;编辑器现在暴露了以前只能手动配置的字段。

关联 Issue

参考:#7862 (comment)

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Qwen Triage finishedview run. See the stage comments in this thread for the result.

Qwen Triage 已完成 —— 查看运行。结果见本线程中的各阶段评论。

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓

Problem: Real consistency gap — GitHub and GitLab channels are fully functional adapters, but the Web Shell management UI only supports DingTalk/WeCom/Feishu. Users have to hand-edit settings.json for GitHub/GitLab, which is inconsistent and error-prone (referenced from #7862). This is an observed, concrete gap, not theoretical hardening.

Direction: Aligned. The Web Shell already manages three channel types through this exact descriptor mechanism; extending it to GitHub/GitLab is the natural next step. CHANGELOG has no direct reference but the area is clearly relevant — channel management is an active surface.

Size: Not applicable — no core module paths touched.

Approach: Scope feels right. The two new field kinds (string-list for GitHub's reasonFilter, record for GitLab's action_prompt_template) are genuinely needed — these fields don't fit the existing string/enum/boolean kinds. Making senderPolicy descriptor-driven for these types (while keeping the legacy radio group for DingTalk/WeCom/Feishu) is the clean way to do it without breaking existing channels. The PLATFORM_MARKS dedup (moved from two files to one export in channel-platform.ts) is a nice cleanup that falls out naturally.

Risk: No elevated risk signals — no high-risk paths matched.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓

问题:真实的一致性缺口——GitHub 和 GitLab 频道是功能完整的适配器,但 Web Shell 管理 UI 仅支持 DingTalk/WeCom/Feishu。用户必须手动编辑 settings.json 配置 GitHub/GitLab,不一致且容易出错(引自 #7862)。这是已观测到的具体缺口,非理论性加固。

方向:对齐。Web Shell 已通过完全相同的描述符机制管理三种频道类型;扩展到 GitHub/GitLab 是自然的下一步。

规模:不适用——未触及核心模块路径。

方案:范围合理。两种新字段类型(string-list 用于 GitHub 的 reasonFilterrecord 用于 GitLab 的 action_prompt_template)确实需要——这些字段不适合现有的 string/enum/boolean 类型。对这些类型由描述符驱动 senderPolicy(同时保留 DingTalk/WeCom/Feishu 的旧版单选组)是干净的做法。

风险:无升级风险信号。

进入代码审查 🔍

Qwen Code · qwen3.8-max-preview

Reviewed at 1d42fbdda527d432499d66571f1d90dc8db732f1 · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Code review

Clean. My independent take before reading the diff was: add a management descriptor to the two plugins mirroring DingTalk, extend the Web Shell allowlist and platform marks, add i18n, and extend the store validator if new field kinds are needed — which is exactly what this does.

Verified against the code:

  • The new descriptors conform to ChannelConfigFieldDescriptor and are field-for-field the same shape as the DingTalk/WeCom/Feishu plugins. token as a required secret, baseUrl as an env-resolvable string, groupPolicy and senderPolicy as required enums — all match what the adapters actually consume.
  • The two new field kinds are sound. string-list validates as an array of strings in the store, renders as comma-separated input in the editor, and round-trips cleanly. record validates as a string-valued object with open keys (correctly — GitLab's action_name set drifts server-side, so the store must accept undeclared keys), and renders as one labelled input per declared option key.
  • The senderPolicy split is the right call: descriptor-driven for github/gitlab (rendered via the generic enum → Select path), legacy hardcoded radio group for dingtalk/wecom/feishu. hasDescriptorSenderPolicy is a one-liner and the buildChannelUpsertRequest correctly skips the hardcoded path when the descriptor declares it.
  • The enum defaulting fix is a real bug fix: editing an instance that lacks a required enum field no longer silently writes the first option. The field.default addition (used for groupPolicy: 'open') is clean.
  • PLATFORM_MARKS dedup from two files to one export in channel-platform.ts — good hygiene that falls out naturally.
  • isRecord appears in both ChannelEditorDialog.tsx and channel-editor-state.ts — trivial one-liner, not worth extracting into a shared module.

No blockers, no AGENTS.md violations.

Test evidence

Check Conclusion
Test (ubuntu-latest, Node 22.x) ✅ success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success
Serve A/B (ubuntu-latest, Node 22.x) ✅ success
Capture web-shell visuals (ubuntu-latest, Node 22.x) ✅ success
precheck-pr / precheck ✅ success
Classify PR ✅ success
label ✅ success
route ✅ success
Test (windows-latest, Node 22.x) ⏭️ skipped
Test (macos-latest, Node 22.x) ⏭️ skipped
Integration Tests (CLI, No Sandbox) ⏭️ skipped

All checks completed on 1d42fbd. The unit suite (ubuntu) and the web-shell E2E smoke both pass. The Serve A/B diff reports no response changes against the PR base across 4 scenarios — expected, since the store change only adds acceptance paths for new field kinds.

The maintainer (@wenshao) independently verified this PR on a fully real stack (PR-source daemon + Vite Web Shell + real Chromium) against fake GitHub/GitLab API servers: channel creation, editing, field validation, and the descriptor-driven senderPolicy all work end-to-end. Both minor findings from that run (wrong string-list validation message, server-side option-key enforcement) were addressed in the final commits.

中文说明

代码审查

干净。我在阅读 diff 之前的独立判断是:为两个插件添加镜像 DingTalk 的 management 描述符,扩展 Web Shell 白名单和平台标记,添加 i18n,并在需要时扩展存储验证器——这正是 PR 所做的。

已对照代码验证:

  • 新描述符符合 ChannelConfigFieldDescriptor,与 DingTalk/WeCom/Feishu 插件结构一致。
  • 两种新字段类型设计合理。string-list 在存储层验证为字符串数组,在编辑器中渲染为逗号分隔输入。record 验证为字符串值对象,接受未声明的 key(正确——GitLab 的 action 集合会服务端漂移)。
  • senderPolicy 分离处理正确:github/gitlab 由描述符驱动,dingtalk/wecom/feishu 保留旧版单选组。
  • enum 默认值修复是真实的 bug 修复。
  • PLATFORM_MARKS 去重良好。

无阻塞问题,无 AGENTS.md 违规。

测试证据

所有检查在 1d42fbd 上完成。单元测试(ubuntu)和 web-shell E2E 冒烟测试均通过。Serve A/B 差异报告无响应变更。维护者已在完全真实的栈上独立验证了端到端功能。

Qwen Code · qwen3.8-max-preview

Reviewed at 1d42fbdda527d432499d66571f1d90dc8db732f1 · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Confidence: 5/5 — a genuinely minimal, pattern-following change that closes a real consistency gap; every prior review finding is addressed and the maintainer has verified it end-to-end on a real stack.

This is the kind of PR that's easy to review and easy to maintain: it solves a real problem (GitHub/GitLab channels were the odd ones out in the Web Shell management UI), does it by reusing the exact descriptor pattern three other channels already use, and carries tests that pin both halves of the change — the registry catalog shape and the store validation for the two new field kinds. The scope is tight; nothing in the diff goes beyond what the stated goal needs.

What convinced me this is ready:

  • The approach matches my independent proposal almost exactly — descriptor + allowlist + store validation + i18n. No simpler path was missed.
  • The two new field kinds (string-list, record) are genuinely required, not speculative generality. GitHub's reasonFilter is a list; GitLab's action_prompt_template is a keyed map. Neither fits the existing kinds.
  • The senderPolicy descriptor-driven split is clean and backward-compatible — legacy channels keep their hardcoded radio group untouched.
  • The enum defaulting fix (no longer silently writing the first option when editing an instance that lacks the field) is a real bug fix that improves the editor for all channel types.
  • @wenshao's real-stack verification (live daemon + browser + fake API servers) fills the "N/A — depends on live daemon integration" gap the PR itself flagged, and both minor findings from that run are fixed in the final commits.
  • CI is fully green on 1d42fbd — unit suite, web-shell E2E smoke, and Serve A/B all pass.

If I had to maintain this in six months, I'd thank the author: the descriptor is the single source of truth, the editor renders generically from it, and adding the next channel type is now a copy-paste-and-adjust exercise.

Approving. ✅

中文说明

置信度:5/5 —— 一个真正最小化、遵循既有模式的改动,弥合了真实的一致性缺口;所有先前审查发现均已解决,维护者已在真实栈上端到端验证。

这个 PR 易于审查、易于维护:它解决了一个真实问题(GitHub/GitLab 频道是 Web Shell 管理 UI 中的异类),通过复用其他三种频道已在使用的完全相同的描述符模式来实现,并携带了钉住变更两半的测试——注册目录结构和两种新字段类型的存储验证。范围紧凑,diff 中没有超出目标所需的内容。

CI 在 1d42fbd 上全绿。批准。✅

Qwen Code · qwen3.8-max-preview

Reviewed at 1d42fbdda527d432499d66571f1d90dc8db732f1 · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

🖼️ web-shell visual preview

Rendered against a mock daemon (no real backend): the PR base vs this PR head 1d42fbd. Only screenshots that changed are shown (flows below, if any, are head-only) — refreshes on every push.

Screenshots · before / after

ℹ️ No screenshot changed against the PR base — but this PR edits 4 render-shaping files:

  • packages/web-shell/client/components/channels/ChannelEditorDialog.module.css
  • packages/web-shell/client/components/channels/ChannelEditorDialog.tsx
  • packages/web-shell/client/components/channels/ChannelsManagerPage.tsx
  • packages/web-shell/client/i18n.tsx

Either the change has no visual effect (logic, plumbing, a state the scenarios never reach), or no scenario renders this UI — in which case the preview cannot see it, and an empty result is a coverage gap rather than a clean bill of health. To make it visible, add a scenario to packages/web-shell/client/e2e/visuals/screenshots.spec.ts that seeds whatever state the UI is gated on; it then appears here as a head-only (NEW) capture.

Full-resolution recordings (.webm) are attached to the workflow run.

Qwen Code · web-shell visuals

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM, looks ready to ship — CI landed green after the review. ✅

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed — no blockers. Suggestions are inline.

中文说明

已审查——无阻断问题。 建议见行内评论。

— qwen3.8-max-preview via Qwen Code /review

Comment on lines +13 to +15
'github',
'gitlab',
]);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] Adding github/gitlab to this allowlist makes them appear in the Channels Manager "Available platforms" grid, but PLATFORM_MARKS in ChannelsManagerPage.tsx (line 77) only defines dingtalk/wecom/feishu, and the read site at ChannelsManagerPage.tsx:531 renders {PLATFORM_MARKS[platform.type]} with no fallback — so the two new platforms render a blank avatar badge. — Concrete cost: the GitHub/GitLab cards show an empty mark where DingTalk/WeCom/Feishu show their initials; the sibling ChannelEditorDialog.tsx:437 already guards the identical lookup with ?? descriptor.displayName[0].

The fix belongs in ChannelsManagerPage.tsx (not this file) — add github/gitlab entries to PLATFORM_MARKS, or mirror the dialog fallback:

// ChannelsManagerPage.tsx:531
{PLATFORM_MARKS[platform.type] ?? platform.displayName[0]}
中文说明

github/gitlab 加入此白名单后,它们会出现在频道管理页的「可用平台」网格中,但 ChannelsManagerPage.tsx(第 77 行)的 PLATFORM_MARKS 只定义了 dingtalk/wecom/feishu,且第 531 行的渲染 {PLATFORM_MARKS[platform.type]} 没有兜底——因此两个新平台会渲染出空白的头像徽标。— 具体代价:GitHub/GitLab 卡片显示空白标记,而 DingTalk/WeCom/Feishu 显示各自的首字母;同级的 ChannelEditorDialog.tsx:437 已经用 ?? descriptor.displayName[0] 对相同查找做了兜底。

修复应在 ChannelsManagerPage.tsx(而非本文件)中进行——向 PLATFORM_MARKS 添加 github/gitlab 条目,或参照编辑对话框的兜底写法(见上方代码块)。

— qwen3.8-max-preview via Qwen Code /review

export function isSupportedChannelType(
type: unknown,
): type is 'dingtalk' | 'wecom' | 'feishu' {
): type is 'dingtalk' | 'wecom' | 'feishu' | 'github' | 'gitlab' {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] Widening the supported set to include github/gitlab leaves the empty-state copy stale: channels.empty.description (i18n.tsx:2400 en, :4848 zh) still reads "Configure DingTalk, WeCom, or Feishu to receive messages in this workspace." / "配置钉钉、企业微信或飞书,让当前工作区接收消息。", rendered in ChannelsManagerPage.tsx:368. — Concrete cost: a workspace with no configured channels is told only DingTalk/WeCom/Feishu are available, even though the platform grid now also offers GitHub and GitLab.

The fix is in i18n.tsx (not this file) — update both locale strings to include GitHub and GitLab, or phrase the list generically (as channels.availablePlatforms.description already does).

中文说明

将支持集合扩展为包含 github/gitlab 后,空状态文案变得过时:channels.empty.description(i18n.tsx:2400 英文,:4848 中文)仍然写着 "Configure DingTalk, WeCom, or Feishu to receive messages in this workspace." / "配置钉钉、企业微信或飞书,让当前工作区接收消息。",渲染于 ChannelsManagerPage.tsx:368。— 具体代价:未配置任何频道的工作区只会被告知 DingTalk/WeCom/Feishu 可用,尽管平台网格现在也提供 GitHub 和 GitLab。

修复在 i18n.tsx(而非本文件)中——更新两个语言的文案以包含 GitHub 和 GitLab,或将列表表述为通用形式(如同 channels.availablePlatforms.description 已有的写法)。

— qwen3.8-max-preview via Qwen Code /review

import type { DaemonChannelTypeDescriptor } from '@qwen-code/sdk/daemon';

const SUPPORTED_CHANNEL_TYPES = new Set(['dingtalk', 'wecom', 'feishu']);
const SUPPORTED_CHANNEL_TYPES = new Set([

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] Making github/gitlab manageable exposes the channel editor for them, but FIELD_LABEL_KEYS in ChannelEditorDialog.tsx (lines 65–79) only has entries for dingtalk/wecom/feishu. fieldLabel() (lines 185–188) therefore falls back to the plugin's hard-coded English field.label ('Personal Access Token', 'Base URL'). — Concrete cost: in the zh locale (where sibling platforms' field labels are translated, i18n.tsx:4875–4882) a user opening the GitHub/GitLab editor sees English field labels inside an otherwise Chinese dialog; future translations also won't be picked up without FIELD_LABEL_KEYS entries. The en locale is unaffected.

The fix is in ChannelEditorDialog.tsx + i18n.tsx (not this file) — add github/gitlab entries to FIELD_LABEL_KEYS (mapping token/baseUrl) and add the corresponding channels.editor.field.github.* / channels.editor.field.gitlab.* keys to both dictionaries, mirroring dingtalk/wecom/feishu.

中文说明

github/gitlab 设为可管理后,频道编辑器会对它们开放,但 ChannelEditorDialog.tsx(第 65–79 行)的 FIELD_LABEL_KEYS 只有 dingtalk/wecom/feishu 的条目。因此 fieldLabel()(第 185–188 行)会回退到插件硬编码的英文 field.label'Personal Access Token''Base URL')。— 具体代价:在中文语言环境下(同级平台的字段标签已翻译,i18n.tsx:4875–4882),用户打开 GitHub/GitLab 编辑器时会在 otherwise 中文的对话框中看到英文字段标签;未来新增的翻译在没有 FIELD_LABEL_KEYS 条目的情况下也不会生效。英文语言环境不受影响。

修复在 ChannelEditorDialog.tsx + i18n.tsx(而非本文件)中——向 FIELD_LABEL_KEYS 添加 github/gitlab 条目(映射 token/baseUrl),并向两个语言字典添加对应的 channels.editor.field.github.* / channels.editor.field.gitlab.* 键,参照 dingtalk/wecom/feishu 的模式。

— qwen3.8-max-preview via Qwen Code /review

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Qwen precheck requires maintainer approval before automated triage/review.

Head SHA: 1d42fbdda527d432499d66571f1d90dc8db732f1

Reason:

  • secret_value:assignment

A maintainer with write access can inspect the PR and manually request a run with @qwen-code /triage or @qwen-code /review. A new push requires a fresh precheck.

@wenshao

wenshao commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Review: feat(channels): add Web Shell management support for GitHub and GitLab

Overview

Adds management descriptors (token secret + baseUrl string) to the GitHub and GitLab plugins so supportedChannelCatalog() reports them as manageable, extends the Web Shell allowlist (SUPPORTED_CHANNEL_TYPES), adds GH/GL platform marks, i18n labels, and updates the empty-state copy. Mechanically the change follows the DingTalk/WeCom/Feishu pattern faithfully.

What I verified

Checked out feat/webshell-github-gitlab-management (5c13870) in an isolated worktree and ran the suites named in the test plan — all green:

packages/web-shell  client/components/channels/        5 files, 48 tests passed
packages/cli        channel-registry.test.ts            1 test passed
packages/cli        channel-settings-store.test.ts     33 tests passed

I also traced the full save path (createChannelEditorDraftvalidateChannelEditorDraftbuildChannelUpsertRequestWorkspaceChannelSettingsStore.upsert) and probed it with throwaway tests. Two things surfaced that I think need to be resolved before merge.


🔴 Blocking — a channel created from the Web Shell is silently dead

buildChannelUpsertRequest only writes descriptor fields plus senderPolicy. For a brand-new GitHub/GitLab channel the persisted config is exactly:

{ "type": "github", "senderPolicy": "pairing" }

(verbatim output from a probe driving the real editor-state functions with this PR's descriptor)

groupPolicy is absent, so new GroupGate(config.groupPolicy, …) falls back to its 'disabled' default (packages/channels/base/src/GroupGate.ts:13). Both adapters emit only isGroup: true envelopes (GithubAdapter.ts:1005), so the group gate drops every single one before the sender gate ever runs.

This is not a corner case — it is documented as the #1 failure mode for both adapters:

  • docs/users/features/channels/github.md:75groupPolicy"Must be "open" for notifications to flow"
  • docs/users/features/channels/gitlab.md:141"The default value "disabled" drops all mentions: todos are marked done and the cursor advances, but no dispatch occurs."

So the end state is worse than a no-op: the channel connects, reports healthy, polls, consumes notifications/todos and advances the cursor, and dispatches nothing. On GitLab those todos are marked done and are gone. A user following the PR's own premise ("instead of editing settings.json by hand") gets a channel that is broken in a way the UI gives them no way to see or fix.

assertSharedField already accepts groupPolicy (channel-settings-store.ts:145), and the editor already renders kind: 'enum' fields with a Select (ChannelEditorDialog.tsx:377), so the fix is contained — add it to the descriptor:

{
  key: 'groupPolicy',
  label: 'Group Policy',
  kind: 'enum',
  required: true,
  options: [
    { value: 'open', label: 'Open' },
    { value: 'allowlist', label: 'Allowlist' },
    { value: 'disabled', label: 'Disabled' },
  ],
},

🟠 Major — senderPolicy: "allowlist" is not expressible, and editing forces it off

The editor offers only pairing and open (ChannelEditorDialog.tsx:529), validateChannelEditorDraft rejects an empty policy, and buildChannelUpsertRequest unconditionally writes config['senderPolicy']. Probing an existing GitHub channel configured with senderPolicy: "allowlist" + allowedUsers: ["alice"]:

draft.senderPolicy    = ""
validation errors     = {"senderPolicy":"policy"}
→ after picking "open":
{"type":"github","senderPolicy":"open","allowedUsers":["alice"],"reasonFilter":["mention"],"cwd":"/repo"}

allowedUsers and reasonFilter survive (good — instance.config is spread through), but the policy that was actually gating access is replaced. An operator who opens the dialog only to rotate a token cannot save without changing the access policy of the channel.

This matters more for these two types than for the IM channels, because the audience is different:

  • docs/users/features/channels/github.md:76 and gitlab.md:66 both document the default as "allowlist".
  • github.md:91 / gitlab.md:133: "Always use senderPolicy: "allowlist" with explicit allowedUsers on public repos" — because open lets any GitHub/GitLab user drive the agent in your cwd.

The new UI path makes the documented-safe posture the one option you cannot pick. Suggest either surfacing allowlist in the policy selector (the backend already accepts it) or leaving senderPolicy untouched when the stored value is one the UI can't represent, rather than forcing a change.

Related: if a user does pick pairing, ChannelBase.onPairingRequired posts "Your pairing code is: XXXX / Ask the bot operator to approve you with: qwen channel pairing approve …" via sendThreadMessage (ChannelBase.ts:5701) — on GitHub/GitLab that is a public comment on the issue/PR, for every unknown commenter. Functional, but noisy on a public repo, and it advertises your channel name. Worth reconsidering whether pairing should be the default preselection for these types.

🟡 Minor

  • baseUrl means two different things. GitHub wants the API root (https://github.example.com/api/v3github.md:64, and GithubAdapter.ts strips /api/v3$ to derive webOrigin); GitLab wants the instance root (https://gitlab.example.comgitlab.md:53). Both are labelled just "Base URL" / "基础 URL". ChannelEditorDialog doesn't render field.description, so the label is the only affordance — cheap fix is to disambiguate via the i18n keys this PR already adds, e.g. "API Base URL (GitHub Enterprise)" vs "GitLab Instance URL".
  • Test coverage. channel-registry.test.ts only asserts the manageable type list; the actual field descriptors are spot-checked for dingtalk alone. A toContainEqual for the github/gitlab token field would lock in the shape the PR is adding. Nothing asserts the groupPolicy/senderPolicy behaviour above.
  • Descriptor labels are dead strings. fieldLabel() prefers FIELD_LABEL_KEYS[type][key] and only falls back to field.label, so 'Personal Access Token' in the plugin is never displayed. Consistent with the existing plugins, just noting it.

👍 Good

  • PLATFORM_MARKS[platform.type] ?? platform.displayName[0] in ChannelsManagerPage brings it into parity with the fallback ChannelEditorDialog.tsx:437 already had — nice drive-by.
  • Round-tripping instance.config means adapter-specific keys (reasonFilter, action_prompt_template) survive an edit; assertManagedConfig's deep-equal check keeps them read-only. Verified.
  • envResolvable: true on token is accurate despite token not being in envResolvableConfigFieldsparseChannelConfig resolves it via KNOWN_CREDENTIAL_FIELDS (config-utils.ts:426,440).
  • Registry order is insertion-ordered, so the exact-array assertion in channel-registry.test.ts is stable.
  • Both i18n locales updated together, including the empty-state copy.

Summary

The plumbing is right and matches the established pattern. But management for these two adapters isn't complete at two fields: without groupPolicy the created channel cannot work at all, and without allowlist the UI can't express the access policy the docs mandate for public repos. Both are additive to the descriptors this PR already introduces. I'd hold merge on the groupPolicy item at minimum.

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] Missing groupPolicy in the management descriptor makes channels created from the Web Shell silently dead. buildChannelUpsertRequest writes only descriptor fields + senderPolicy, so a new GitHub/GitLab channel persists { "type": "github", "senderPolicy": "pairing" } with no groupPolicy. ChannelBase.ts:821 passes config.groupPolicy (undefined) to GroupGate, which defaults to 'disabled' (GroupGate.ts:13). Both adapters emit only isGroup: true envelopes (GithubAdapter.ts:1005), so the group gate drops every message. The channel connects, polls, consumes notifications/todos, advances the cursor, and dispatches nothing. On GitLab those todos are marked done and are gone. This blocker was raised by @wenshao in comment 5151503348 and still stands at the reviewed commit — the diff adds no groupPolicy field.

— qwen3.8-max-preview via Qwen Code /review (v0.21.2)

expect(
catalog.filter((entry) => entry.manageable).map((entry) => entry.type),
).toEqual(['dingtalk', 'wecom', 'feishu']);
).toEqual(['dingtalk', 'wecom', 'feishu', 'github', 'gitlab']);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] The test asserts the manageable type list but not the field descriptor shape. Reverting the management descriptors from the github/gitlab plugins leaves this test green — the test-efficacy probe confirmed this: both management descriptor hunks survived reversion with no test failure. — Concrete cost: if a future change accidentally sets kind: 'string' on the token field, the Web Shell editor would render it as a plain text input instead of a masked secret field, and no test would catch it.

Suggested change
).toEqual(['dingtalk', 'wecom', 'feishu', 'github', 'gitlab']);
).toEqual(['dingtalk', 'wecom', 'feishu', 'github', 'gitlab']);
expect(
catalog.find((entry) => entry.type === 'github')?.fields,
).toContainEqual(
expect.objectContaining({ key: 'token', kind: 'secret', required: true }),
);
expect(
catalog.find((entry) => entry.type === 'gitlab')?.fields,
).toContainEqual(
expect.objectContaining({ key: 'token', kind: 'secret', required: true }),
);
中文说明

该测试仅断言了可管理类型列表,未断言字段描述符的结构。测试效力探针确认:回退 github/gitlab 插件的 management 描述符后,所有测试仍然通过。具体代价:如果未来的变更意外将 token 字段的 kind 设为 'string',Web Shell 编辑器会将其渲染为明文输入框而非掩码密钥字段,且没有测试能捕获此回归。

— qwen3.8-max-preview via Qwen Code /review (v0.21.2)

Comment on lines 81 to 83
github: 'GH',
gitlab: 'GL',
};

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] PLATFORM_MARKS is duplicated identically in ChannelEditorDialog.tsx:59 and here; this PR extends both copies manually. — Concrete cost: when a sixth channel type is added, the developer must update two identical objects in two files. Missing one causes that view to render displayName[0] (e.g. 'G') instead of the intended abbreviation (e.g. 'GH'). The shared module channel-platform.ts in the same directory already exports shared channel utilities and is the natural home for this constant.

中文说明

PLATFORM_MARKSChannelEditorDialog.tsx:59 和此处完全重复;本 PR 手动扩展了两份副本。具体代价:添加第六种频道类型时,开发者必须同时更新两个文件中的相同对象。遗漏其中一个会导致该视图渲染 displayName[0](如 'G')而非预期缩写(如 'GH')。同目录下的共享模块 channel-platform.ts 已导出共享频道工具函数,是此常量的自然归属。

— qwen3.8-max-preview via Qwen Code /review (v0.21.2)

@OrbitZore
OrbitZore force-pushed the feat/webshell-github-gitlab-management branch from b2eb883 to ac558ab Compare August 1, 2026 18:08
@OrbitZore

Copy link
Copy Markdown
Collaborator Author

Thanks for the thorough review! All items addressed in the latest commits (63c32c5, 6dfe557):

Blocking — missing groupPolicy

Fixed. Added groupPolicy as a required enum field (open / allowlist / disabled, default open) to both GitHub and GitLab management descriptors. The editor renders it via the existing generic enum → Select path, and buildChannelUpsertRequest writes it through the descriptor field loop — no special-case logic.

Major — senderPolicy: "allowlist" not expressible

Fixed. senderPolicy is now a descriptor-driven enum field (allowlist / pairing / open, default allowlist) for GitHub/GitLab. When a descriptor declares senderPolicy, the hardcoded radio group is skipped and the generic enum rendering takes over. Added allowedUsers as a new string-list field kind (comma-separated text input ↔ string[] in config). The editor state handles join/split generically; the backend assertSharedField already accepted both fields.

IM channels (dingtalk/wecom/feishu) are unchanged — they keep the existing radio with pairing/open, which is appropriate since their default senderPolicy and group semantics differ.

Minor — baseUrl ambiguity

Fixed via i18n: GitHub shows "API Base URL", GitLab shows "Instance URL".

Minor — test coverage

Fixed. channel-registry.test.ts now asserts the field descriptor shape (token secret, groupPolicy enum, senderPolicy enum, allowedUsers string-list) for both github and gitlab via toContainEqual. Also added 5 new channel-editor-state.test.ts cases covering enum defaults, string-list round-trip, descriptor-driven upsert, validation skip, and empty list omission.

Minor — dead descriptor labels

Acknowledged — consistent with the existing IM plugins. fieldLabel() prefers FIELD_LABEL_KEYS i18n entries; the plugin label serves as a fallback for unmanaged contexts (CLI output).

PLATFORM_MARKS duplication (from /review)

Fixed. Extracted to channel-platform.ts as a shared export; both ChannelsManagerPage and ChannelEditorDialog now import from there.

New — field-level descriptions in the editor

The ChannelConfigFieldDescriptor type already carried a description key, but the editor never rendered it. Now FieldShell accepts and displays field.description as a small hint line below the input. All five GitHub/GitLab management fields carry contextual descriptions, e.g.:

  • tokenClassic PAT with "notifications" scope (GitHub) / PAT with "read_api" + "api" scopes (GitLab)
  • baseUrlGitHub Enterprise API root (e.g. https://ghe.example.com/api/v3). Leave empty for github.com
  • groupPolicyMust be "Open" for notifications to flow
  • senderPolicyUse "Allowlist" with allowed users on public repos
  • allowedUsersGitHub usernames, used by Allowlist and Pairing policies

This is generic — any plugin can set description on its fields and it will render automatically.

中文翻译

感谢详细的 review!所有问题已在最新 commit(63c32c56dfe557)中修复:

阻塞项 — 缺少 groupPolicy

已修复。在 GitHub 和 GitLab 的 management 描述符中添加了 groupPolicy 必填 enum 字段(open / allowlist / disabled,默认 open)。编辑器通过现有的通用 enum → Select 路径渲染,buildChannelUpsertRequest 通过描述符字段循环写入——无特殊分支逻辑。

主要问题 — senderPolicy: "allowlist" 不可表达

已修复。GitHub/GitLab 的 senderPolicy 现在是描述符驱动的 enum 字段(allowlist / pairing / open,默认 allowlist)。当描述符声明了 senderPolicy 时,硬编码的 radio 组被跳过,由通用 enum 渲染接管。新增 allowedUsers 字段类型 string-list(逗号分隔文本输入 ↔ 配置中的 string[])。编辑器状态层通用处理 join/split;后端 assertSharedField 已接受这两个字段。

IM 频道(dingtalk/wecom/feishu)不受影响——保持现有 radio(pairing/open),因为它们的默认 senderPolicy 和群组语义不同。

次要 — baseUrl 歧义

已通过 i18n 修复:GitHub 显示 "API Base URL",GitLab 显示 "Instance URL"。

次要 — 测试覆盖

已修复。channel-registry.test.ts 现在通过 toContainEqual 断言 github 和 gitlab 的字段描述符结构(token secret、groupPolicy enum、senderPolicy enum、allowedUsers string-list)。另新增 5 个 channel-editor-state.test.ts 用例,覆盖 enum 默认值、string-list 往返、描述符驱动 upsert、校验跳过和空列表省略。

次要 — 描述符 label 是死字符串

已知——与现有 IM 插件一致。fieldLabel() 优先使用 FIELD_LABEL_KEYS i18n 条目;插件 label 作为非管理上下文(CLI 输出)的 fallback。

PLATFORM_MARKS 重复(来自 /review)

已修复。提取到 channel-platform.ts 作为共享导出;ChannelsManagerPageChannelEditorDialog 均改为 import。

新增 — 编辑器字段级描述

ChannelConfigFieldDescriptor 类型已有 description 键,但编辑器从未渲染。现在 FieldShell 接受并在输入框下方显示 field.description 作为小字说明。GitHub/GitLab 的全部 5 个 management 字段均添加了上下文描述,例如:

  • tokenClassic PAT with "notifications" scope(GitHub)/ PAT with "read_api" + "api" scopes(GitLab)
  • baseUrlGitHub Enterprise API root (e.g. https://ghe.example.com/api/v3). Leave empty for github.com
  • groupPolicyMust be "Open" for notifications to flow
  • senderPolicyUse "Allowlist" with allowed users on public repos
  • allowedUsersGitHub usernames, used by Allowlist and Pairing policies

这是通用功能——任何插件都可以在字段上设置 description,编辑器会自动渲染。

@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Please do not rebase or force-push to an active PR as it invalidates existing review comments. Note for future reference, the bots always squash all changes into a single commit automatically as part of the integration.

中文

请勿对活跃的 PR 执行 rebase 或 force-push,因为这会使已有的评审评论失效。另外,供日后参考:作为集成流程的一部分,机器人始终会自动将所有改动压缩(squash)为单个提交。

@OrbitZore

Copy link
Copy Markdown
Collaborator Author

Update — adapter-specific config fields (e10ad1c)

Added the remaining private config fields for each adapter, plus a new record field kind to support structured key-value maps in the editor:

GitHub — reasonFilter (string-list)

Optional comma-separated allowlist of notification reasons to process (e.g. mention, review_requested, assign). The description lists all 15 valid values. Leave empty to process all reasons.

GitLab — action_prompt_template (record, required)

GitLab's action_prompt_template is a Record<string, string> mapping action names to prompt templates. Since the keys are a finite set of 9 known actions, the new record field kind renders one labeled text input per key instead of a raw JSON textarea. Each label includes the trigger description (e.g. "Mentioned — @bot in a comment or description"), fully translated in zh. Only non-empty entries are written to config; unconfigured actions are skipped by the adapter.

The field-level description (rendered below the last key input) documents all template variables (%project%, %author%, %title%, etc.) and includes a worked example.

New record field kind

Generic addition to ChannelConfigFieldKind / DaemonChannelConfigFieldKind. The descriptor's options array defines the available keys (with labels); the editor renders one optional text input per key. Draft stores the map as a JSON string; assignField parses and filters empty values on save. Any future plugin with a fixed-key Record<string, string> config can use this kind.

中文翻译

更新 — 适配器私有配置字段e10ad1c

为每个适配器补充了剩余的私有配置字段,并新增 record 字段类型以支持编辑器中的结构化键值映射:

GitHub — reasonFilterstring-list

可选的逗号分隔通知原因白名单(如 mention, review_requested, assign)。描述中列出了全部 15 个有效值。留空则处理所有原因。

GitLab — action_prompt_templaterecord,必填)

GitLab 的 action_prompt_templateRecord<string, string>,将动作名映射到 prompt 模板。由于 key 是 9 个已知动作的有限集合,新的 record 字段类型为每个 key 渲染一个带标签的文本输入框,而非原始 JSON 文本域。每个标签包含触发说明(如"被提及 — 评论或描述中 @bot"),中文已完整翻译。仅非空条目写入配置;未配置的动作由适配器跳过。

字段级描述(渲染在最后一个 key 输入框下方)记录了所有模板变量(%project%%author%%title% 等)并附带示例。

新增 record 字段类型

ChannelConfigFieldKind / DaemonChannelConfigFieldKind 的通用扩展。描述符的 options 数组定义可用 key(带标签);编辑器为每个 key 渲染一个可选文本输入框。草稿以 JSON 字符串存储映射;assignField 在保存时解析并过滤空值。未来任何具有固定 key 的 Record<string, string> 配置的插件均可使用此类型。

Comment on lines +12 to +28
management: {
fields: [
{
key: 'token',
label: 'Personal Access Token',
kind: 'secret',
required: true,
envResolvable: true,
},
{
key: 'baseUrl',
label: 'Base URL',
kind: 'string',
envResolvable: true,
},
],
},

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] Missing groupPolicy in the management descriptor makes channels created from the Web Shell silently dead. — Failure scenario: a user creates a GitHub channel from the Web Shell UI, enters a PAT and optional base URL. The channel connects and reports healthy. buildChannelUpsertRequest (channel-editor-state.ts:172) writes only descriptor fields plus senderPolicy, so groupPolicy is absent from the persisted config. GroupGate (GroupGate.ts:13) defaults to 'disabled', which drops every isGroup: true envelope (GroupGate.ts:36-38). All four GitHub adapter envelope sites (GithubAdapter.ts:1005,1055,1099,1249) unconditionally set isGroup: true, so every message is silently dropped before dispatch. The channel polls, marks notifications as read (advancing the cursor), and dispatches nothing. On GitLab the same flow marks todos as done without dispatching them — those todos are permanently lost (data loss).

This differs from the DingTalk/WeCom/Feishu channels this PR follows: those adapters emit both isGroup: true and isGroup: false envelopes, so DM messages bypass the group gate. GitHub and GitLab have no DM path — every message goes through the group gate.

Suggested change
management: {
fields: [
{
key: 'token',
label: 'Personal Access Token',
kind: 'secret',
required: true,
envResolvable: true,
},
{
key: 'baseUrl',
label: 'Base URL',
kind: 'string',
envResolvable: true,
},
],
},
management: {
fields: [
{
key: 'token',
label: 'Personal Access Token',
kind: 'secret',
required: true,
envResolvable: true,
},
{
key: 'baseUrl',
label: 'Base URL',
kind: 'string',
envResolvable: true,
},
{
key: 'groupPolicy',
label: 'Group Policy',
kind: 'enum',
required: true,
options: [
{ value: 'open', label: 'Open' },
{ value: 'allowlist', label: 'Allowlist' },
{ value: 'disabled', label: 'Disabled' },
],
},
],
},

— qwen3.7-max via Qwen Code /review

Comment on lines +12 to +28
management: {
fields: [
{
key: 'token',
label: 'Personal Access Token',
kind: 'secret',
required: true,
envResolvable: true,
},
{
key: 'baseUrl',
label: 'Base URL',
kind: 'string',
envResolvable: true,
},
],
},

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] Same issue as packages/channels/github/src/index.ts — missing groupPolicy field. — Failure scenario: a user creates a GitLab channel from the Web Shell. Same silent-dead channel behavior: GroupGate defaults to 'disabled', dropping every isGroup: true envelope. GitLab adapter emits isGroup: true envelopes (GitlabAdapter.ts:393). Todos are marked done and lost without dispatch. The GitLab adapter even logs a startup warning (GitlabAdapter.ts:81: groupPolicy is "${cfg.groupPolicy ?? 'disabled'}"; must be "open"…) — but this warning goes to stderr, not the Web Shell UI, so the user has no visibility.

Suggested change
management: {
fields: [
{
key: 'token',
label: 'Personal Access Token',
kind: 'secret',
required: true,
envResolvable: true,
},
{
key: 'baseUrl',
label: 'Base URL',
kind: 'string',
envResolvable: true,
},
],
},
management: {
fields: [
{
key: 'token',
label: 'Personal Access Token',
kind: 'secret',
required: true,
envResolvable: true,
},
{
key: 'baseUrl',
label: 'Base URL',
kind: 'string',
envResolvable: true,
},
{
key: 'groupPolicy',
label: 'Group Policy',
kind: 'enum',
required: true,
options: [
{ value: 'open', label: 'Open' },
{ value: 'allowlist', label: 'Allowlist' },
{ value: 'disabled', label: 'Disabled' },
],
},
],
},

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

Review

Reviewed at head e10ad1c (read against refs/pull/8310/head).

Overview

The PR adds management descriptors to the GitHub/GitLab channel plugins, extends the Web Shell allowlist, introduces two new field kinds (string-list, record), makes senderPolicy descriptor-driven in the editor, and adds a record editor UI plus EN/ZH i18n. Frontend state logic is well-tested and the descriptor shapes match what the adapters actually consume (reasonFilter: string[], action_prompt_template: Record<string,string>, allowedUsers: string[]). The type mirrors (channels/base, sdk-typescript/daemon) are updated consistently; webui re-exports the SDK types so there is no third copy to drift.

Blocker — daemon-side validation rejects the new field kinds; GitLab save can never succeed

packages/cli/src/serve/channel-settings-store.tsassertDescriptorValue only knows string | secret | boolean | number | enum:

const valid =
  ((field.kind === 'string' || field.kind === 'secret') && ...) ||
  (field.kind === 'boolean' && ...) ||
  (field.kind === 'number' && ...) ||
  (field.kind === 'enum' && ...);
if (!valid) throw invalidConfig(...);

assertManagedConfig checks descriptor fields before assertSharedField, so declaring allowedUsers in management.fields actually removes the shared-field path that used to accept a string array. Every value of kind string-list or record submitted through the Web Shell now throws channel_settings_invalid_config (HTTP 400):

  • GitHub: upsert fails whenever Allowed Users or Reason Filter is non-empty. Since the editor defaults senderPolicy to allowlist (first enum option), the only savable GitHub config is an allowlist channel with an empty allowlist — i.e. one that accepts nobody.
  • GitLab: upsert always fails. action_prompt_template is required and the frontend validation (isMissingField) demands at least one non-empty template, so the config always contains a record value, which the store then rejects.

Chain: workspace-channel-management.tsChannelManagementService.upsertWorkspaceChannelSettingsStore.upsertassertManagedConfigassertDescriptorValue (channel-settings-store.ts:224-237).

Fix: teach assertDescriptorValue the new kinds — string-list → array of strings; record → object whose values are strings (ideally restricted to the declared options keys). Please also add store-level tests (channel-settings-store.test.ts) that upsert a github config with allowedUsers/reasonFilter and a gitlab config with action_prompt_template — the current tests only cover the frontend state (channel-editor-state.test.ts) and the catalog (channel-registry.test.ts), which is exactly why this gap was not caught.

Medium

  1. PR description is stale. It says only token and baseUrl are surfaced ("Full configuration field exposure will be implemented in a follow-up") and calls this a "non-UI logic change", but the diff ships the full field set, two new field kinds, a record editor UI, and the descriptor-driven senderPolicy refactor. Please update the description and the reviewer test plan (which doesn't mention channel-editor-state.test.ts, the file with the most new coverage).
  2. Editing an existing channel silently rewrites its config. initialFieldValue defaults required enums to the first option. An existing github/gitlab instance configured in settings.json without groupPolicy (unset behaves as disabled — cf. GitlabAdapter's cfg.groupPolicy ?? 'disabled') will be written back with groupPolicy: 'open' after any unrelated edit in the dialog. Consider seeding from the adapter's effective default instead of the first option, or only writing fields the user actually touched.
  3. isMissingField record branch can crash on hand-edited config. Object.values(parsed).every((v) => !v.trim()) throws TypeError if a template value in settings.json is not a string (e.g. a number). Guard with typeof v === 'string' — same for the dialog's record[option.value] ?? '' path.

Minor

  • For descriptor-driven types, the "Access" section heading still renders with no content beneath it (RadioGroup suppressed, pairing block usually hidden). Hide the heading too.
  • descriptor.fields.some((f) => f.key === 'senderPolicy') is repeated four times across the dialog and state helpers — worth a tiny hasDescriptorSenderPolicy(descriptor) helper.
  • Field descriptions are now duplicated between plugin management.fields[].description (English) and web-shell i18n; they will drift silently since i18n wins whenever the key exists. Consider dropping the plugin-side copy or noting the precedence.
  • PLATFORM_MARKS GH/GL are two characters where existing marks are one — double-check .platformMark styling still renders cleanly.
  • GitHub groupPolicy says "Must be 'Open' for notifications to flow" yet offers Allowlist/Disabled as selectable options — accurate to the adapter, but confusing in a creation form.

Positives

  • Good frontend coverage: enum defaults, stored-value round-trip, upsert shape, empty string-list omission, and the descriptor-driven senderPolicy path are all tested.
  • The manageable catalog ordering assertion matches registry insertion order.
  • Malformed record JSON is handled defensively in the dialog (renders empty instead of crashing).

Overall: the frontend half is in good shape, but the daemon validation gap is a hard blocker — as shipped, GitLab management is entirely non-functional and GitHub management only works for configs that don't use the new fields.

The daemon-side store validation only accepted string, secret, boolean,
number, and enum field kinds. Channels declaring string-list or record
fields in their management descriptor (GitHub allowedUsers/reasonFilter,
GitLab action_prompt_template) could never be saved through the Web Shell.

Teach assertDescriptorValue the two new kinds and add store-level tests
covering both acceptance and rejection paths.
- initialFieldValue: for existing instances with an absent enum field,
  return empty string instead of the first option. This forces the user
  to explicitly choose rather than silently writing a new value on save.
- isMissingField: guard Object.values().every() with typeof check so
  hand-edited configs with non-string record values show a validation
  error instead of throwing TypeError.
…mpty Access section

- Extract repeated descriptor.fields.some(f => f.key === 'senderPolicy')
  into a shared hasDescriptorSenderPolicy() helper (was inline ×4).
- Conditionally render the Access section: for descriptor-driven types
  with a non-pairing policy the section would show only a bare heading
  with no content beneath it; now it is omitted entirely.
@OrbitZore

Copy link
Copy Markdown
Collaborator Author

Addressed the second-round review findings (head e10ad1cda16a19):

Blocker — daemon store rejects new field kinds (a62f738)
assertDescriptorValue now validates string-list (array of strings) and record (string-valued object, keys restricted to declared options). Added store-level tests: 4 rejection cases (non-string items, non-array, non-string record value, undeclared record key) + 1 acceptance case.

Medium — silent config rewrite (50b8642)
initialFieldValue returns empty string for enum fields absent from an existing instance's config, instead of defaulting to the first option. The user must explicitly choose; no value is written until they do. New channels still get the first-option default.

Medium — isMissingField record crash (50b8642)
Added typeof v !== 'string' guard so hand-edited configs with non-string record values show a validation error instead of throwing TypeError.

Minor — empty Access section (da16a19)
The Access section (heading + RadioGroup + pairing block) is now omitted entirely when descriptor-driven senderPolicy is active and the effective policy is not pairing.

Minor — repeated expression (da16a19)
Extracted hasDescriptorSenderPolicy(descriptor) helper, replacing 4 inline descriptor.fields.some(…) calls across the dialog and state module.

Not addressed (by design):

  • Field description duplication (plugin vs i18n): i18n takes precedence by design; plugin-side description serves as fallback for non-Web-Shell consumers.
  • PLATFORM_MARKS two-character marks: verified the 36–42px fixed boxes with 12–13px font render two uppercase characters cleanly.
  • groupPolicy description wording: accurately reflects adapter behavior; all three options are valid configurations.

PR description updated to reflect the full scope.

中文翻译

针对第二轮 review 发现进行修复(head e10ad1cda16a19):

Blocker — daemon 存储拒绝新字段类型a62f738
assertDescriptorValue 现在验证 string-list(字符串数组)和 record(字符串值对象,key 限于声明的 options)。新增 store 级测试:4 个拒绝用例(非字符串元素、非数组、record 值非字符串、未声明的 record key)+ 1 个接受用例。

Medium — 静默改写配置50b8642
initialFieldValue 对现有实例配置中缺失的 enum 字段返回空字符串,而非默认取第一个选项。用户必须显式选择;未选择前不写入任何值。新建频道仍取第一个选项作默认。

Medium — isMissingField record 崩溃50b8642
添加 typeof v !== 'string' 防护,手动编辑配置中的非字符串 record 值现在显示验证错误而非抛出 TypeError

Minor — 空 Access 区域da16a19
当描述符驱动的 senderPolicy 生效且有效策略不是 pairing 时,Access 区域(标题 + RadioGroup + pairing 块)整体不渲染。

Minor — 重复表达式da16a19
提取 hasDescriptorSenderPolicy(descriptor) helper,替换对话框和状态模块中 4 处内联 descriptor.fields.some(…) 调用。

未处理(设计如此):

  • 字段描述重复(plugin vs i18n):i18n 按设计优先;plugin 侧 description 作为非 Web Shell 消费者的 fallback。
  • PLATFORM_MARKS 两字符标记:已验证 36–42px 固定方框 + 12–13px 字体渲染两个大写字母无问题。
  • groupPolicy 描述措辞:准确反映适配器行为;三个选项都是有效配置。

PR 描述已更新以反映完整范围。

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

🩺 serve daemon A/B

Built the PR base vs this PR head 1d42fbd, drove a fixed endpoint set against each, and diffed the JSON responses. Only fields that changed are shown.

No response changes against the PR base across 4 scenario(s).

Qwen Code · serve A/B

@wenshao

wenshao commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

Overview

Extends Web Shell channel management to GitHub and GitLab by (1) adding management descriptors to both plugins, (2) teaching the editor two new field kinds (string-list, record) plus descriptor-driven senderPolicy, and (3) extending assertDescriptorValue in the daemon store to accept those kinds. Also fixes a real bug: editing an existing channel no longer silently defaults a missing required enum to the first option.

The layering is right — descriptor is the single source of truth, the store validates independently of the client, and the legacy hardcoded pairing/open radio group is preserved for DingTalk/WeCom/Feishu behind hasDescriptorSenderPolicy. Confirmed by grep that every field the descriptors expose is actually read by its adapter (reasonFilter, baseUrl, allowedUsers, senderPolicy in GithubAdapter.ts; action_prompt_template, groupPolicy, baseUrl in GitlabAdapter.ts), and that field.kind has exactly two consumer sites, both updated. No dead switches.


Findings

Major — GitLab action_prompt_template option list is closed, but the adapter's action set is open

assertDescriptorValue rejects any record key not in field.options:

Object.keys(value).every((k) => field.options!.some((opt) => opt.value === k))

The GitLab descriptor declares 9 actions. GitlabAdapter.resolveTemplate does a plain templates[actionName] lookup with no whitelist — it accepts any GitLab todo action_name. GitLab ships more than 9: attention_requested, member_access_requested, okr_checkin_requested, added_approver, review_submitted, etc.

Failure path: a user hand-writes action_prompt_template: { mentioned: "...", attention_requested: "..." }, then opens the channel in Web Shell and changes anything at all. initialFieldValue round-trips the whole object through JSON.stringify, the editor renders only the 9 declared rows, updateRecord spreads record so the undeclared key survives, assignField keeps it (it's a non-empty string), and the daemon returns 400 Channel field "action_prompt_template" has an invalid value. — naming a key the UI never showed. There is no way to fix it from the UI.

Not a regression (GitLab wasn't manageable before, so upsert previously threw channel_settings_unmanageable), but it makes the new feature unusable against a class of legitimate configs.

Pick one:

  • Have assertDescriptorValue accept any string key for record and treat options as UI hints only (matches the adapter's actual contract).
  • Or preserve-and-passthrough: drop undeclared keys from the rendered set but carry them untouched, and let the store allow keys already present in previous (the same escape hatch assertManagedConfig already gives non-manageable fields via isDeepStrictEqual(previous[key], value)).

I'd take the first — a closed enum here encodes a GitLab-side list that will drift.

Minor — JSON.parse results used without a shape guard (3 sites)

ChannelEditorDialog record branch, isMissingField, and assignField all do JSON.parse(value) as Record<string, string> and immediately index or Object.values() it. For "null" that's null[key] / Object.values(null) → TypeError, which in the dialog takes down the whole render.

Currently unreachable — every write path goes through JSON.stringify of an object and initialFieldValue only stringifies when value && typeof value === 'object' && !Array.isArray(value). But the catch blocks show the code already treats this string as untrusted, and the guard is one line. Reuse the isRecord shape already used in initialFieldValue.

Minor — new-channel enum default is decided by option ordering, and it's security-relevant

initialFieldValue now returns field.options?.[0]?.value for new drafts. For GitHub that means a brand-new channel is created with groupPolicy: 'open', whereas the runtime default when the key is absent is disabled (packages/channels/base/src/types.ts, groupPolicy: GroupPolicy; // default: "disabled").

The net posture isn't wide open — senderPolicy defaults to allowlist with an empty allowedUsers — so this is a correctness-of-intent nit, not an exposure. But an access-control default should not be an emergent property of array order in a plugin file. Either add an explicit default to ChannelConfigFieldDescriptor and key off that, or order options safest-first and say so in a comment.

Minor — reasonFilter accepts free text; invalid values fail at channel start, not in the form

normalizeReasonFilter throws on unrecognized reasons. The valid set is closed and already enumerated (in the descriptor description, the EN string, and the ZH string — three copies). A typo in the comma list produces a channel that won't start, with the error only in daemon logs. A multi-select (or reusing options on a string-list as a checkbox group) would move that to form validation. Reasonable to defer, but worth a follow-up issue.

Minor — PLATFORM_MARKS[platform.type] ?? platform.displayName[0]

displayName[0] is undefined for an empty displayName (renders nothing) and doesn't uppercase. platform.displayName[0]?.toUpperCase() ?? '?' is the same length and can't render blank.


Design / consistency

  • Two senderPolicy mechanisms now coexist, selected by hasDescriptorSenderPolicy. The bridge is clean and the tests pin it, but it's carrying cost: the legacy radio group can only express pairing/open, so DingTalk/WeCom/Feishu still can't be set to allowlist from the UI even though assertSharedField accepts it. Worth a follow-up that moves all five onto descriptor-declared senderPolicy and deletes ChannelEditorDraft.senderPolicy entirely.
  • groupPolicy / senderPolicy / allowedUsers are shared ChannelConfig fields, already validated by assertSharedField. Declaring them per-plugin means the descriptor now shadows shared validation and ~40 lines get copy-pasted into every plugin that wants them exposed (already duplicated between github/src/index.ts and gitlab/src/index.ts). Consider exporting a SHARED_ACCESS_FIELDS descriptor fragment from @qwen-code/channel-base and spreading it. Note the descriptor's enum options for senderPolicy happen to match assertSharedField's set exactly today — a shared constant would keep them from drifting.
  • Label/description text now lives in three places (descriptor, EN, ZH) and has already drifted: descriptor Base URL vs EN API Base URL / Instance URL; descriptor Allowed Users vs EN Allowed Users (comma-separated). Non-Web-Shell consumers see the descriptor label. Either make the descriptor label the fallback-only value and keep UI wording in i18n, or sync them.
  • The translated !== key sniff in fieldDescription works (t() is messages[key] ?? EN[key] ?? key), but it's a sniff. A hasMessage(key) export would say what it means.
  • Storing a record as a JSON string inside values: Record<string, string | boolean> is the root cause of the two findings above. Widening the draft value type to string | boolean | Record<string, string> removes the encode/decode on every keystroke.
  • Style nit: the Access section is now a JSX IIFE. Hoisting descriptorPolicy / showRadioGroup / showPairing above the return (or extracting an <AccessSection>) reads better and matches the rest of the file.

Test coverage

Good density at the state layer, and the store tests correctly cover both accept and reject for the new kinds. Gaps:

  • ChannelEditorDialog.test.tsx is untouched. The riskiest new code — the record editor, the description paragraph, and the conditional Access section (hidden for a descriptor-driven non-pairing policy, shown for pairing) — has zero render-level coverage even though the render test file already exists.
  • No test drives the real GitHub/GitLab descriptors through the store validator. channel-settings-store.test.ts uses a synthetic management-validation-test plugin, and channel-editor-state.test.ts uses a hand-written GITHUB fixture that omits reasonFilter. Nothing asserts that what buildChannelUpsertRequest emits for the actual shipped descriptors survives assertManagedConfig. That's exactly the descriptor/validator drift the PR description flags as the main risk — one test closes it.
  • No test for the undeclared-record-key case above.

Security

  • Token handling is correct: declared kind: 'secret', so upsert rejects it inline in config and forces an explicit preserve/replace/clear, matching DingTalk's clientSecret. envResolvable: true gives the $VAR escape hatch. (Secrets still land plaintext in workspace settings.json — pre-existing, not introduced here.)
  • Making these plugins manageable means anyone with Web Shell access to the workspace can mint a channel that acts with a GitHub/GitLab PAT. That's the intended feature and it goes through the same channel-management-service auth as the existing flows — noting it only so it's a conscious call.
  • assertDescriptorValue's string-list branch correctly requires every item to be a string, so allowedUsers can't be poisoned with non-strings that would slip past GithubAdapter's .toLowerCase() normalization.

Verdict

Solid, well-layered work with a genuine bug fix bundled in. The GitLab record-key finding should be resolved before merge — it silently bricks editing for a real class of configs. Everything else is follow-up material.

record field options are UI hints for which rows to render, not a closed
set. The GitLab adapter resolves action_prompt_template by plain key
lookup and accepts any GitLab todo action_name, but the store rejected
keys outside the 9 declared options — silently bricking editing of
configs that use server-side actions the descriptor doesn't enumerate.
- Guard the three record JSON.parse sites with an isRecord shape check
  so a non-object payload can't throw during render or upsert.
- Add an explicit descriptor `default` for enum fields and prefer it over
  option order when seeding a new channel, so an access-control default is
  declared intent rather than an emergent property of array order.
- Declare the GitHub reasonFilter reasons as options and validate
  string-list input against them in the form, surfacing typos before
  submission instead of failing at channel start.
- Fall back to an uppercased display-name initial (or '?') for the
  platform mark so an empty name can't render blank.
@OrbitZore

Copy link
Copy Markdown
Collaborator Author

Thanks for the thorough review! Pushed two commits addressing the merge blocker and the minor findings:

Merge blocker (record key validation) — fixed in 1275ac87a. Took your preferred option: assertDescriptorValue now accepts any string key for record fields and treats options as UI hints only, matching the adapter's open key lookup. The store still requires every value to be a string. The accept case in the store tests now covers an undeclared key.

Minor findings — fixed in be88732e7:

  • JSON.parse shape guard: all three record parse sites now check an isRecord shape before use, so a non-object payload can't throw during render or upsert.
  • Enum default: added an explicit default to the field descriptor (base + SDK types) and the editor prefers it over option order when seeding a new channel; GitHub/GitLab groupPolicy now declare default: 'open' explicitly.
  • reasonFilter: the valid reasons are now declared as options and the editor validates string-list input against them, so a typo is flagged in the form before submission instead of failing at channel start.
  • Platform mark fallback: now uppercases the display-name initial and falls back to ?, so an empty name can't render blank.

Added editor-state tests for the explicit enum default and the string-list validation.

The design/consistency suggestions and the render-level test coverage are follow-up material per your verdict — tracked for a separate follow-up rather than this PR.

中文翻译

感谢细致的 review!推了两个 commit,处理 merge blocker 和 minor findings:

Merge blocker(record key 校验) — 在 1275ac87a 修复。采用你推荐的方案:assertDescriptorValue 现在对 record 字段接受任意 string key,options 仅作 UI hint,与 adapter 的开放 key 查找一致。store 仍要求每个 value 是 string。store 测试的 accept 用例现在覆盖了未声明 key。

Minor findings — 在 be88732e7 修复:

  • JSON.parse shape guard:三个 record 解析点现在都先做 isRecord 形状检查再使用,非对象 payload 不会在渲染或 upsert 时抛错。
  • Enum 默认值:给字段 descriptor 加了显式 default(base + SDK 类型),编辑器新建频道时优先用它而非 option 顺序;GitHub/GitLab 的 groupPolicy 现在显式声明 default: 'open'
  • reasonFilter:合法 reason 现在声明为 options,编辑器对 string-list 输入做校验,拼写错误会在表单里提交前标红,而不是等频道启动才失败。
  • 平台标记 fallback:现在大写显示名首字母并兜底 ?,空名不会渲染空白。

为显式 enum 默认值和 string-list 校验补了 editor-state 测试。

设计/一致性建议和渲染层测试覆盖按你的结论属于 follow-up 材料——记录到单独的后续处理,不在本 PR。

@wenshao

wenshao commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

Maintainer verification — real stack, live daemon + browser

I ran this PR on a fully real stack (PR-source daemon + Vite Web Shell + real Chromium), against fake GitHub/GitLab API servers and a recording OpenAI-compatible model server, so the "N/A — depends on live daemon integration" row in the test plan is now filled in. This also covers the 🍏 macOS row the PR marked ⚠️.

Verdict: works end-to-end, no blockers found. Two minor issues below (one wrong error message, one validation asymmetry) — neither blocks merge.

Setup

PR head be88732
Base (merge-base) 5799b90
Platform macOS 26.6 (arm64), Node v24.18.1
Stacks PR daemon :8410 + Vite :5510 · base daemon :8411 + Vite :5511 · mutant daemon :8412
Fakes fake GitHub API :19301, fake GitLab API :19302, recording OpenAI server :19999
Isolation dedicated QWEN_HOME + fixture workspace per stack

Both packages/channels/{base,github,gitlab} were rebuilt in each worktree (the daemon loads channel plugins from dist, not source).


1. Before / after — GitHub and GitLab become manageable

GET /workspace/channel-types on each daemon:

base   github fields= (none)          gitlab fields= (none)
PR     github fields= token:secret, baseUrl:string, groupPolicy:enum,
                      senderPolicy:enum, allowedUsers:string-list, reasonFilter:string-list
       gitlab fields= token:secret, baseUrl:string, groupPolicy:enum,
                      senderPolicy:enum, allowedUsers:string-list, action_prompt_template:record
Before (5799b90) After (this PR)

2. Both new field kinds render and round-trip

string-list renders as a comma-separated input; record renders as one labelled input per declared option key (note: the PR description still says "JSON textarea" — the head commit replaced that with per-key inputs, which is better; the description is just stale).

Saving from the UI produced HTTP 200 and the correct JSON shapes on disk — arrays for string-list, an object for record:

"gh-verify": { "type": "github", "baseUrl": "http://127.0.0.1:19301",
  "groupPolicy": "open", "senderPolicy": "allowlist",
  "allowedUsers": ["operator-alice", "Operator-Bob"],
  "reasonFilter": ["mention", "review_requested"] }

"gl-verify": { "type": "gitlab", "baseUrl": "http://127.0.0.1:19302",
  "action_prompt_template": { "mentioned": "PR8310-VERIFY project=%project% ..." } }

Reopening the editor rebuilds the draft correctly (comma-joined lists, populated record rows, Open/Allowlist selected, secret shown as Stored securely with Keep/Replace):

3. The descriptors match what the adapters actually consume

This is test-plan item 4, verified at runtime rather than by reading. I started each channel from the Web Shell and watched the fake APIs.

GitHub — the fake API received:

GET /user                                 auth= token ghp_pr8310_verify_token
GET /notifications                        auth= token ghp_pr8310_verify_token
PUT /notifications                        auth= token ghp_pr8310_verify_token
GET /repos/acme/widgets/issues/7/comments auth= token ghp_pr8310_verify_token
GET /repos/acme/widgets/issues/7          auth= token ghp_pr8310_verify_token

The token typed into the secret field is the one sent; baseUrl is where requests go. The fake served two notifications — #7 (reason mention, in the filter) and #9 (reason subscribed, not in it). Only #7 was fetched, and the daemon logged:

[Channel:gh-verify] skipping notification (reason=subscribed not in reasonFilter,
                    subject=http://127.0.0.1:19301/repos/acme/widgets/issues/9)

So string-list → comma input → array in settings → normalizeReasonFilter is consistent across all four layers.

GitLab — I configured only the mentioned template via the UI, then served two todos (mentioned and build_failed). The mentioned one ran the full lane — 👀 award emoji → model call → reply note — while build_failed was marked done without dispatch:

POST /api/v4/projects/acme%2Fwidgets/issues/7/notes/1010/award_emoji   {"name":"eyes"}
POST /api/v4/projects/acme%2Fwidgets/issues/7/notes                    {"body":"Acknowledged by ..."}
POST /api/v4/todos/110/mark_as_done          ← mentioned  (processed)
POST /api/v4/todos/111/mark_as_done          ← build_failed (skipped, no template)

And the template reached the model prompt fully interpolated — captured from the recording model server:

[operator-alice]  please handle this (mentioned)

PR8310-VERIFY project=acme/widgets author=operator-alice iid=7 title=Issue 7 — mentioned

That is the record field kind proven end-to-end, from keystroke to model prompt.

4. The store validation change is load-bearing

I reverted only packages/cli/src/serve/channel-settings-store.ts to base on an otherwise-unchanged PR tree and replayed the exact payloads the PR's own UI produces:

Payload Mutant daemon PR daemon
allowedUsers: ["operator-alice"] (string-list) 400 Channel field "allowedUsers" has an invalid value. 200
action_prompt_template: {"mentioned":"T"} (record) 400 Channel field "action_prompt_template" has an invalid value. 200

Rejection paths still work on the PR daemon: allowedUsers: [1,2] → 400, action_prompt_template: {"mentioned": 42} → 400.

5. Regressions and edge cases checked

  • Legacy channels untouched — the DingTalk editor renders identically on base and PR (same dialog dimensions, exactly matching innerText), Access Policy pairing/open radio group intact.
  • Missing required enum — I hand-removed groupPolicy from a saved config; the editor shows the field empty (not silently defaulted) and blocks save with "Group Policy is required." Exactly as claimed.
  • No silent config loss — a hand-written action_prompt_template key that isn't in the descriptor's options (custom_hand_written) survives an editor open→edit→save round-trip untouched. This is the failure mode I most expected from a descriptor-driven record editor; it does not happen.
  • Chinese locale — new labels/descriptions are translated.

6. Test plan, lint, typecheck

Command Result
packages/web-shellvitest run components/channels/ ✅ 5 files, 56 passed
packages/clivitest run channel-settings-store.test.ts channel-registry.test.ts 38 passed
eslint over all 15 changed files, --max-warnings 0 ✅ clean
typecheck — cli, web-shell, sdk-typescript ✅ clean
build — channels/base, channels/github, channels/gitlab ✅ clean

Findings (minor, non-blocking)

① Wrong error message for the new string-list option validation.

validateChannelEditorDraft reuses the 'invalid' code for out-of-range string-list tokens, but validationMessage maps 'invalid' to channels.editor.validation.invalidName"Choose a different instance name." So typing a bad reason shows an instance-name error under Reason Filter. The save is correctly blocked and the field turns red, but the copy misdirects the user. Same in zh ("请使用其他实例名称。").

Suggested fix — a distinct code + message, e.g. 'invalidOption'"Remove values that aren't in the allowed list."

string-list / record option keys aren't enforced server-side (and the PR description says they are).

The description states record keys are "restricted to declared options", but assertDescriptorValue only checks that values are strings. Live against the PR daemon:

  • action_prompt_template: {"not_a_real_action": "T"}200
  • reasonFilter: ["totally_bogus_reason"]200, and the channel then dies at connect:
    [Channel] Failed to connect "gh-undeclared": Unrecognized reasonFilter values ... totally_bogus_reason

enum is checked against options in the same function, so this is an asymmetry. Only reachable via the raw API — the editor blocks it (finding ①) — so it's hardening, not a bug. Either extend the check to options for these two kinds, or drop the "keys restricted to declared options" claim from the description.

③ Nit: enum option labels (Open / Allowlist / Disabled / Pairing) stay English under lang=zh, since option.label comes from the descriptor. Probably intentional (they mirror literal config values) — flagging only for awareness.


中文版本

维护者验证 —— 真实环境(真实 daemon + 浏览器)

我在完全真实的栈上跑了这个 PR(PR 源码起的 daemon + Vite Web Shell + 真实 Chromium),配合伪造的 GitHub/GitLab API 服务和一个会记录请求的 OpenAI 兼容模型服务,把测试计划里 "N/A —— 依赖 live daemon 集成" 那一项补上了。同时也覆盖了 PR 中标为 ⚠️ 的 🍏 macOS 一栏。

结论:端到端可用,没有阻塞性问题。 下面两个小问题(一处错误文案、一处校验不对称)都不影响合并。

环境

PR head be88732
Base(merge-base) 5799b90
平台 macOS 26.6(arm64),Node v24.18.1
PR daemon :8410 + Vite :5510 · base daemon :8411 + Vite :5511 · 变异 daemon :8412
伪服务 伪 GitHub API :19301、伪 GitLab API :19302、记录型 OpenAI 服务 :19999
隔离 每个栈独立的 QWEN_HOME + fixture 工作区

每个 worktree 都重新构建了 packages/channels/{base,github,gitlab}(daemon 从 dist 而非源码加载频道插件)。

1. 前后对比 —— GitHub / GitLab 变为可管理

两个 daemon 的 GET /workspace/channel-types:base 上 github、gitlab 的 fields 都为空;PR 上分别暴露了 6 个字段(含 string-listrecord)。截图见英文版对照表。

2. 两种新字段类型渲染与往返正常

string-list 渲染为逗号分隔输入框;record 渲染为每个声明 option key 一个带标签的输入框。注意:PR 描述里仍写的是 "JSON 文本域",head 提交已改成按 key 分行的输入框(更好),描述文案过期了。

从 UI 保存返回 HTTP 200,落盘结构正确 —— string-list 是数组、record 是对象。重新打开编辑器能正确还原(逗号拼接、record 行填充、Open/Allowlist 选中、密钥显示为 Stored securely 并提供 Keep/Replace)。

3. 描述符与适配器实际消费的配置一致

这是测试计划第 4 项,我用运行时验证而非阅读代码。从 Web Shell 启动频道后观察伪 API:

GitHub —— UI 里填的 token 就是实际发出的 Authorization: token ...baseUrl 就是请求去向。伪服务返回两条通知:#7(reason mention,在过滤器内)与 #9(reason subscribed,不在)。只有 #7 被拉取评论,daemon 日志:

[Channel:gh-verify] skipping notification (reason=subscribed not in reasonFilter, ...issues/9)

string-list → 逗号输入 → settings 数组 → normalizeReasonFilter 四层完全一致。

GitLab —— 我只通过 UI 配置了 mentioned 模板,然后投喂两条 todo(mentionedbuild_failed)。mentioned 走完了整条链路(👀 表态 → 调模型 → 回帖),build_failed 被直接 mark_as_done 未派发。而且模板变量已完整插值送达模型 prompt:

PR8310-VERIFY project=acme/widgets author=operator-alice iid=7 title=Issue 7 — mentioned

这就是 record 字段类型从键盘输入到模型 prompt 的端到端证明。

4. 存储层校验改动确实是必需的

我在其余不变的 PR 树上packages/cli/src/serve/channel-settings-store.ts 回退到 base,然后重放 PR 自己的 UI 产生的请求体:

请求体 变异 daemon PR daemon
allowedUsers: ["operator-alice"]string-list 400 200
action_prompt_template: {"mentioned":"T"}record 400 200

PR daemon 上拒绝路径也正常:allowedUsers: [1,2] → 400,action_prompt_template: {"mentioned": 42} → 400。

5. 回归与边界检查

  • 旧频道未受影响 —— DingTalk 编辑器在 base 与 PR 上渲染一致(对话框尺寸相同、innerText 完全一致),Access Policy 的 pairing/open 单选组保留。
  • 必填 enum 缺失 —— 我手工删掉已保存配置里的 groupPolicy,编辑器该字段显示为(未静默填默认值),保存被拦截并提示 "Group Policy is required.",与 PR 描述一致。
  • 不会静默丢配置 —— 手写的、不在描述符 options 里的 action_prompt_template key(custom_hand_written)在「打开→编辑→保存」往返后原样保留。这是描述符驱动 record 编辑器最容易出的问题,此处没有发生。
  • 中文本地化 —— 新增标签与说明均已翻译。

6. 测试计划、lint、typecheck

命令 结果
packages/web-shellvitest run components/channels/ ✅ 5 个文件,56 通过
packages/clivitest run channel-settings-store.test.ts channel-registry.test.ts 38 通过
eslint 覆盖全部 15 个改动文件,--max-warnings 0 ✅ 无告警
typecheck —— cli、web-shell、sdk-typescript ✅ 通过
build —— channels/base、channels/github、channels/gitlab ✅ 通过

发现的问题(次要,不阻塞)

① 新增 string-list option 校验复用了错误的文案。

validateChannelEditorDraft 对越界的 string-list token 复用了 'invalid' 码,而 validationMessage'invalid' 映射到 channels.editor.validation.invalidName —— "请使用其他实例名称。"。于是在 Reason Filter 下面会显示一条关于实例名的错误。保存确实被正确拦截、字段也标红了,但提示文案误导用户。中英文都一样。

建议:新增独立的错误码与文案,例如 'invalidOption'"请移除不在允许列表中的值。"

string-list / record 的 option key 服务端未强校验(而 PR 描述说校验了)。

描述里写 record 的 key "限于声明的 options",但 assertDescriptorValue 只检查值是字符串。对 PR daemon 实测:

  • action_prompt_template: {"not_a_real_action": "T"}200
  • reasonFilter: ["totally_bogus_reason"]200,随后频道在 connect 阶段直接失败:
    [Channel] Failed to connect "gh-undeclared": Unrecognized reasonFilter values ... totally_bogus_reason

同一个函数里 enum 是会对 options 做校验的,所以这是一处不对称。只能通过裸 API 触发(编辑器会拦,见问题 ①),属于加固而非缺陷。建议二选一:把 options 校验扩展到这两种类型,或从描述里去掉 "key 限于声明的 options" 的说法。

③ 小提示: lang=zh 下 enum 的选项标签(Open / Allowlist / Disabled / Pairing)仍是英文,因为 option.label 来自描述符。大概率是有意为之(它们对应字面配置值),仅作提醒。

…n errors

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@OrbitZore

Copy link
Copy Markdown
Collaborator Author

Thanks for the thorough real-stack verification! Addressing the findings:

① Wrong string-list validation message — Fixed in 1d42fbdda. Added a distinct invalidOption validation code so out-of-range string-list tokens no longer surface the instance-name message; new copy: "Remove values that aren't in the allowed list." / "请移除不在允许列表中的值。"

② Server-side option-key enforcement — Went with your second option (description fix) rather than extending the check:

  • record: enforcing declared keys server-side would reject configs whose action_prompt_template contains hand-written keys — exactly the "no silent config loss" round-trip you verified in item 5 — and would block forward-compatible keys for actions a future GitLab adapter supports before the descriptor catches up. GitlabAdapter tolerates unknown keys (unmatched templates are simply skipped), so the store now deliberately does too.
  • string-list: the editor and normalizeReasonFilter both match case-insensitively (trim().toLowerCase()), while the store's assignField only trims — a strict options check there would reject input the editor accepts (e.g. Mention). The adapter's connect-time rejection remains the authoritative check for this kind.

The description now states this layering explicitly and drops the "keys restricted to declared options" claim (also corrected the stale "JSON textarea" wording — it's per-key inputs).

③ English enum labels under lang=zh — Intentional, as you guessed: option.label mirrors the literal config values. Leaving as-is.

中文翻译

感谢细致的实机验证!逐项回应:

string-list 校验文案错误 — 已在 1d42fbdda 修复。新增独立的 invalidOption 校验码,越界的 string-list 取值不再复用实例名文案;新文案:"Remove values that aren't in the allowed list." / "请移除不在允许列表中的值。"

② 服务端 option key 强校验 — 采纳你的第二个建议(改描述),而非扩展校验:

  • record:若服务端强制声明 key,会拒绝含手写 key 的 action_prompt_template 配置——正是你 item 5 验证的 "no silent config loss" 往返——并阻断前向兼容 key(未来 GitLab 适配器支持、描述符尚未跟上的新 action)。GitlabAdapter 对未知 key 是宽容的(未匹配的模板直接跳过),所以 store 现在也有意如此。
  • string-list:编辑器和 normalizeReasonFilter 都大小写不敏感匹配(trim().toLowerCase()),而 store 的 assignField 只 trim——在那里做严格 options 校验会拒绝编辑器接受的取值(如 Mention)。此类型以适配器 connect 阶段的拒绝为权威校验。

描述现已明确说明这一分层,删除 "keys restricted to declared options" 声称(顺带修正过期的 "JSON textarea" 措辞——实为按 key 分行输入框)。

lang=zh 下 enum 标签仍为英文 — 如你所猜是有意为之:option.label 对应字面配置值。保持现状。

@wenshao

wenshao commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

Sandboxed verification: ✅ passed — merge-ready (agent verdict) - workflow run

Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check.

Scripted assertions: 57 passed · 0 failed · 57 total

中文 — 判定:✅ 通过 · 可合入(agent 判定)

沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查

脚本断言:57 通过 · 0 失败 · 57 总计

Verification report

PR 8310 — deep verification

Verdict: merge-ready — assertions 57 pass / 0 fail / 57 total (verifier harnesses; base-arm reject-expectations encoded as passes). Verified head OID 1d42fbdda527d432499d66571f1d90dc8db732f1 (git rev-parse HEAD^2). No blocking finding. One non-blocking maintenance advisory.

中文摘要
  • 结论:merge-ready。核心改动(守护进程 assertDescriptorValue 新增 string-listrecord 两种字段校验)经 A/B 证明为 load-bearing:用真实 github/gitlab 插件描述符驱动真实 WorkspaceChannelSettingsStore.upsert,HEAD 上 github 的 allowedUsers/reasonFilter(string-list)与 gitlab 的 action_prompt_template(record)upsert 成功并落盘,BASE(仅回退该 8 行 hunk)则返回 channel_settings_invalid_config;描述符被剥离的对照两臂均为 unmanageable,仅 enum/secret 的 legacy 对照两臂均成功——证明回退是外科手术式的、改动确实承载了行为。见证:01-ab-head-store.png / 02-ab-base-store.png
  • A/B 结论:见下表「Central claim」。判别单元 2/2 翻转(REJECT→SUCCESS),对照 3/3 两臂一致。
  • 非空性:回退该 hunk 后,PR 新增的 accepts string-list and record descriptor fields 测试以预期断言失败(Channel field "tags" has an invalid value.),其余 36 个(含三条 reject)两臂皆绿——该测试确实钉住了改动(03-vacuity-store-test.png)。
  • 次级声明:编辑器 record/string-list/enum 边界(非字符串 record 值、畸形 JSON、数组型 record、未声明 key 往返、必填 enum 不再静默写首选项)经公开 API 9/9 通过(04-editor-record-boundaries.png);reasonFilter 描述符 options 与适配器 KNOWN_NOTIFICATION_REASONS 集合相等(15==15,零漂移)。
  • 门禁:cli 存储测试 37/37、channel-registry 1/1、web-shell channels 目录 56/56(PR 自有,6 文件含我临时探针时为 65/65)、受影响 workspace typecheck 全绿。
  • 未覆盖:逐 commit 归因(shallow,元数据 16 commits 仅 1 可达);带真实 daemon 的 Web Shell 实时渲染(本 lane 无浏览器/daemon;但 dialog 渲染路径已被 PR 自带 ChannelEditorDialog.test.tsx 在门禁中覆盖);pairing 噪声流(PR 自声明 out of scope);仓库级 eslint/format(PR 自带 CI 覆盖)。
  • findings:无阻塞项;仅一条非阻塞维护建议(reasonFilter 合法值在描述符与适配器中各存一份,当前一致,见下)。

Central claim + A/B

Central claim: the daemon channel-settings store accepts the two new descriptor field kinds (string-list, record) so that a GitHub/GitLab channel upsert carrying those fields succeeds instead of returning HTTP 400 (channel_settings_invalid_config). The frontend editor and the github/gitlab management descriptors are the surfaces that produce those values; the store hunk is the load-bearing gate.

Harness harness/verify-pr8310-ab.test.ts registers the real @qwen-code/channel-github / @qwen-code/channel-gitlab plugin objects (under synthetic type names so the real builtins stay untouched) and drives the real WorkspaceChannelSettingsStore.upsert against a temp settings.json, reading the file back as the oracle. It ran twice: VERIFY_ARM=head (PR store) and VERIFY_ARM=base (the 8-line assertDescriptorValue hunk reverted in a scratch edit, then restored). Mock-free w.r.t. the unit under test: store, descriptors, and settings I/O are all production code.

Cell descriptor oracle (observed) HEAD BASE (control)
github string-list upsert (allowedUsers,reasonFilter) github full upsert outcome + written fields SUCCESSallowedUsers=['alice','bob'], reasonFilter=['mention','assign'], groupPolicy='open', senderPolicy='allowlist', token written REJECT channel_settings_invalid_config
gitlab record upsert (action_prompt_template) gitlab full upsert outcome + written record SUCCESS — record written verbatim REJECT channel_settings_invalid_config
github descriptor stripped none error code REJECT unmanageable REJECT unmanageable
gitlab descriptor stripped none error code REJECT unmanageable REJECT unmanageable
github enum/secret-only (control) github full upsert outcome SUCCESS SUCCESS

The two discriminating cells flip REJECT→SUCCESS; the three controls are identical on both arms, which proves the revert is surgical (it changes only the new-kinds acceptance, not the unmanageable path or the pre-existing enum/secret path). Witness captures: 01-ab-head-store.png (head, 6/6) and 02-ab-base-store.png (base, 5/5). The base-arm "expect REJECT" rows are encoded as passing assertions (the control is supposed to go red), so they contribute to pass, not fail.

Type-boundary matrix (head store only, 7 rows, all matched expectation — witness in 01-ab-head-store.png):

input shape field expected observed
['a', 1] (non-string item) string-list REJECT REJECT invalid_config
'alice' (not an array) string-list REJECT REJECT invalid_config
[] (empty array) string-list ACCEPT ACCEPT
['x'] (array, not object) record REJECT REJECT invalid_config
{mentioned: 123} (non-string value) record REJECT REJECT invalid_config
{mentioned:'x', brand_new_action:'y'} (undeclared key) record ACCEPT ACCEPT
{} (empty object) record ACCEPT ACCEPT

The undeclared-key ACCEPT confirms the PR's stated design (store accepts undeclared record keys for forward-compat / hand-edited configs); the empty-array / empty-object ACCEPTs are consistent with the field being optional (github allowedUsers/reasonFilter) or, for the required gitlab record, with the editor — not the store — owning the "at least one template" rule (verified in the editor probe below).

Secondary claim — editor record/string-list/enum boundaries

Harness harness/verify-pr8310-editor.test.ts drives the public editor API (createChannelEditorDraft / validateChannelEditorDraft / buildChannelUpsertRequest) on shapes the PR's own tests leave implicit. 9/9 pass (witness 04-editor-record-boundaries.png):

  • record round-trips through its JSON draft representation and back to an object on build;
  • undeclared record keys survive an editor round-trip (the dialog renders only declared option rows, but updateRecord spreads the parsed record, so a hand-written brand_new_server_action is preserved — matches the store's accept-undeclared design end to end);
  • hand-edited non-string record values ({mentioned:123, assigned:{nested:true}}) do not crash the editor: isMissingField's record guard treats them as missing → required, and assignField drops the all-invalid record; a partial record ({mentioned:123, assigned:'ok'}) keeps the valid entry and drops the invalid one;
  • malformed record JSON → required, dropped on build (no crash);
  • an array-typed record (hand-edited) coerces to empty draft, no crash;
  • string-list validation is case-insensitive ('Alice, BOB' passes) while build preserves original case (['Alice','BOB']) — correct, because the GitHub adapter lowercases both the stored reasonFilter and incoming notification reasons at runtime (so no silent filter-drop);
  • a required enum missing on an existing instance renders empty and fails required (the "no silent first-option rewrite" fix), while a new channel defaults a required enum to its declared default (groupPolicy→'open') or first option (senderPolicy→'allowlist').

Secondary claim — predicate consistency (reasonFilter)

The editor validates string-list tokens against the descriptor's options; the GitHub adapter validates the same values against KNOWN_NOTIFICATION_REASONS at connect time and throws on unknowns. These are two copies of one allow-list in two packages. Harness harness/verify-pr8310-seteq.test.ts lifts both sets (descriptor options from the loaded plugin; the adapter set parsed verbatim from GithubAdapter.ts) and asserts set-equality: descriptor = 15, adapter = 15, only-in-descriptor = ∅, only-in-adapter = ∅ (witness in set-equality.log). They currently agree, so a value that passes the editor cannot throw at connect. See the advisory below for the maintenance implication.

Vacuity check on the new store test

Reverting only the 8-line assertDescriptorValue hunk and running the PR's own channel-settings-store.test.ts turns exactly one test red — accepts string-list and record descriptor fields — with the intended behavioural message Channel field "tags" has an invalid value. (not an import/fixture break); the other 36, including the three new reject cases, stay green on both arms. Witness 03-vacuity-store-test.png. Conclusion: the new "accepts" test is non-vacuous and pins the change; the three reject tests pin the negative shapes but do not discriminate head from base (expected — they reject on both arms). Hunk restored; tree clean.

Targeted gates (PR's own suites + typecheck)

gate result
packages/cli channel-settings-store.test.ts 37/37 pass
packages/cli channel-registry.test.ts 1/1 pass (loads real plugins; github+gitlab manageable with secret/enum/string-list shapes)
packages/web-shell client/components/channels/ 56/56 pass across 5 files (incl. ChannelEditorDialog.test.tsx, which renders the record/string-list UI via RTL)
typecheck (cli, web-shell, sdk-typescript) pass (root tsc --noEmit clean; channels packages compiled by the HEAD build)

(Counts measured at the verified head. The PR body's "54" web-shell / "38" store figures are from an earlier commit in the 16-commit branch; the suites are green at the verified head — this is a stale prose count, not a code discrepancy.)

Advisory (non-blocking)

  • Duplicated reasonFilter allow-list. The 15 valid reasons live in two places — the github descriptor's reasonFilter.options (editor validation) and KNOWN_NOTIFICATION_REASONS in GithubAdapter.ts (connect-time validation). They are currently identical (set-equality probe above), so there is no user-visible defect today. But they are two independently-edited copies of one predicate; a future reason added to the adapter without the descriptor (or vice-versa) would let a value pass the editor and then throw Unrecognized reasonFilter values at connect, or be silently un-selectable in the UI. Suggestion only: derive one from the other (e.g. build the descriptor options from KNOWN_NOTIFICATION_REASONS, or export the set from a shared module). Non-blocking — flagged for awareness, not as a merge condition.

No blocking findings.

Not covered

  • Per-commit attribution. The checkout is shallow (depth 2): git rev-list HEAD^1..HEAD^2 returns 1 while the metadata snapshot lists 16 commits. Only the aggregate HEAD^1..HEAD diff was exercised; per-commit claims were out of reach and are not asserted.
  • Live Web Shell UI against a running daemon. Not attempted in this lane (no browser/daemon; the PR itself marks this N/A). The dialog render path for the new field kinds is nonetheless covered by the PR's ChannelEditorDialog.test.tsx, which passed in the gate above — so the record/string-list rendering is exercised, just not against a live daemon by this verifier. I did not A/A a browser boot because none is available here; the gap is environmental-by-construction, stated as such.
  • Pairing flow on GitHub/GitLab (public-comment noise) — PR-declared out of scope; not exercised.
  • i18n label completeness (packages/web-shell/client/i18n.tsx) — not asserted by a scripted check here; the dialog falls back to field.label/option.label when a key is missing, so a missing translation degrades to the descriptor label rather than crashing. Not a verified claim.
  • Repo-wide lint/format — not run by this verifier (the affected-workspace typecheck was); the PR's own CI covers eslint/prettier.
  • baseRefOid note. The metadata snapshot's baseRefOid (74dc5547…) had drifted from the merge base; per the merge-ref contract I used HEAD^1 (b4128ba…) as the A/B base, which is the correct base tip for this checkout.

Methodology

Environment: CI verify container (node v22.23.2), merge-ref checkout at depth 2 (HEAD merge commit, HEAD^1 base tip, HEAD^2 verified PR head); npm ci + npm run build pre-done at HEAD, no rebuild needed (vitest transforms source directly; the A/B "base" arm was produced by reverting the single 8-line hunk in a scratch edit and restoring via git checkout HEAD -- afterward — the tree was confirmed clean except the temporary harnesses, which were then moved into harness/). Each harness is mock-free w.r.t. the unit under test: the store A/B uses the real WorkspaceChannelSettingsStore + real github/gitlab plugin descriptors + real settings.json I/O; the editor probe uses the real channel-editor-state public API; the set-equality probe reads the real adapter source. Raw per-run logs are in logs/ (ab-head.log, ab-base.log, vacuity-store-base.log, editor-boundary.log, set-equality.log, gate-store-head.log, gate-registry.log, gate-webshell-channels.log, typecheck.log); rerunnable harnesses in harness/; image witnesses in evidence/. assertions.json counts the verifier-harness expect() calls that executed across the head A/B (24), base A/B (9), editor probe (19), and set-equality probe (5) — base-arm reject-expectations are encoded as passes; the PR's own suites and the vacuity revert-run are reported separately as gates/evidence and are not folded into this tally.

Evidence images

01-ab-head-store

02-ab-base-store

03-vacuity-store-test

04-editor-record-boundaries

Harness scripts and raw logs are in the workflow run artifacts (7-day retention).

Qwen Code · sandboxed verification

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM, looks ready to ship. ✅

@wenshao
wenshao added this pull request to the merge queue Aug 2, 2026
Merged via the queue into QwenLM:main with commit 41f0e3c Aug 2, 2026
55 checks passed
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.21.4.

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.

4 participants