diff --git a/.changes/1082-sheet-whiteboard-shortcuts.md b/.changes/1082-sheet-whiteboard-shortcuts.md new file mode 100644 index 00000000..0b8017b0 --- /dev/null +++ b/.changes/1082-sheet-whiteboard-shortcuts.md @@ -0,0 +1,6 @@ +--- +category: Added +--- + +- **Whiteboard shortcuts** (#1082) — adds strict query and confirmed update workflows with stable-target receipts and exact readback verification. +- **Sheet shortcut hardening** (#1082) — makes worksheet listing and cell-range reads fail closed on malformed, ambiguous, or truncated responses, publishes a closed reviewed output shape, and preserves non-executing `--dry-run` previews for range reads. diff --git a/docs/shortcut-downstream-requirements/attendance.md b/docs/shortcut-downstream-requirements/attendance.md deleted file mode 100644 index 064abb6b..00000000 --- a/docs/shortcut-downstream-requirements/attendance.md +++ /dev/null @@ -1,310 +0,0 @@ -# Attendance Shortcut 下游业务能力需求规格 - -> 日期:2026-08-18 -> Rebased executable 基线:`69bda96e49c7a478729b5f9232677fd9055e5d7d`;最终 clean PR HEAD 的 live SHA 与发布复核结果记录在 PR 证据中 -> 对比基线:Lark CLI 1.0.87 -> 范围:Attendance Shortcut only;不改 DWS 产品 Skill 的路由、流程或业务逻辑。仓库 policy 强制的可见 Shortcut 自动生成块单独机械同步。 - -## 1. 执行摘要 - -- Attendance 共审核 35 个源码 Shortcut;8 个具备 Agent 公开条件,27 个保持 unavailable。为守住已发布 CLI 的 argv/Help 兼容,其中 11 个历史可见入口继续以 compatibility-visible 形式可发现,但仍从 Agent public Catalog 排除、保持 legacy 输出且不发布 Result/Pagination;其余 16 个保持 hidden。公开数量按「严格响应合同 + 稳定身份 + 安全真实 fixture」的发布门计算,不把空数组或仅退出码 0 计为通过。 -- 这 11 个 compatibility-visible 入口在完整 Schema 中保留历史 `availability=available` 与既有 workflow property,仅表示旧调用仍可执行;它们的 Shortcut 语义状态仍为 `public=false/unavailable`,默认 Shortcut 列表与 Agent public Catalog 均不发布。底层 MCP 字段名由 Execute 的显式 adapter 负责,不能在未经过版本化迁移时重定向已发布 Schema property。 -- `+check-result` 已覆盖 Lark CLI 当前唯一 Attendance 用户任务 `attendance user_tasks query`;DWS inventory 还包含打卡流水、审批、班次、规则、设置、假期和个人视图等更宽能力。排班查询入口虽然保留历史 CLI 兼容,但因 `DS-ATTENDANCE-008` 当前保持 Agent-unavailable。 -- 已确认 8 组下游需求:补卡规则详情返回空结果、报表合同不足、打卡结果分页缺少服务端确定终止证据、缺少安全可回收的管理员/写操作 fixture、6 个读场景缺少请求绑定字段或 nonempty/zero 双态 fixture、班次详情不回显稳定 ID、个人设置缺少逐场景权限发现与安全 fixture,以及排班查询对合法非空/空请求均返回 `exit 0 + literal null`。 -- 审批模板的同类型多模板问题已在上游修复:以 `processCode` 作为资源身份,`approveType` 只做请求绑定,并要求 `submitUrl` 非空。班次详情与个人设置仍有下游合同/权限前置,不能以请求 echo 或部分场景成功伪造整体可用。 - -| ID | 优先级 | 类型 | 用户任务 | 当前状态 | 建议 Owner | 解锁的 Shortcut | -|---|---|---|---|---|---|---| -| `DS-ATTENDANCE-001` | P1 | business-service defect / contract insufficient | 搜索后读取补卡规则详情 | unavailable | Attendance Wukong 规则服务 | `+get-adjustment-rule` | -| `DS-ATTENDANCE-002` | P1 | business-service defect / contract insufficient | 发现报表列并查询考勤/假期报表 | unavailable | Attendance 报表服务 / MCP adapter | `+list-report-columns`, `+query-report-data`, `+query-report-leave` | -| `DS-ATTENDANCE-003` | P2 | contract insufficient | 可靠翻完打卡结果 | partial | Attendance 打卡查询服务 | `+check-result` 完整分页 | -| `DS-ATTENDANCE-004` | P1 | tenant-or-fixture / permission | 验证考勤组、全局设置、余额和写操作 | blocked / unavailable | Attendance 产品测试基础设施 / 权限 Owner | 14 个读写 Shortcut | -| `DS-ATTENDANCE-005` | P1 | response contract / tenant-or-fixture | 可验证地读取摘要、假期、签到和个人考勤 | blocked / unavailable | Attendance 查询服务 / 产品测试基础设施 | 6 个读 Shortcut | -| `DS-ATTENDANCE-006` | P1 | response contract | 用搜索得到的班次 ID 精确读取同一班次详情 | unavailable | Attendance Wukong 班次服务 | `+get-class` | -| `DS-ATTENDANCE-007` | P1 | capability / permission fixture | 可发现地读取全部个人设置场景 | blocked / unavailable | Attendance 设置服务 / 权限 Owner / 测试基础设施 | `+get-self-setting` | -| `DS-ATTENDANCE-008` | P1 | response contract | 可验证地读取员工排班 | unavailable | Attendance Wukong 排班服务 / MCP adapter | `+get-schedule` | - -## 2. 用户任务与能力缺口总览 - -| 用户任务 / Golden Route | DWS Shortcut | Lark CLI 对应 | 当前能力 | 缺口分类 | 临时处置 | -|---|---|---|---|---|---| -| 批量查询员工打卡结果 | `attendance +check-result` | `attendance user_tasks query` | covered;框架分页 token 由当前页保守派生 | contract insufficient | 声明 `Pagination(kind=cursor,cursor_parameter=offset)`;续页只放 `meta.pagination`,业务 `data` 仅含 `count/records` | -| 搜索并读取班次 | `+search-class` → `+get-class` | 无同级入口 | partial | response contract | 只公开搜索;详情因不回显请求 classId 而 unavailable | -| 搜索并读取补卡规则 | `+search-adjustment-rule` → `+get-adjustment-rule` | 无同级入口 | partial | business-service defect | 只公开搜索;详情 unavailable | -| 发现字段并查询考勤报表 | `+list-report-columns` → `+query-report-data` | 无同级入口 | unavailable | contract insufficient | 两个入口均不进入 Agent Catalog;历史 `+query-report-data` 仅保留 CLI 兼容可见性 | -| 查询假期报表 | `+query-report-leave` | 无同级入口 | unavailable | business-service defect | hidden/unavailable | -| 搜索并读取考勤组 | `+search-group` → `+get-group` | 无同级入口 | blocked | tenant-or-fixture | 无已知非空安全 fixture;历史 `+search-group` 仅保留 CLI 兼容可见性,二者都不进入 Agent Catalog | -| 查询企业全局设置和假期余额 | `+get-global-setting`, `+get-leave-balance` | 无同级入口 | blocked | permission / fixture | hidden/unavailable | -| 查询个人设置 | `+get-self-setting` | 无同级入口 | partial | capability / permission fixture | 前五个场景已验证;全部场景发布前保持 Agent-unavailable,仅保留历史 CLI 兼容可见性 | -| 查询员工排班 | `+get-schedule` | 无同级入口 | unavailable | response contract | 合法非空与保证零命中请求均收到 `exit 0 + literal null`;旧 CLI 兼容可见,但不进入 Agent Catalog | -| 修改排班、班次、考勤组、假期和打卡结果 | 9 个写 Shortcut | 无同级入口 | unsafe to verify | tenant-or-fixture / contract insufficient | hidden/unavailable,不以 dry-run 记通过 | - -## 3. 下游需求明细 - -### `DS-ATTENDANCE-001` — 让搜索得到的补卡规则可被稳定读取 - -#### A. 用户任务与现状 - -- 用户任务:先按名称浏览补卡规则,再用结果中的稳定主键读取完整规则。 -- canonical Shortcut:`attendance +search-adjustment-rule`、`attendance +get-adjustment-rule`。 -- atomic/raw route:`attendance adjustment search`、`attendance adjustment get`。 -- Exact Shortcut 与 atomic/raw 均使用搜索返回的同一候选主键;搜索明确成功且非空,详情调用明确 `success=true`,但 `result=null`。 -- 已排除上游空数组投影、整数解析和候选字段遗漏:多个可作为候选的数值字段均未得到非空详情;加班规则的相邻搜索→详情闭环正常。 -- 置信度:高。仍需下游确认“搜索 ID 与详情 ID 不同”还是详情服务未返回对象。 -- 安全证据句柄:`ATT-DETAIL-NULL-01`;仓库不保存 raw body、资源 ID 或 trace。 - -#### B. 需要下游提供的合同 - -- 明确 `get_adjustment_rule` 列表项中哪个字段是 `get_adjustment_rule_detail.adjustmentId` 的稳定主键;名称和类型必须在 Schema 中一致。 -- 对存在且有权限的规则返回 `success=true` 和非空对象 `result`,对象必须回显同一稳定规则 ID。 -- 对不存在、已删除、无权限、租户未开通分别返回稳定的 typed error;不得以 `success=true + result=null` 表示任一失败。 -- 如详情接口不受支持,提供可发现的 capability/feature 状态,或在搜索结果中返回足以完成详情任务的完整对象并声明字段稳定性。 -- 改动应 additive/versioned;旧字段保留兼容期,禁止静默改变现有 ID 的语义。 - -#### C. 验收标准 - -1. 创建或选择隔离规则,atomic search 非空并取得稳定 ID。 -2. atomic detail 和 exact `+get-adjustment-rule` 均返回同一 ID 的非空对象。 -3. 不存在 ID、无权限和已删除 ID 分别返回非零 typed error。 -4. 上游恢复公开后,搜索→详情 E2E 通过且仓库/远端无测试残留。 - -#### D. 临时处置 - -`+get-adjustment-rule` 保持 Agent-unavailable 并从公开 Catalog 排除;旧 CLI 入口仅为 argv/Help 兼容继续可见,`+search-adjustment-rule` 不再承诺详情入口可用。 - -### `DS-ATTENDANCE-002` — 提供可发现、可验证的考勤报表合同 - -#### A. 用户任务与现状 - -- Golden Route:列出企业可查询报表列 → 选择稳定列 ID → 查询一批员工的列值;另一路径按假期类型查询时长报表。 -- canonical Shortcut:`+list-report-columns`、`+query-report-data`、`+query-report-leave`。 -- atomic/raw operations:`get_report_columns`、`get_report_columns_value`、`get_leave_time_by_leave_names`。 -- 观察:列发现与假期报表调用均退出码 0 且 payload 为 JSON `null`;使用未经验证的列 ID 查询列值仅得到显式空数组,不能证明列 ID 有效或查询正确。 -- 已排除上游投影丢失:原子调用本身即返回 `null`;Shortcut 现已拒绝把 `null` 当作合法空集合。 -- 置信度:高。权限/租户功能可能是触发条件,但接口没有返回可区分的状态。 -- 安全证据句柄:`ATT-REPORT-NULL-01`。 - -#### B. 需要下游提供的合同 - -- `get_report_columns`:成功时必须返回显式列数组;每项含稳定 `columnId`、显示名、值类型、单位、支持的日期/人员范围和是否需要管理员权限。 -- 合法无列必须是 `success=true + result=[]`;未开通、无权限和服务异常必须是不同 typed error,不得返回裸 `null`。 -- `get_report_columns_value`:返回值必须绑定请求的用户集合、列 ID 和时间范围;未知列返回 `COLUMN_NOT_FOUND`,不能静默得到空数组。 -- `get_leave_time_by_leave_names`:返回显式数组并包含稳定用户身份、假期类型标识、单位和数值;合法零记录为显式空数组。 -- 列值和假期报表若分页,必须提供 page/cursor、hasMore 和终止证据;批量用户存在部分失败时返回逐项 ledger 与整体 partial status。 -- 提供安全 capability discovery:租户是否开通、调用身份所需权限、最大用户数、最大列数、最大时间跨度。 - -#### C. 验收标准 - -1. 管理员测试租户中列发现有已知非空和明确空租户两组 E2E。 -2. 使用发现的同一 `columnId` 执行 atomic 与 exact Shortcut,返回与请求用户/区间绑定的非空值。 -3. 未知列、无权限、未开通和超范围分别产生稳定非零错误。 -4. 假期报表至少覆盖已知非空、合法空和未知假期类型。 -5. 分页/partial 分支和远端零残留通过。 - -#### D. 临时处置 - -三个报表 Shortcut 均保持 Agent-unavailable;其中历史 `+query-report-data` 只保留 CLI 兼容可见性。不得用 `null`、请求 echo 或未验证列产生的空数组标记 PASS。 - -### `DS-ATTENDANCE-003` — 为打卡结果提供确定的分页终止证据 - -#### A. 用户任务与现状 - -- `+check-result` 已真实返回非空打卡结果并覆盖 Lark 任务;当前接口只接受 `offset/limit`,响应缺少稳定总量、hasMore 或 nextOffset。 -- DWS 只能在返回条数小于 limit 时证明结束;满页时保守输出 `meta.pagination.endpoint_exhausted=false` 和 `next_token=offset+count`,不能声明全量完成。`complete/nextOffset/limit` 仅保留在 legacy 兼容输出,unified 业务 `data` 不冒充分页协议。 -- 安全证据句柄:`ATT-CHECK-PAGE-01`。 - -#### B. 需要下游提供的合同 - -- 响应增加 `hasMore` 与 `nextOffset`,或 `totalCount`;这些字段必须与同一快照/排序一致。 -- 固定稳定排序键和同 offset 重放语义;说明并发新增/修改是否可能造成重复或漏项。 -- 空页且 `hasMore=true` 必须仍给出前进 token/offset;重复或倒退 offset 为协议错误。 -- 声明最大 limit、最大时间跨度和超过上限的 typed validation error。 - -#### C. 验收标准与临时处置 - -- 验收覆盖多页、最后一页、零记录、满页但仍有下一页、重复 token/offset 和并发变更。 -- 下游完成前,DWS 使用框架 `PaginationSpec` 和 `meta.pagination`表达保守续页;`cursor_parameter=offset` 表示调用者将 `next_token` 作为下一次 `--offset`,不表示下游已提供服务端 opaque cursor。满页始终不会被当作已完整。 - -### `DS-ATTENDANCE-004` — 建立可回收的 Attendance 管理员与写操作测试资源 - -#### A. 用户任务与现状 - -- 受影响读取:`+search-group`、`+get-group`、`+get-group-filtered`、`+get-global-setting`、`+get-leave-balance`。 -- 受影响写入:`+import-schedule`、`+create-class`、`+update-class`、`+update-group-members`、`+create-group`、`+update-group`、`+update-leave-type`、`+save-leave-balance`、`+boss-check`。 -- 当前安全身份没有已知非空考勤组 fixture;全局设置被权限拒绝;余额读取没有可验证结果。写操作会影响真实员工规则,且部分资源缺删除/恢复能力,因此未执行生产数据写入。 -- 这不是对业务接口必然有 bug 的结论,而是可测试性和权限前置不足。 -- 安全证据句柄:`ATT-FIXTURE-GAP-01`。 - -#### B. 需要的测试基础设施与合同 - -- 提供隔离租户或专用测试组织,包含:管理员测试身份、两个无业务含义测试成员、一个可删除考勤组、一个可删除班次、一个可恢复假期类型、可控排班与打卡结果。 -- 只授予完成相应接口所需的最小 scopes;提供 capability discovery,区分权限不足、功能未开通和资源不存在。 -- 写接口返回稳定资源 ID、逐项结果、幂等/commit-unknown 语义;所有更新支持精确读回。 -- 为不可删除的企业设置提供 snapshot/restore 或专用 reset API;余额和 BOSS 改签必须能恢复原值。 -- Fixture 有 TTL、Owner 和自动清理告警;日志只保留受控 evidence handle,不输出业务内容或身份值。 - -#### C. 验收标准与临时处置 - -1. 考勤组搜索有已知非空和保证零命中;详情绑定同一 ID。 -2. create→get→update→restore/delete 覆盖班次、考勤组与排班。 -3. 成员、余额和打卡结果写入均有 before/after 精确读回并恢复原值。 -4. 未确认时远程写调用为 0;任一 partial/commit-unknown 非零退出。 -5. 测试结束远端和本地均零残留。 - -在完整 fixture 到位前,相关 Shortcut 保持 hidden/unavailable。 - -### `DS-ATTENDANCE-005` — 为 6 个读场景提供请求绑定与双态 fixture - -#### A. 用户任务与现状 - -- `+get-summary`:真实响应只含统计项,不回显请求 user、period 或 statsType,上游无法证明返回属于哪个请求。 -- `+list-leave-types`:当前安全租户只有已知非空列表,而命令无筛选参数;不能用越界分页或错误请求伪造合法空结果。 -- `+get-leave-records`、`+get-checkin-record`:当前只取得合法空结果,缺少已知非空流水 fixture,无法排除响应投影或请求绑定错误。 -- `+my-attendance`、`+this-month`:上游已严格验证当前用户 profile 与每条打卡 ID,但当前期间仅有合法空数组,缺少同一身份下的已知非空 fixture。 -- 安全证据句柄:`ATT-READ-FIXTURE-GAP-01`;不保存 raw body、用户 ID 或打卡时间。 - -#### B. 需要下游提供的合同与 fixture - -- 摘要响应回显稳定 userId、统计周期起止和 statsType,或返回可校验的请求摘要;任一字段不一致必须 typed failure。 -- 提供隔离的「无假期类型」测试租户,以显式 `success=true + result=[]` 证明 `+list-leave-types` 的合法空语义。 -- 提供可创建、读取并清理的假期变更流水、签到流水和打卡流水;每项都必须包含稳定 ID、请求用户和时间范围回显。 -- 为 nonempty 与 guaranteed-zero 提供独立 fixture;未知用户、无权限、未开通和合法空集合必须可区分,不得都返回裸 `null` 或无标识空数组。 - -#### C. 验收标准与临时处置 - -1. 每个集合叶子都用 exact Shortcut 和 owning atomic/raw 在同一参数下各证明一次已知非空和一次合法保证零命中。 -2. 非空项的稳定 ID、用户和时间绑定在两层结果中一致;空结果仍有显式业务 success 和正确集合容器。 -3. malformed/null/success=false/错身份/超范围均非零失败,且不会继续调用后续考勤接口。 - -在上述证据完整前,6 个 Shortcut 均保持 Agent-unavailable,并仅为历史 argv/Help 保留 CLI 兼容可见性;已实现的严格校验不等于已获得发布证据。 - -### `DS-ATTENDANCE-006` — 让班次详情回显可验证的稳定身份 - -#### A. 用户任务与现状 - -- 用户任务:先用 `+search-class` 浏览班次并取得稳定 `classId`,再用同一 ID 读取班次详情。 -- canonical Shortcut:`+search-class`、`+get-class`;atomic/raw route:`attendance class search`、`attendance class get`。 -- 在 clean discovery HEAD 上,搜索 exact/raw 均返回同一组非空正整数 `classId`;使用其中真实 ID 调用 raw detail,服务端返回 `success=true` 和非空 `shiftVO`,但对象没有 `id` 或 `classId`。 -- 上游不能把请求 ID 注入响应来伪造 readback,也不能仅凭“非空详情”证明详情属于请求资源。因此 `+get-class` 保持 unavailable。 -- 安全证据句柄:`ATT-CLASS-ID-ECHO-GAP-01`;不保存 raw body、资源 ID 或 trace。 - -#### B. 需要下游提供的合同 - -- `get_class_detail` 成功对象必须回显与请求精确一致的稳定 `id`/`classId`,类型与 `get_class_list` 列表身份字段一致。 -- 存在、已删除、不存在、无权限和租户未开通必须返回可区分的 typed terminal 状态;不得以非空但无身份对象表示可验证成功。 -- 明确班次 ID 的租户作用域、生命周期和搜索→详情一致性;如详情存在版本号,也应返回稳定版本字段以支持更新前读回。 -- 改动需 additive/versioned;现有详情业务字段保持兼容。 - -#### C. 验收标准与临时处置 - -1. exact/raw 搜索得到同一非空 `classId`,同 ID detail 均返回身份精确匹配的非空对象。 -2. 不存在、已删除和无权限分别非零 typed failure,不能成为 `success=true + result=null` 或无身份对象。 -3. 上游 `+get-class` 的 missing/false/null/malformed/wrong-ID 回归与真实 E2E 全部通过。 - -下游补齐稳定 ID 回显前,`+get-class` 保持 hidden/unavailable;`+search-class` 仍可独立公开。 - -### `DS-ATTENDANCE-007` — 提供个人设置逐场景 capability 与权限安全 fixture - -#### A. 用户任务与现状 - -- `+get-self-setting` 公开参数包含 6 个场景。clean discovery HEAD 上,前 5 个场景的 exact/raw 均能精确绑定请求 userId、场景字段和已观测类型;`bossAttendStatNotify` 在两层均返回稳定业务错误 `NO_PERMISSION`。 -- 当前接口没有 capability discovery 告知调用身份可读哪些场景,也没有可安全授权的隔离 fixture。只验证 5/6 不能宣称整个公开枚举可用。 -- 这不是把权限错误误判为业务空结果;exact/raw 均非零退出。上游保留严格 user/scene/type 校验,但发布面整体降级。 -- 安全证据句柄:`ATT-SELF-SETTING-PERMISSION-GAP-01`。 - -#### B. 需要下游提供的合同与 fixture - -- 提供 capability discovery,返回当前调用身份逐场景的 readable/forbidden/unsupported 状态、所需最小 scope/角色和租户功能开通状态。 -- 为 6 个场景提供字段名、类型、可空性和版本化语义;成功必须回显请求 userId,并明确返回对应场景字段。 -- 提供隔离测试身份或可撤销的临时最小权限授权 fixture,使 6 个场景均能完成 exact/raw 同场景验证;测试后权限必须回收。 -- 无权限、场景不支持、用户不存在和设置未配置必须返回不同 typed error;不得统一为 `null`、空对象或无标识空成功。 - -#### C. 验收标准与临时处置 - -1. capability discovery 与 6 个场景实际调用一致,不遗漏权限前置。 -2. 每个场景 exact/raw 的 userId、场景字段、类型和对象内容一致;`null`、错类型、错用户均非零。 -3. bogus user、invalid scene、无权限和未开通均返回可区分非零错误。 -4. 权限 fixture 全程最小化、可撤销,结束后无授权残留。 - -能力发现和安全 fixture 到位前,`+get-self-setting` 保持 Agent-unavailable;旧 CLI 入口仅保留兼容可见性。 - -### `DS-ATTENDANCE-008` — 让排班查询返回可判定的成功集合或业务错误 - -#### A. 用户任务与现状 - -- 用户任务:按员工和日期范围读取逐日排班,用稳定排班 ID 继续执行只读分析或受控的 BOSS 改签。 -- canonical Shortcut:`attendance +get-schedule`;owning raw route:`attendance-wukong/getScheduleByRange`。 -- 两次独立 clean HEAD 的真实验证中,已知历史非空区间与保证零命中的未来区间都得到同一结果:owning raw 进程退出 0,但响应为 literal `null`;Exact Shortcut 均以 `response_validation/empty_tool_response` 非零拒绝。 -- 这既不能证明排班非空,也不能证明合法为空。上游严格校验已避免把 `null` 投影成 `[]`,但在下游提供可判定合同前无法公开该能力。 -- 安全证据句柄:`ATT-SCHEDULE-NULL-01`;仓库不保存用户、日期、排班 ID、raw body 或 trace。 - -#### B. 需要下游提供的合同 - -- 成功查询必须返回显式排班数组;每项包含稳定非空排班 ID、请求用户身份、业务日期、班次身份和是否休息等字段。 -- 合法零结果必须返回 `success=true + result=[]`(或等价的已审核显式集合),不得以裸 `null`、缺字段或空 body 表示。 -- 无权限、用户不存在、租户未开通、日期范围非法和服务异常必须返回可区分的 typed nonzero error;不得继续用进程退出 0 掩盖业务失败。 -- 如服务存在分页,必须提供页大小、前进 token/页号、hasMore/total 和明确终止证据;同一请求的 item identity 不得跨页重复。 - -#### C. 验收标准与临时处置 - -1. 已知非空 fixture 的 raw 与 exact 均返回同一显式数组,稳定 ID 集合、用户和日期绑定一致。 -2. 保证零命中 fixture 的 raw 与 exact 均返回显式空数组,并有明确终止证据。 -3. `null`、缺集合、错型 item、重复/空 ID、错用户和越界日期全部非零;错误 reason 可稳定区分。 -4. 新 clean HEAD 完成 nonempty/zero 双层 E2E,仓库和远端均无测试残留。 - -下游修复前,`+get-schedule` 保持 `public=false/unavailable`、legacy 输出且不发布 Result/Pagination;旧 CLI/Help/full Schema 仅为历史兼容继续可发现,不代表 Agent 可用。 - -## 4. Lark 对齐与平台差异 - -| Lark 用户任务 | 所需下游能力 | 可精确对齐 | 平台差异 | DWS 推荐结论 | -|---|---|---|---|---| -| `attendance user_tasks query` 查询打卡结果 | 现有 `query_check_result`;最好补分页终止证据 | yes,分页完整性 partial | Lark 当前没有同级的排班、规则、报表和企业设置任务 | 保留 `+check-result` 为主对齐入口,报告分页边界 | - -无法对齐的不是 DWS 缺入口,而是部分钉钉管理面缺少可验证下游合同或安全 fixture;不能为追求同名率伪造成功。 - -## 5. 超越 Lark 的产品机会 - -| 产品原生能力 | 所需下游支持 | 可形成的 DWS Shortcut | 安全/验证要求 | 优先级 | -|---|---|---|---|---| -| 异常考勤处置队列 | 稳定异常记录 ID、原因、关联审批、处理状态、分页和可恢复更正 | `attendance +exceptions` / `+resolve-exception` | 读写分离;更正确认;写后同 ID 终态读回;可恢复 | P2 | -| 跨员工考勤汇总 | 可按组织/成员批量聚合迟到、缺卡、加班、请假并给出统计口径版本 | `attendance +team-summary` | 最小权限、聚合脱敏、口径版本、分页完整性 | P2 | -| 规则影响预览 | 更新班次/考勤组/假期前返回受影响成员与日期范围,不提交写入 | `attendance +rule-impact-preview` | 只读、稳定影响计数、无副作用、与最终写请求同参数语义 | P1 | - -## 6. 无需下游变更的上游修复 - -| Shortcut | 上游根因 | 已完成修复 | 回归证据 | -|---|---|---|---| -| 最终保留公开的 Attendance 集合查询 | 容错 projector 可能把缺字段、错型或坏元素投成 `[]` | 共享严格 success/result/collection 校验;显式空数组才合法;稳定 ID 和请求用户/时间/类型必须绑定 | 单元负向矩阵与最终 clean runtime tree 的 8 个公开入口真实 nonempty/zero、详情或模板 exact/raw 双层复核均完成 | -| `+check-record` | 初版误用业务归属日 `workDate` 校验按 `checkDateFrom/checkDateTo` 发起的实际打卡查询,导致跨午夜下班卡被静默丢弃 | 改用 `userCheckTime` 严格绑定请求日期范围;`workDate` 只作为班次归属日原样保留。完整 raw 集合仍必须先通过显式 collection、全量正整数唯一 ID、请求用户和实际打卡时间校验;任何实际时间越界都整次 fail-closed,不再静默过滤 | 最终 live 复核 exact/raw 均为 157 条且完整对象一致;旧轮 `workDate=start-24h`、`userCheckTime` 在范围内的跨午夜 OffDuty 记录明确保留;fresh zero 双层显式空,不由过滤制造 | -| `+check-result`, `+list-approve` | 初版把裸日期 `--end` 解析为当天 00:00,可能拒绝结束日白天的结果;旧 end-of-day 语义还会漏最后 999ms | 裸日期结束边界改为本地下一日 00:00 前 1ms;显式 datetime 保持精确值;结束日中午与最后 1ms 可接受,下一日 00:00 非零拒绝 | Execute 回归覆盖结束日中午/最后毫秒/下一日并锁定 reason;最终 live 的 `+check-result` 有真实 end-date item,`+list-approve` end-date 单日 probe exact/raw 一致 | -| `+get-approve-template` | 把请求维度 `approveType` 误作集合唯一身份,会拒绝同一类型下多个合法模板 | 改用非空唯一 `processCode` 作为资源身份;`approveType` 仅做请求精确绑定;每项 `submitUrl` 必须非空;允许 TRAVEL/OUT 同类型多项 | missing/wrong/duplicate processCode、wrong approveType、missing/blank submitUrl 负向矩阵;clean HEAD 上 5 个类型 exact/raw 全通过,TRAVEL/OUT 双项集合一致 | -| `+search-class`, `+search-adjustment-rule`, `+search-overtime-rule` | 嵌套 `shiftVO/entityVO` 导致身份投影风险 | 固定审核路径、展开 wrapper、要求正整数且不重复的稳定 ID,严格校验分页矛盾与无前进页 | 坏 item/空 ID/重复 ID/分页矛盾单元回归通过;clean HEAD 上 nonempty/guaranteed-zero 与 raw 对照通过,班次/加班规则另完成实际多页前进与终止 | -| `+get-overtime-rule` | 能力存在但缺少请求 ID 与响应对象的强绑定 | 详情对象要求非空且 `id` 与请求精确一致 | missing/false/null/malformed/wrong-ID/valid Execute 级矩阵;clean HEAD 上 exact/raw 同真实搜索 ID 对象一致,raw 对不存在 ID 返回错对象时 exact 非零拒绝 | -| `+get-class` | 上游已严格要求 `shiftVO.id`,但真实下游详情不回显任何 ID | 没有注入请求 ID 或放宽校验;按真实合同降级 unavailable | discovery HEAD 上真实搜索→raw detail 非空但 ID 缺失;等待 `DS-ATTENDANCE-006`,修复后再重跑 | -| `+get-self-setting` | 仅检查场景 key 存在会让 `null` 伪成功;用户外围空白可造成下传/比较漂移 | 用户输入只归一化一次并以同值下传/比较;场景字段必须非空且符合已观测 object/boolean/integer 类型;因 1/6 场景权限不可验证而整体 unavailable | 5 个 scene exact/raw 对照通过;boss scene exact/raw 均 `NO_PERMISSION`,等待 `DS-ATTENDANCE-007`,不把部分场景成功当整体 PASS | -| `+my-attendance`, `+this-month` | 旧的当前用户解析可跳过 malformed row,也可把 success=false 中的 stale result 当身份 | 改为严格 business success/result/唯一用户身份,坏 profile 后考勤 raw 调用为 0;每条打卡要求唯一正整数 ID | 静态/Execute 回归已通过;因当前只有合法空集合而保持 unavailable,不记 live PASS | - -### 6.1 clean-HEAD live 发布门状态 - -| 叶子 | clean executable HEAD 双层证据 | 发布状态 | -|---|---|---| -| `+check-result` | exact/raw known-nonempty 以 20/20/8 三页前进并终止;48 个 ID、用户绑定与逐页对象一致;合法未来日显式空双层一致 | `PASS`;最终 SHA 见 PR 证据 | -| `+check-record` | exact/raw 均 157 条且完整对象、稳定 ID 集合一致;跨午夜 `workDate=start-24h`、`userCheckTime` 在范围内的记录已保留;fresh zero 两层均为显式空 | `PASS`;最终 SHA 见 PR 证据 | -| `+list-approve` | exact/raw known-nonempty 为 7 条,稳定 ID、用户、类型、日期范围及完整数组一致;合法未来日显式空双层一致 | `PASS`;最终 SHA 见 PR 证据 | -| `+get-schedule` | 两次独立 clean HEAD 的 known-nonempty 与 guaranteed-zero 均为 raw `exit 0 + literal null`,Exact Shortcut 均非零 `empty_tool_response`;没有把未知结果投影成空数组 | unavailable;等待 `DS-ATTENDANCE-008`,旧 CLI 仅兼容可见 | -| `+search-class`, `+search-adjustment-rule`, `+search-overtime-rule` | exact/raw known-nonempty 与随机唯一词 guaranteed-zero 通过;稳定 ID 集合与分页终止一致,班次为 5/5/3 三页,加班规则为 1/1/1 三页 | `PASS`;最终 SHA 见 PR 证据 | -| `+get-overtime-rule` | 使用本轮真实搜索取得的 ID,exact 与 raw 单项对象一致;不存在 ID 的 raw 返回错 ID 对象时 exact 非零拒绝 | `PASS`;最终 SHA 见 PR 证据 | -| `+get-approve-template` | 5 个 approveType 全部 exact/raw 通过,数量 1/1/1/2/2;TRAVEL/OUT 多项 `processCode` 非空唯一且集合一致,类型绑定和提交入口有效 | `PASS`;最终 SHA 见 PR 证据 | -| `+get-class` | raw 非空但不回显请求 ID | unavailable;等待下游合同,不以旧调用记 PASS | -| `+get-self-setting` | 5 个场景通过,1 个场景 `NO_PERMISSION` | unavailable;等待 capability/权限 fixture,不以部分结果记 PASS | - -pre-rebase discovery 轮次的多页加班规则 raw 验证曾一次返回字面量 `null` 且进程退出 0;该次结果没有计为 PASS,重试后才完成同场景双层分页核对。这是 owning atomic/raw 的下游/renderer 终态合同风险:atomic 不应把 transport/null 失败表示为零退出。Shortcut 自身对 `null` 仍严格非零,不会把它投影为空集合;后续最终轮次未再出现该 transient。 - -上述 8 个公开入口均在最终 clean runtime tree 从零重跑,未继承 discovery PASS;最终可执行 SHA 写入 PR 证据,本文只保留脱敏业务断言。`+get-schedule` 的四次 raw `null` 与 Exact 非零结果作为降级证据保留,不计入公开通过数。 - -## 7. 安全与脱敏声明 - -- 本文不含真实用户、组织、租户、profile、规则、排班、考勤组或打卡记录 ID。 -- 本文不含 trace/request ID、token、签名 URL、邮箱、电话、业务标题正文或真实日程内容。 -- Raw 响应仅在仓库外临时目录中处理并已删除;本文只保留不可反查的证据句柄和聚合事实。 -- 进入 Git 前必须扫描最终树、未跟踪文件和 `origin/main..HEAD` 全部历史。 diff --git a/docs/shortcut-downstream-requirements/mail.md b/docs/shortcut-downstream-requirements/mail.md deleted file mode 100644 index a9fede56..00000000 --- a/docs/shortcut-downstream-requirements/mail.md +++ /dev/null @@ -1,198 +0,0 @@ -# Mail Shortcut 下游业务能力需求规格 - -> 日期:2026-08-18 -> Rebased executable 基线:`3fc3be37c67d14f60273a702a7a6b38f6ba32d4c`;最终 clean PR HEAD 的 live SHA 与发布复核结果记录在 PR 证据中 -> 对比基线:lark-cli 1.0.87 -> 范围:Shortcut only;不改 `skills/multi` 或 `skills/mono` 的路由、流程或业务逻辑。仓库 policy 强制的可见 Shortcut 自动生成块单独机械同步。 -> 发布属性:仓库安全版本;不包含真实邮箱、人员、组织、邮件内容、资源 ID 或请求标识。 - -## 1. 执行摘要 - -本轮对 18 个 Mail Shortcut 完成严格 success、固定集合路径、稳定 ID、分页完整性和统一 Result 收口。8 个公开只读入口已在相同 runtime tree 逐条完成 Shortcut 与原子层的真实数据双层复核;`+unread-mail`、`+recent-mail`、`+thread-list`、`+tag-list`、`+template-list`、`+contact-list` 因缺少可控 guaranteed-zero fixture 保持 Agent-unavailable,但为守住既有 argv/Help 合同继续以 compatibility-visible 形式留在 CLI;4 个草稿/模板写入口因无法证明清理终态同样不进入公开 Catalog。 - -上述 6 个 compatibility-visible 入口在完整 Schema 中保留历史 `availability=available` 与既有 workflow property,仅表示旧调用仍可执行;其 Shortcut 语义状态仍为 `public=false/unavailable`,默认 Shortcut 列表与 Agent public Catalog 均不发布。底层 `folderId`、`size` 等 MCP 字段继续由 Execute 显式适配,不能在未经过版本化迁移时改写已发布 Schema property。 - -仍不能诚实对齐的任务集中在草稿/模板清理终态、发送终态、回复/转发草稿语义、批量修改/删除逐项结果、回执、签名、事件监听、模板附件事务和联系人创建身份回执。它们不是再包一层 Shortcut 就能解决,需要下游业务接口或安全测试 fixture 补足可验证合同。 - -| ID | 优先级 | 类型 | 用户任务 | 当前状态 | 下游 Owner | 解锁的 Shortcut | -|---|---|---|---|---|---|---| -| `DS-Mail-001` | P0 | contract insufficient | 发信/发送草稿并确认最终投递 | partial | Mail service / adapter | `+send`、`+draft-send` | -| `DS-Mail-002` | P0 | missing capability | 回复、回复全部、转发默认保存草稿 | partial | Mail service | `+reply`、`+reply-all`、`+forward` | -| `DS-Mail-003` | P0 | contract insufficient | 批量修改、移动、软删除邮件 | partial | Mail service / adapter | `+message-modify`、`+message-trash` | -| `DS-Mail-004` | P1 | missing capability | 处理已读回执与邮箱签名 | unavailable | Mail service | `+send-receipt`、`+decline-receipt`、`+signature` | -| `DS-Mail-005` | P1 | missing capability | 持续监听新邮件 | unavailable | Event + Mail service | `+watch` | -| `DS-Mail-006` | P1 | contract insufficient | 带附件/内联图片的模板创建更新 | partial | Mail + Drive adapters | 完整 `+template-create/update` | -| `DS-Mail-007` | P1 | adapter defect | 创建联系人并取得稳定身份 | blocked | Mail adapter | `+contact-create/update/delete` | -| `DS-Mail-008` | P1 | adapter defect | 一致的成功、空结果与分页合同 | partial | Mail adapter | 全部 list/search Shortcut | -| `DS-Mail-009` | P1 | tenant-or-fixture | 安全验证发送、回执、分享和监听 | blocked | Product QA / tenant admin | 全部高影响 Mail Shortcut | -| `DS-Mail-010` | P0 | contract insufficient | 草稿/模板可证明的清理终态 | blocked | Mail service / adapter | `+draft-create/edit`、`+template-create/update` | - -## 2. 用户任务与能力缺口总览 - -| 用户任务 / Golden Route | DWS Shortcut | Lark CLI 对应 | 当前能力 | 缺口分类 | 临时处置 | -|---|---|---|---|---|---| -| 浏览/筛选摘要 | `+triage`、`+search-mail` | `+triage` | covered | 无 | 公开,严格分页 | -| 固定未读/近期列表 | `+unread-mail`、`+recent-mail` | Lark 对应任务入口 | blocked | 固定查询/文件夹缺可控 guaranteed-zero fixture | 保持 unavailable | -| 读取一封、多封、会话 | `+message`、`+messages`、`+thread` | 同名入口 | covered | 无 | 公开,精确 ID 读回 | -| 新建/编辑草稿 | `+draft-create`、`+draft-edit` | 同名入口 | blocked | 两次 batch-delete 后同 ID 仍可读,无法证明零残留 | 保持 unavailable | -| 创建/更新基础模板 | `+template-create`、`+template-update` | 同名入口 | blocked | delete 后 get 没有 typed nonfound;from/isDraft 也不可读回 | 保持 unavailable | -| 发送新邮件/已有草稿 | 无公开 Shortcut;存在 raw send | `+send`、`+draft-send` | partial | 终态、逐项结果、幂等不足 | 保持 raw,不宣称对齐 | -| 回复/回复全部/转发 | 无公开 Shortcut;raw 路径会立即发送 | `+reply`、`+reply-all`、`+forward` | partial | 缺少默认草稿与邮件头保真合同 | 保持 raw,不宣称对齐 | -| 修改/删除邮件 | 无公开 Shortcut;存在 raw batch route | `+message-modify`、`+message-trash` | partial | 无逐项 ledger 和严格终态 | 保持 raw,不宣称对齐 | -| 发送/拒绝已读回执 | 无 | `+send-receipt`、`+decline-receipt` | unavailable | 专用业务接口与标签合同缺失 | 明确不可用 | -| 邮箱签名 | 无 | `+signature` | unavailable | 签名读取接口缺失 | 明确不可用 | -| 分享邮件到聊天 | raw 高风险入口 | `+share-to-chat` | partial | 缺安全 fixture、逐目标结果与读回 | 不公开 Shortcut | -| HTML lint | 无 | `+lint-html` | unavailable | 缺统一邮件 HTML 规则包 | 下游或本地规则能力需求 | -| 监听新邮件 | 无公开 Mail Shortcut | `+watch` | unavailable | 订阅生命周期和安全事件合同不足 | 不公开 Shortcut | -| 文件夹/标签/联系人/企业邮箱用户 | `+folder-list`、`+user-search`、`+find-mail-user` 公开;其余列表不公开 | 无同名任务入口 | partial DWS extra | 标签/模板/联系人/会话列表缺安全双态 fixture | 无双态证据的入口保持 unavailable | - -## 3. 下游需求明细 - -### `DS-Mail-001` — 可验证的发送生命周期 - -- 用户任务:发送新邮件或一个/多个草稿,并知道每一封最终是成功、失败、部分成功还是状态未知。 -- 当前证据:raw 发送可返回业务 success 或发送标识,但不能统一证明最终投递;批量草稿发送没有逐项 ledger、请求顺序、未知提交和安全重试合同。 -- 所需接口合同: - - 创建/发送必须返回稳定 `messageId` 与 `internetMessageId`,并明确 `accepted/pending/sent/partial_failure/failure/unknown`。 - - 提供按同一身份查询发送状态的接口;状态必须绑定请求邮件与收件人集合。 - - 批量发送返回逐项结果,任何一项失败时整体不得退出 0 冒充全成功。 - - 支持幂等键,或明确 unknown commit 不可自动重试。 - - 失败错误区分参数、权限、风控、限流、收件人拒收和提交未知。 -- 验收:安全自发自收 fixture 完成 draft-create → exact get → send → 状态终态 → sent-folder exact read;批量中注入一项失败,验证 ledger 与非零整体结果;清理无测试草稿残留。 - -### `DS-Mail-002` — 回复/转发的草稿优先与 MIME 保真 - -- 用户任务:回复、回复全部或转发一封邮件,默认保存草稿,只有再次确认才发送。 -- 当前证据:DWS raw route 会创建回复/转发草稿后立即发送,无法对齐 Lark 的默认草稿语义;上游也无法证明 `In-Reply-To`、`References`、原始引用块和收件人集合正确。 -- 所需接口合同: - - 独立 `create_reply_draft`、`create_reply_all_draft`、`create_forward_draft`,返回稳定草稿 ID,不隐式发送。 - - 服务端生成并可读回线程关系头、回复全部去重后的 To/CC、转发引用块和附件继承结果。 - - 发送必须复用 `DS-Mail-001` 的确认、终态和幂等合同。 -- 验收:用隔离自发邮件分别创建三类草稿,精确 ID 读回核对父邮件、参与人集合和引用语义;未确认时远程发送调用为 0;确认发送后状态终态可验证。 - -### `DS-Mail-003` — 邮件修改、移动和删除的逐项终态 - -- 用户任务:批量标记已读/未读、增删标签、移动文件夹、软删除邮件。 -- 当前证据:raw batch route 多数只给聚合 success;删除后邮件仍可能可读,无法区分“移入已删除文件夹”“永久删除”“延迟可见”或“未生效”。 -- 所需接口合同: - - 每个输入 messageId 返回 `applied/already_applied/failed/unknown` 与稳定原因码。 - - 修改/移动后详情或摘要必须可读回 `isRead/tags/folderId`;删除返回明确 tombstone 或 folder transition。 - - 软删除和永久删除使用不同操作,危险级别与确认要求可声明。 - - 任何部分失败整体 outcome 为 `partial_failure` 且进程非零。 -- 验收:创建隔离邮件,执行 mark-unread/read、标签增删、移动与软删除,每步同 ID 读回;错误 ID 与合法 ID 混合时逐项 ledger 完整且整体非零。 - -### `DS-Mail-004` — 已读回执与签名 - -- 用户任务:识别邮件是否请求回执;确认后发送标准回执,或拒绝并清除提示;列出和查看默认签名。 -- 当前证据:现有 Mail 接口没有稳定暴露回执请求标签、专用发送/拒绝操作或签名读取资源,上游无法安全组合普通回复替代。 -- 所需接口合同: - - 消息详情公开稳定回执请求状态和请求者身份类型。 - - 专用 send/decline receipt 操作,幂等且返回状态;正文由服务端生成,不能让上游伪造。 - - 签名列表/详情返回稳定 ID、默认发送场景、HTML/文本内容和敏感字段标注。 -- 验收:预置请求回执邮件,未确认零写调用;发送/拒绝后状态读回且重复调用幂等;签名已知非空与合法空均可证明。 - -### `DS-Mail-005` — 新邮件监听的订阅生命周期 - -- 用户任务:在限定时间内监听新邮件,得到稳定、可恢复、可去重的事件流。 -- 当前证据:通用事件基础设施不能证明 Mail scope、订阅状态、ready marker、断线续传和消息读取权限形成完整任务链。 -- 所需接口合同:订阅/查询/退订;明确 user/bot 身份、scope 和租户开关;ready marker;事件 `eventId/messageId/mailbox/time`;断线 cursor、去重和界限参数;心跳不冒充业务事件。 -- 验收:隔离邮箱订阅后注入一封测试邮件,只收到一次并能以 messageId 精确读取;超时、权限缺失、断线重连和退订后零事件均有确定结果。 - -### `DS-Mail-006` — 模板附件与内联图片事务 - -- 用户任务:创建或更新含普通附件、内联图片和 HTML 的模板,同时保留未修改 MIME 结构。 -- 当前证据:本轮只对齐名称、主题、正文核心字段;现有多步上传缺少模板级事务、附件稳定 ID、失败回滚和更新时的结构保真证明。 -- 所需接口合同:创建/更新草稿会话、附件上传会话、content-id 映射、提交/取消;返回逐附件 ledger;更新提供版本或 etag,避免 last-write-wins 覆盖;失败可回滚且无孤儿文件。 -- 验收:普通附件和内联图片各一,创建后按模板 ID 读取附件 ID/名称/大小/content-id;更新正文不丢附件;中途失败自动取消并证明零孤儿资源。 - -### `DS-Mail-007` — 联系人写操作的稳定身份 - -- 用户任务:创建、更新、删除个人邮件联系人并验证精确对象。 -- 当前证据:真实 create 返回 `success=true` 但没有 contactId;上游只能用随机显示名再扫列表定位,无法用于一般用户输入,因为名称/邮箱可能重复。 -- 所需接口合同:create 返回稳定 contactId;get-by-id;update/delete 返回同 ID 与版本;列表支持 exact email 或 ID filter;重复联系人规则明确。 -- 验收:创建回执直接得到 ID,get-by-id 精确核对,更新同 ID,删除后 not-found/tombstone;重复邮箱和同名联系人有稳定结果而非猜测。 - -### `DS-Mail-008` — 统一成功、空结果与分页协议 - -- 用户任务:可靠地区分“确实没有结果”“还有下一页”“服务异常或响应漂移”。 -- 当前证据:同一产品的 success 同时出现布尔和字符串;hasMore 也出现两种编码;搜索终页用 `$`,部分列表用空串;零命中邮件会返回 `total=0` 加一个只有空收件人字段的占位对象。当前租户又没有空邮箱或空邮件文件夹,不能为无筛选列表证明 guaranteed-zero。 -- 所需接口合同: - - success 与 hasMore 统一为布尔;所有列表显式数组,合法空只返回 `[]`。 - - 统一 `nextCursor` 与 `endpointExhausted`;终页不使用业务哨兵对象或魔法值。 - - 每项稳定 ID 必填;total 使用整数;服务错误必须 `success=false` 和稳定错误码。 - - 保留兼容期,但提供 capability/version 让上游安全切换。 -- 验收:每个列表/搜索执行已知非空、保证零命中、坏 item、缺集合、错型、hasMore 无游标、重复游标;只有显式合法空成功。 - -### `DS-Mail-009` — 安全租户与真实 E2E fixture - -- 用户任务:在不触达真实业务收件人和内容的前提下验证所有高影响 Mail Shortcut。 -- 所需 fixture:隔离自发自收邮箱、可控第二收件人、回执请求邮件、可分享的测试聊天、安全事件订阅、测试签名、可回收附件;所有资源用随机无业务含义标记并有自动清理。 -- 权限:最小 Mail read/write/event、Drive attachment、IM share scopes 分离;可测试 user/bot 差异和缺权限错误。 -- 验收:stdout 只输出 PASS 标签与聚合计数;原始 JSON 只在临时目录;finally 清理;远端零测试草稿/模板/联系人/邮件/订阅残留;仓库和历史扫描无身份数据。 - -### `DS-Mail-010` — 草稿/模板可证明的清理终态 - -- 用户任务:用可回收 fixture 验证草稿与模板写 Shortcut,不留下无法确认的远端测试对象。 -- 当前证据:草稿创建/更新回执和 exact-ID 读回成功,但同一 ID 连续两次 batch-delete 后仍可读;模板 delete 返回成功后,get 仅为未分类失败,既非 typed nonfound 也不能证明 tombstone。 -- 所需接口合同:分离软删除与永久删除;返回稳定 ID、终态和幂等证据;get-by-id 对已永久删除对象返回稳定 `not_found/deleted` 错误或已审核 tombstone,不得空 body、通用失败或继续返回对象。 -- 验收:create/update → exact-ID readback → permanent delete → exact Shortcut + raw get 双层 typed absence;有界轮询后仍可读或终态未知时整体非零,且不得发布 Shortcut。 -- 临时处置:四个写 Shortcut 保持 `public=false` / `unavailable`,直到安全 fixture 与 typed absence 同时可证明。 - -## 4. Lark 对齐与平台差异 - -| Lark 用户任务 | 可精确对齐 | 平台差异 | DWS 推荐结论 | -|---|---|---|---| -| `+message` / `+messages` / `+thread` / `+triage` | yes | DWS 额外自动解析邮箱和收件箱,并严格发布完整性 | 已公开 | -| `+draft-create` / `+draft-edit` | blocked | 核心写回可证,但删除后同 ID 仍可读,无安全清理终态 | 不公开,保持 unavailable | -| `+template-create` / `+template-update` | blocked | 核心字段可读回,但 from/isDraft 不可验且删除后缺 typed nonfound | 不公开,保持 unavailable | -| `+send` / `+draft-send` | no | DWS raw 偏立即发送且缺统一终态/逐项 ledger | 暂不公开 Shortcut | -| `+reply` / `+reply-all` / `+forward` | no | DWS raw 会立即发送,Lark 默认保存草稿 | 暂不公开 Shortcut | -| `+message-modify` / `+message-trash` | no | 聚合 success 不足以证明逐项终态 | 暂不公开 Shortcut | -| `+send-receipt` / `+decline-receipt` | no | 缺专用接口和可验证标签 | platform unavailable | -| `+signature` | no | 缺签名读取资源 | platform unavailable | -| `+watch` | no | 缺完整订阅生命周期与安全 fixture | fixture + capability blocked | -| `+share-to-chat` | partial | raw 可调用但缺逐目标验证和安全 fixture | 保持 raw | -| `+lint-html` | no | DWS 未提供统一规则包 | downstream/local capability needed | - -## 5. 超越 Lark 的产品机会 - -| 产品原生能力 | 可形成的 DWS Shortcut | 安全/验证要求 | 优先级 | -|---|---|---|---| -| 文件夹、标签与联系人目录 | `+organize`:规则化移动、标记与标签组合 | 逐项 ledger、写后读回、补偿恢复 | P1 | -| 收信规则、白名单、黑名单、自动回复 | `+inbox-policy-audit` | 只读汇总优先;写操作强确认和版本化 | P2 | -| 邮箱日历 | `+mail-calendar-conflicts` | 与主 Calendar 的 ownership boundary 明确,禁止双写 | P2 | -| 发送状态与召回 | `+delivery-audit` | 终态、收件人粒度、召回结果和不可逆提示 | P1 | -| 附件导出与分享 | `+archive-message` | 精确 messageId、原子本地写入、敏感路径与清理 | P2 | - -## 6. 无需下游变更的上游修复 - -| Shortcut | 上游根因 | 已完成修复 | 回归证据 | -|---|---|---|---| -| 全部 list/search | 容忍式探测任意 result/data/list/items,坏元素静默丢弃 | 固定已观测路径、严格 success/数组/item/ID;无双态 fixture 的 leaf 不发布 | deterministic 响应矩阵;live 证据逐 leaf 记录,不作泛化 | -| `+search-mail` / `+triage` | `$` 终止游标被误作下一页;零命中占位对象被当邮件 | 明确 `$` 终页;仅窄规则归一化已观测哨兵 | 各完成 known-nonempty 20;3 个 fresh 零命中 raw 均为 `total=0` + 无稳定 ID/正文且收件字段全空的 reviewed sentinel + terminal cursor,exact 才归一化为显式 `[]`;不把该下游特例描述成 raw 空数组 | -| `+search-mail` / `+triage` 自动邮箱解析 | 严格化时只接受顶层对象数组,会拒绝历史已观测的字符串数组和 `result/data.emailAccounts` 包装 | 仅接受三个审核路径 `emailAccounts` / `result.emailAccounts` / `data.emailAccounts`,每项可为非空邮箱字符串或含非空 `email` 的对象;缺集合、错型、坏项或多路径冲突全部 fail-closed;空发件人也不再投影为空字符串成功 | top/result/data × string/object、blank/wrong/multiple-path 与 sender missing/null/wrong-type 回归覆盖;最终 live 未传 `--email` 执行 `+search-mail`/`+triage`,owning 响应为顶层 object-item 形态并成功解析 | -| `+unread-mail` / `+recent-mail` / `+thread-list` | 固定条件或文件夹不能保证零命中 | 严格响应代码已完成,但没有空邮箱/空文件夹证据时关闭发布 | BLOCKED fixture;不得修改真实邮件状态造空 | -| `+user-search` / `+find-mail-user` | `hasMore`/`nextCursor` 未交付;零命中被误报 validation error | 发布 complete/nextCursor;合法空成功 | 各完成 known-nonempty 20 + fresh raw 显式空;stable identity set 与 raw pagination/meta 精确一致;`+user-search` 同轮实跑历史 string `--limit` | -| `+tag-list` / `+template-list` / `+contact-list` | 无 query 的列表容易把末页/删除后列表误作合法空 | 严格响应代码已完成;无专用空邮箱和 typed cleanup 时关闭发布 | BLOCKED fixture;不把临时资源从列表消失记为零态 PASS | -| `+message(s)` / `+thread` | 缺任务层完整读取和身份绑定 | 自动邮箱解析、精确请求 ID 读回、保序多读 | `+message`/`+thread` 与同稳定 ID raw 完整对象一致;`+messages` 用两个不同 ID 验证输入顺序与逐对象一致 | -| 草稿/模板写 | 仅写回执会产生假成功 | 稳定 ID + exact get + 请求字段核对;清理无法证明时保持 unavailable | deterministic 回执/读回矩阵 PASS;live cleanup BLOCKED | - -### 6.1 clean executable HEAD 双层证据 - -| 公开入口 | exact Shortcut + owning raw 证据 | 状态 | -|---|---|---| -| `+search-mail`, `+triage` | 各 20 条 known-nonempty;3 个独立 fresh 零命中由 raw `total=0`、无稳定 ID/正文的单 sentinel 与 terminal cursor 共同证明,exact 严格归一化为显式空;稳定 message ID 集合和分页状态一致 | `PASS_WITH_REVIEWED_ZERO_ENCODING`;最终 SHA 见 PR 证据 | -| `+user-search`, `+find-mail-user` | 各 20 条 known-nonempty 与 raw 显式 fresh zero;条件身份集合和分页状态一致 | `PASS`;最终 SHA 见 PR 证据 | -| `+folder-list` | 顶层 5 条 nonempty;本轮先由 raw 验证同一父文件夹确实为空,再由 Shortcut 返回显式空;ID 集合一致 | `PASS`;最终 SHA 见 PR 证据 | -| `+message`, `+messages`, `+thread` | 单邮件/会话同稳定 ID 完整对象一致;批量用两个不同 ID 验证请求顺序和逐对象一致 | `PASS`;最终 SHA 见 PR 证据 | - -8 个公开入口均在最终 clean runtime tree 从零重跑;其中 6 个使用标准 raw 显式空或精确对象证据,2 个邮件搜索使用上述审核过的下游零命中 sentinel 编码。最终可执行 SHA 写入 PR 证据,本文只保留脱敏业务断言。 - -## 7. 安全与脱敏声明 - -- 本文不含用户、组织、租户、profile、邮箱、人员姓名、邮件/会话/模板/联系人/聊天真实 ID。 -- 本文不含邮件主题正文、收发件人、trace/request ID、token、签名 URL、电话或真实业务时间。 -- 真实 E2E 原始响应仅在仓库外临时目录解析;普通输出只保留能力标签、计数和布尔断言。 -- 临时草稿虽已执行两次 batch-delete 但仍可按同 ID 读取;临时模板删除后也未获得 typed nonfound。两者都不记为清理 PASS,四个写 Shortcut 因此保持 unavailable。 -- 当前邮箱没有已验证的空邮件文件夹或专用空邮箱;因此 `+unread-mail`、`+recent-mail`、`+thread-list`、`+tag-list`、`+template-list`、`+contact-list` 不记 live 双态 PASS,并保持 unavailable。 -- 最终提交前仍需扫描最终树、未跟踪文件和 `origin/main..HEAD` 全部历史。 diff --git a/docs/shortcut-public-catalog.json b/docs/shortcut-public-catalog.json index 6e6904cb..8ed5fd5f 100644 --- a/docs/shortcut-public-catalog.json +++ b/docs/shortcut-public-catalog.json @@ -1,6 +1,6 @@ { - "generated_at": "2026-08-20T11:51:44.156046", - "count": 416, + "generated_at": "2026-08-21T10:07:48.251313", + "count": 418, "results": [ { "suite": "semantic", @@ -3634,18 +3634,24 @@ "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "sheet", "command": "+list-sheets", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格要求 success=true、显式 sheets 数组、非空且唯一的 sheetId 与标题;提供完整标题本地精确筛选,因此可分别证明已知非空和合法零命中,未知结构绝不降级为空数组。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "sheet", "command": "+read", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格校验 success、二维 cells、行列坐标与完成证据;服务返回 hasMore=true 时因没有可执行续页游标而失败关闭,保留 Sheet 读取与 AITable/Base 记录查询的产品边界。", + "availability": "available" }, { "suite": "semantic", @@ -3857,6 +3863,26 @@ "semantic_delta": "更新指定字段后读取详情逐字段核验。", "availability": "available" }, + { + "suite": "semantic", + "service": "whiteboard", + "command": "+query", + "risk": "read", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格投影要求 success=true、OpenNodes V1、显式 pages 数组、每页稳定 id 与显式 nodes 数组,并校验跨页节点身份及服务端完整性摘要。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "whiteboard", + "command": "+update", + "risk": "high-risk-write", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "首次远端调用前完成 OpenNodes V1 校验与用户确认;写后要求 success=true、同一目标、非空终态回执、createdNodeIds/idMap 精确映射,再按真实节点身份独立 query 读回请求关键字段。", + "availability": "available" + }, { "suite": "semantic", "service": "wiki", diff --git a/internal/app/markdown_shortcut_boundary_test.go b/internal/app/markdown_shortcut_boundary_test.go new file mode 100644 index 00000000..74be3dc4 --- /dev/null +++ b/internal/app/markdown_shortcut_boundary_test.go @@ -0,0 +1,175 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package app + +import ( + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/spf13/cobra" +) + +func TestCrossPlatformCoverageSheetWhiteboardMarkdownRoutes(t *testing.T) { + root := NewRootCommand() + tools := deliverySchemaAllToolsForHelpFlagTest(t, root) + assertMarkdownLarkTasksRouteWithoutDuplicateShortcuts(t, root, tools) + assertMarkdownDriveRoutesStayCrossProduct(t, root, tools) + assertWhiteboardPublicShortcutsStayAvailableInSchema(t, root, tools) +} + +func assertMarkdownLarkTasksRouteWithoutDuplicateShortcuts(t *testing.T, root *cobra.Command, tools map[string]map[string]any) { + t.Helper() + registered := 0 + for _, item := range shortcut.All() { + if item.Service == "markdown" { + registered++ + } + } + if registered != 0 { + t.Fatalf("registered Markdown Shortcuts=%d, want 0: existing composite leaves own these workflows", registered) + } + + type route struct { + canonical string + confirmation string + flags []string + } + routes := map[string]route{ + "create": { + canonical: "markdown.create", confirmation: "not_required", + flags: []string{"content", "file", "folder", "name", "space-id", "workspace"}, + }, + "fetch": { + canonical: "markdown.fetch", confirmation: "not_required", + flags: []string{"node", "output", "space-id", "workspace"}, + }, + "overwrite": { + canonical: "markdown.overwrite", confirmation: "user_required", + flags: []string{"content", "dry-run", "file", "name", "node", "space-id", "workspace"}, + }, + "patch": { + canonical: "markdown.patch", confirmation: "user_required", + flags: []string{"content", "dry-run", "node", "pattern", "regex", "space-id", "workspace"}, + }, + "diff": { + canonical: "markdown.diff", confirmation: "not_required", + flags: []string{"context", "file", "node", "version", "version2"}, + }, + } + + group := mustFindCommand(t, root, "markdown") + children := map[string]bool{} + for _, child := range group.Commands() { + children[child.Name()] = true + } + if len(children) != len(routes) { + t.Fatalf("Markdown ordinary leaves=%v, want exactly five routed workflows", children) + } + + for name, want := range routes { + leaf := mustFindCommand(t, root, "markdown", name) + if leaf.Hidden || !leaf.Runnable() { + t.Errorf("markdown %s hidden/runnable=%v/%v, want false/true", name, leaf.Hidden, leaf.Runnable()) + } + if !children[name] { + t.Errorf("markdown %s is not mounted on the ordinary product group", name) + } + for _, flag := range want.flags { + if leaf.Flags().Lookup(flag) == nil { + t.Errorf("markdown %s is missing routed flag --%s", name, flag) + } + } + if shortcut.InPublicCatalog("markdown", "+"+name) { + t.Errorf("markdown +%s unexpectedly entered the public Shortcut catalog", name) + } + + meta, ok := cli.ResolveMeta("markdown " + name) + if !ok { + t.Errorf("markdown %s missing from assembled Schema", name) + continue + } + if meta.Identity.Canonical != want.canonical || meta.Identity.CLIPath != "markdown "+name { + t.Errorf("markdown %s identity=%#v, want canonical=%q cli_path=%q", name, meta.Identity, want.canonical, "markdown "+name) + } + if meta.Safety.Confirmation != want.confirmation { + t.Errorf("markdown %s confirmation=%q, want %q", name, meta.Safety.Confirmation, want.confirmation) + } + + tool := tools[want.canonical] + if tool == nil { + t.Errorf("markdown %s missing from full delivery Schema", name) + continue + } + if got := schemaContractString(tool["availability"]); got != "available" { + t.Errorf("markdown %s availability=%q, want available", name, got) + } + if got := schemaContractString(tool["interface_mode"]); got != "composite" { + t.Errorf("markdown %s interface_mode=%q, want composite", name, got) + } + if got := schemaContractString(tool["interface_reason"]); got == "" { + t.Errorf("markdown %s is missing the reviewed composite routing reason", name) + } + } +} + +func assertMarkdownDriveRoutesStayCrossProduct(t *testing.T, root *cobra.Command, tools map[string]map[string]any) { + t.Helper() + driveShortcuts := map[string]string{ + "+copy": "drive.shortcut_copy", + "+delete": "drive.shortcut_delete", + "+find-file": "drive.shortcut_find_file", + "+list": "drive.shortcut_list", + "+move": "drive.shortcut_move", + "+publish-get": "drive.shortcut_publish_get", + "+recycle-restore": "drive.shortcut_recycle_restore", + "+rename": "drive.shortcut_rename", + "+version-download": "drive.shortcut_version_download", + "+version-get": "drive.shortcut_version_get", + "+version-history": "drive.shortcut_version_history", + "+version-revert": "drive.shortcut_version_revert", + } + for name, canonical := range driveShortcuts { + leaf := mustFindCommand(t, root, "drive", name) + if leaf.Hidden || !leaf.Runnable() { + t.Errorf("drive %s hidden/runnable=%v/%v, want false/true", name, leaf.Hidden, leaf.Runnable()) + } + if !shortcut.InPublicCatalog("drive", name) { + t.Errorf("drive %s is not in the public Shortcut catalog", name) + } + assertMarkdownCrossProductRoute(t, tools, "drive "+name, canonical) + } + + ordinaryRoutes := map[string]string{ + "drive permission list": "drive.list_permission", + "drive pull": "drive.folder_pull", + "drive push": "drive.folder_push", + "drive status": "drive.folder_status", + "drive sync": "drive.folder_sync", + "wiki node list": "wiki.list_nodes", + } + for cliPath, canonical := range ordinaryRoutes { + assertMarkdownCrossProductRoute(t, tools, cliPath, canonical) + } +} + +func assertMarkdownCrossProductRoute(t *testing.T, tools map[string]map[string]any, cliPath, canonical string) { + t.Helper() + meta, ok := cli.ResolveMeta(cliPath) + if !ok { + t.Errorf("cross-product route %q is missing from assembled Schema", cliPath) + return + } + if meta.Identity.Canonical != canonical || meta.Identity.CLIPath != cliPath { + t.Errorf("cross-product route %q identity=%#v, want canonical=%q", cliPath, meta.Identity, canonical) + } + tool := tools[canonical] + if tool == nil { + t.Errorf("cross-product route %q is missing from full delivery Schema", cliPath) + return + } + if got := schemaContractString(tool["availability"]); got != "available" { + t.Errorf("cross-product route %q availability=%q, want available", cliPath, got) + } +} diff --git a/internal/app/schema_shortcut_contract_test.go b/internal/app/schema_shortcut_contract_test.go index ed89cc80..6db414c1 100644 --- a/internal/app/schema_shortcut_contract_test.go +++ b/internal/app/schema_shortcut_contract_test.go @@ -16,12 +16,12 @@ import ( ) const ( - publicShortcutCount = 415 + publicShortcutCount = 417 // schemaPublishedShortcutCount counts every delivered *.shortcut_* tool, // including reviewed hidden compatibility and unavailable contracts. - schemaPublishedShortcutCount = 453 + schemaPublishedShortcutCount = 455 // publiclyDeliveredShortcutCount is the public-catalog subset of that surface. - publiclyDeliveredShortcutCount = 415 + publiclyDeliveredShortcutCount = 417 ) func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) { diff --git a/internal/app/whiteboard_shortcut_availability_test.go b/internal/app/whiteboard_shortcut_availability_test.go new file mode 100644 index 00000000..28256c8e --- /dev/null +++ b/internal/app/whiteboard_shortcut_availability_test.go @@ -0,0 +1,42 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package app + +import ( + "testing" + + "github.com/spf13/cobra" +) + +func assertWhiteboardPublicShortcutsStayAvailableInSchema(t *testing.T, root *cobra.Command, tools map[string]map[string]any) { + t.Helper() + for canonical, command := range map[string]string{ + "whiteboard.shortcut_query": "+query", + "whiteboard.shortcut_update": "+update", + } { + leaf, _, err := root.Find([]string{"whiteboard", command}) + if err != nil || leaf == nil || leaf.Name() != command { + t.Errorf("find whiteboard %s: leaf=%v err=%v", command, leaf, err) + } else if leaf.Hidden || !leaf.Runnable() { + t.Errorf("whiteboard %s hidden/runnable=%v/%v, want false/true", command, leaf.Hidden, leaf.Runnable()) + } + tool := tools[canonical] + if tool == nil { + t.Errorf("public %s missing from delivery Schema surface", canonical) + continue + } + if got := schemaContractString(tool["availability"]); got != "available" { + t.Errorf("%s availability=%q, want available", canonical, got) + } + if got := schemaContractString(tool["interface_mode"]); got != "composite" { + t.Errorf("%s interface_mode=%q, want composite", canonical, got) + } + if got := schemaContractString(tool["interface_reason"]); got == "" { + t.Errorf("%s missing composite adapter reason", canonical) + } + if tool["interface_ref"] != nil { + t.Errorf("%s composite interface_ref=%#v, want nil", canonical, tool["interface_ref"]) + } + } +} diff --git a/internal/shortcut/builtin/builtin.go b/internal/shortcut/builtin/builtin.go index aca6aa6e..127a35c0 100644 --- a/internal/shortcut/builtin/builtin.go +++ b/internal/shortcut/builtin/builtin.go @@ -41,6 +41,7 @@ import ( _ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/sheet" _ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/smart" _ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/todo" + _ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/whiteboard" _ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/wiki" "github.com/spf13/cobra" diff --git a/internal/shortcut/builtin/sheet_semantic_catalog_test.go b/internal/shortcut/builtin/sheet_semantic_catalog_test.go new file mode 100644 index 00000000..d6650fd0 --- /dev/null +++ b/internal/shortcut/builtin/sheet_semantic_catalog_test.go @@ -0,0 +1,72 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package builtin_test + +import ( + "encoding/json" + "os" + "sort" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func TestCrossPlatformCoverageSheetSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) { + raw, err := os.ReadFile("../semantic_catalog_sheet.json") + if err != nil { + t.Fatal(err) + } + var source chatSemanticCatalogFixture + if err := json.Unmarshal(raw, &source); err != nil { + t.Fatal(err) + } + if source.Service != "sheet" { + t.Fatalf("service=%q", source.Service) + } + registered := map[string]shortcut.Shortcut{} + for _, item := range shortcut.All() { + if item.Service != "sheet" { + continue + } + if _, duplicate := registered[item.Command]; duplicate { + t.Fatalf("duplicate Sheet Shortcut %s", item.Command) + } + registered[item.Command] = item + } + if len(registered) != 2 || len(source.Shortcuts) != 2 { + t.Fatalf("registered/catalog=%d/%d, want 2/2", len(registered), len(source.Shortcuts)) + } + var missing, stale []string + for command, item := range registered { + record, ok := source.Shortcuts[command] + if !ok { + missing = append(missing, command) + continue + } + if !record.Reviewed || !item.SemanticReviewed || strings.TrimSpace(item.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta { + t.Errorf("%s semantic review facts drifted", command) + } + if !record.Public || item.Hidden || !shortcut.InPublicCatalog("sheet", command) { + t.Errorf("%s is not delivered as reviewed public", command) + } + if item.Availability != shortcut.AvailabilityAvailable || item.Risk != record.Risk { + t.Errorf("%s availability/risk=%q/%q", command, item.Availability, item.Risk) + } + if item.Contract.Empty() || item.Contract.Result == nil || item.Contract.Pagination != nil || strings.TrimSpace(item.Safety.Effect) == "" || item.OutputRollout != output.RolloutUnifiedActive { + t.Errorf("%s lacks Result/Safety/unified output or publishes false pagination", command) + } + } + for command := range source.Shortcuts { + if _, ok := registered[command]; !ok { + stale = append(stale, command) + } + } + sort.Strings(missing) + sort.Strings(stale) + if len(missing) != 0 || len(stale) != 0 { + t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale) + } +} diff --git a/internal/shortcut/builtin/whiteboard_semantic_catalog_test.go b/internal/shortcut/builtin/whiteboard_semantic_catalog_test.go new file mode 100644 index 00000000..bbdda746 --- /dev/null +++ b/internal/shortcut/builtin/whiteboard_semantic_catalog_test.go @@ -0,0 +1,84 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package builtin_test + +import ( + "encoding/json" + "os" + "sort" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func TestCrossPlatformCoverageWhiteboardSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) { + raw, err := os.ReadFile("../semantic_catalog_whiteboard.json") + if err != nil { + t.Fatal(err) + } + var source chatSemanticCatalogFixture + if err := json.Unmarshal(raw, &source); err != nil { + t.Fatal(err) + } + if source.Service != "whiteboard" { + t.Fatalf("service=%q", source.Service) + } + registered := map[string]shortcut.Shortcut{} + for _, item := range shortcut.All() { + if item.Service != "whiteboard" { + continue + } + if _, duplicate := registered[item.Command]; duplicate { + t.Fatalf("duplicate Whiteboard Shortcut %s", item.Command) + } + registered[item.Command] = item + } + if len(registered) != 2 || len(source.Shortcuts) != 2 { + t.Fatalf("registered/catalog=%d/%d, want 2/2", len(registered), len(source.Shortcuts)) + } + public, unavailable, hidden := 0, 0, 0 + var missing, stale []string + for command, item := range registered { + record, ok := source.Shortcuts[command] + if !ok { + missing = append(missing, command) + continue + } + if !record.Reviewed || !item.SemanticReviewed || strings.TrimSpace(item.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta { + t.Errorf("%s semantic review facts drifted", command) + } + if !record.Public || item.Hidden || !shortcut.InPublicCatalog("whiteboard", command) { + t.Errorf("%s did not enter the public surface", command) + } + if !record.Public { + hidden++ + } else { + public++ + } + if item.Availability == shortcut.AvailabilityUnavailable { + unavailable++ + } + if item.Availability != shortcut.AvailabilityAvailable || record.Availability != shortcut.AvailabilityAvailable || item.Risk != record.Risk { + t.Errorf("%s availability/risk=%q/%q", command, item.Availability, item.Risk) + } + if item.Contract.Empty() || item.Contract.Result == nil || item.Contract.Pagination != nil || strings.TrimSpace(item.Safety.Effect) == "" || item.OutputRollout != output.RolloutUnifiedActive || item.Contract.Interface == nil || item.Contract.Interface.Availability != "available" || strings.TrimSpace(item.Contract.Interface.Reason) == "" { + t.Errorf("%s lacks Result/Safety/unified output or publishes false pagination", command) + } + } + if public != 2 || unavailable != 0 || hidden != 0 { + t.Fatalf("Whiteboard source/public/unavailable/hidden=2/%d/%d/%d, want 2/2/0/0", public, unavailable, hidden) + } + for command := range source.Shortcuts { + if _, ok := registered[command]; !ok { + stale = append(stale, command) + } + } + sort.Strings(missing) + sort.Strings(stale) + if len(missing) != 0 || len(stale) != 0 { + t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale) + } +} diff --git a/internal/shortcut/public_catalog_generated.go b/internal/shortcut/public_catalog_generated.go index 1d81b103..a78f8901 100644 --- a/internal/shortcut/public_catalog_generated.go +++ b/internal/shortcut/public_catalog_generated.go @@ -402,6 +402,8 @@ func generatedPublicShortcutCatalog() map[string]struct{} { "todo\u0000+search": {}, "todo\u0000+todo-done": {}, "todo\u0000+update": {}, + "whiteboard\u0000+query": {}, + "whiteboard\u0000+update": {}, "wiki\u0000+delete-space": {}, "wiki\u0000+feed-list": {}, "wiki\u0000+member-add": {}, diff --git a/internal/shortcut/semantic_catalog.go b/internal/shortcut/semantic_catalog.go index 60aa42c6..c72cc9ec 100644 --- a/internal/shortcut/semantic_catalog.go +++ b/internal/shortcut/semantic_catalog.go @@ -49,6 +49,12 @@ var dingSemanticCatalogJSON []byte //go:embed semantic_catalog_report.json var reportSemanticCatalogJSON []byte +//go:embed semantic_catalog_sheet.json +var sheetSemanticCatalogJSON []byte + +//go:embed semantic_catalog_whiteboard.json +var whiteboardSemanticCatalogJSON []byte + type semanticCatalogFile struct { Version int `json:"version"` Service string `json:"service"` @@ -81,6 +87,8 @@ var reviewedSemanticCatalog = mustLoadSemanticCatalogs( oaSemanticCatalogJSON, dingSemanticCatalogJSON, reportSemanticCatalogJSON, + sheetSemanticCatalogJSON, + whiteboardSemanticCatalogJSON, ) func mustLoadSemanticCatalogs(sources ...[]byte) map[string]semanticCatalogRecord { diff --git a/internal/shortcut/semantic_catalog_sheet.json b/internal/shortcut/semantic_catalog_sheet.json new file mode 100644 index 00000000..3107cb70 --- /dev/null +++ b/internal/shortcut/semantic_catalog_sheet.json @@ -0,0 +1,21 @@ +{ + "version": 1, + "service": "sheet", + "default_availability": "available", + "shortcuts": { + "+list-sheets": { + "disposition": "semantic_adapter", + "semantic_delta": "严格要求 success=true、显式 sheets 数组、非空且唯一的 sheetId 与标题;提供完整标题本地精确筛选,因此可分别证明已知非空和合法零命中,未知结构绝不降级为空数组。", + "risk": "read", + "public": true, + "reviewed": true + }, + "+read": { + "disposition": "semantic_adapter", + "semantic_delta": "严格校验 success、二维 cells、行列坐标与完成证据;服务返回 hasMore=true 时因没有可执行续页游标而失败关闭,保留 Sheet 读取与 AITable/Base 记录查询的产品边界。", + "risk": "read", + "public": true, + "reviewed": true + } + } +} diff --git a/internal/shortcut/semantic_catalog_whiteboard.json b/internal/shortcut/semantic_catalog_whiteboard.json new file mode 100644 index 00000000..2cc8a086 --- /dev/null +++ b/internal/shortcut/semantic_catalog_whiteboard.json @@ -0,0 +1,23 @@ +{ + "version": 1, + "service": "whiteboard", + "default_availability": "available", + "shortcuts": { + "+query": { + "disposition": "semantic_adapter", + "semantic_delta": "严格投影要求 success=true、OpenNodes V1、显式 pages 数组、每页稳定 id 与显式 nodes 数组,并校验跨页节点身份及服务端完整性摘要。", + "risk": "read", + "availability": "available", + "public": true, + "reviewed": true + }, + "+update": { + "disposition": "primary_smart", + "semantic_delta": "首次远端调用前完成 OpenNodes V1 校验与用户确认;写后要求 success=true、同一目标、非空终态回执、createdNodeIds/idMap 精确映射,再按真实节点身份独立 query 读回请求关键字段。", + "risk": "high-risk-write", + "availability": "available", + "public": true, + "reviewed": true + } + } +} diff --git a/internal/shortcut/sheet/sheet.go b/internal/shortcut/sheet/sheet.go index 8f67cbc0..52b33818 100644 --- a/internal/shortcut/sheet/sheet.go +++ b/internal/shortcut/sheet/sheet.go @@ -17,9 +17,17 @@ package sheet import ( + "encoding/json" + "fmt" + "math" + "strings" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/responsecheck" ) // ── workbook & worksheet ────────────────────────────────────────────── @@ -27,12 +35,13 @@ import ( // Create creates a new DingTalk online spreadsheet document. // ListSheets lists all worksheets in a spreadsheet document. var ListSheets = shortcut.Shortcut{ - Service: "sheet", - Command: "+list-sheets", - Product: "sheet", - Description: "获取表格文档中全部工作表列表", - Intent: "当你拿到一个表格文档、想先了解它里面有哪些工作表(sheet)以及各自的 sheetId 时使用,通常作为读写具体数据前的第一步;传入表格文档 ID 或 URL,返回工作表清单。", - Risk: shortcut.RiskRead, + OutputRollout: output.RolloutUnifiedActive, + Service: "sheet", + Command: "+list-sheets", + Product: "sheet", + Description: "严格列出在线电子表格的工作表,并可按完整标题精确筛选", + Intent: "需要发现一个在线电子表格中的工作表与稳定 sheetId,或验证某个完整工作表标题是否存在时", + Risk: shortcut.RiskRead, Safety: contract.SafetySpec{ Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", @@ -45,107 +54,95 @@ var ListSheets = shortcut.Shortcut{ CLIPath: "sheet +list-sheets", PrimaryCLIPath: "sheet +list-sheets", }, - Description: "获取表格文档中全部工作表列表", + Description: "严格列出在线电子表格的工作表", Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", }, Selection: contract.SelectionSpec{ - AgentSummary: "获取表格文档中全部工作表列表", - UseWhen: []string{"当你拿到一个表格文档、想先了解它里面有哪些工作表(sheet)以及各自的 sheetId 时使用,通常作为读写具体数据前的第一步;传入表格文档 ID 或 URL,返回工作表清单。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, + AgentSummary: "严格列出在线电子表格的工作表,并可按完整标题精确筛选", + UseWhen: []string{"需要发现一个在线电子表格中的工作表与稳定 sheetId,或验证某个完整工作表标题是否存在时"}, + AvoidWhen: []string{"需要管理 AITable/Base 的数据表或记录时改用 aitable;需要原始 get_all_sheets 响应时改用 sheet list"}, Examples: []string{"dws sheet +list-sheets --node NODE_ID"}, }, + Parameters: []contract.ParamDecl{ + {Name: "node", Property: "node"}, + {Name: "title", Property: "title"}, + }, + Result: &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"经过严格校验的工作表列表","properties":{"count":{"type":"integer","description":"精确筛选后的工作表数量"},"sheets":{"type":"array","description":"工作表条目;显式空数组表示合法零命中","items":{"type":"object","description":"带稳定身份的工作表","properties":{"sheetId":{"type":"string","description":"稳定工作表 ID"},"title":{"type":"string","description":"工作表完整标题"},"index":{"type":"integer","description":"可选工作表顺序"},"visibility":{"description":"可选工作表可见性"},"rowCount":{"type":"integer","description":"可选行数"},"columnCount":{"type":"integer","description":"可选列数"}},"required":["sheetId","title"],"additionalProperties":false}}},"required":["count","sheets"],"additionalProperties":false}`), + SensitivePaths: []string{"sheets.sheetId", "sheets.title"}, + }, }, Flags: []shortcut.Flag{ - {Name: "node", Type: shortcut.FlagString, Desc: "表格文档 ID 或 URL", Required: true}, + {Name: "node", Type: shortcut.FlagString, Desc: "表格文档 ID 或 URL;--node 去除空白后不能为空", Required: true}, + {Name: "title", Type: shortcut.FlagString, Desc: "按完整工作表标题精确筛选(区分大小写);显式传入时去除空白后不能为空"}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"node"}, Description: "--node 去除空白后不能为空"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"title"}, Description: "--title 显式传入时去除空白后不能为空"}, }, Tips: []string{`dws sheet +list-sheets --node NODE_ID`}, + Validate: func(rt *shortcut.RuntimeContext) error { + if err := validateSheetNode(rt); err != nil { + return err + } + return validateOptionalSheetString(rt, "title") + }, Execute: func(rt *shortcut.RuntimeContext) error { data, err := rt.CallMCPData("sheet", "get_all_sheets", map[string]any{"nodeId": rt.Str("node")}) if err != nil { return err } - sheets := listSheetsProject(data) + sheets, err := projectSheetList(data, rt.Str("title")) + if err != nil { + return err + } return rt.Output(map[string]any{"count": len(sheets), "sheets": sheets}) }, } -// listSheetsProject reshapes the raw get_all_sheets response into a clean -// worksheet list (sheetId/title/index/visibility/rowCount/columnCount) — the -// the clean output projection applied to every list command. Both -// the list container and the per-item field names are probed defensively across -// candidate keys, so an empty/unknown shape yields an empty list rather than a -// crash or fabricated data. -func listSheetsProject(data map[string]any) []map[string]any { - raw := listSheetsResolveList(data) - out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { +func projectSheetList(data map[string]any, exactTitle string) ([]map[string]any, error) { + items, err := responsecheck.RequireObjectCollection(data, "sheet/get_all_sheets", "sheets") + if err != nil { + return nil, err + } + seen := make(map[string]struct{}, len(items)) + out := make([]map[string]any, 0, len(items)) + for index, item := range items { + sheetID, err := requireSheetString(item, index, "sheetId") + if err != nil { + return nil, err + } + if _, duplicate := seen[sheetID]; duplicate { + return nil, responsecheck.Error("sheet/get_all_sheets", "duplicate_sheet_id", fmt.Sprintf("响应 sheets[%d] 的 sheetId 重复", index)) + } + seen[sheetID] = struct{}{} + title, err := requireSheetStringAliases(item, index, "name", "title") + if err != nil { + return nil, err + } + if exactTitle != "" && title != exactTitle { continue } - row := map[string]any{} - if v, ok := listSheetsFirst(m, "sheetId", "sheet_id", "id"); ok { - row["sheetId"] = v + row := map[string]any{"sheetId": sheetID, "title": title} + if err := copySheetOptionalNumber(row, item, index, "index", "index", "sheetIndex"); err != nil { + return nil, err } - if v, ok := listSheetsFirst(m, "title", "name", "sheetName", "sheet_name"); ok { - row["title"] = v + if err := copySheetOptionalVisibility(row, item, index, "visibility", "visibility", "hidden", "visible"); err != nil { + return nil, err } - if v, ok := listSheetsFirst(m, "index", "sheetIndex"); ok { - row["index"] = v + if err := copySheetOptionalNumber(row, item, index, "rowCount", "rowCount", "row_count", "rows"); err != nil { + return nil, err } - if v, ok := listSheetsFirst(m, "visibility", "hidden", "visible"); ok { - row["visibility"] = v - } - if v, ok := listSheetsFirst(m, "rowCount", "row_count", "rows"); ok { - row["rowCount"] = v - } - if v, ok := listSheetsFirst(m, "columnCount", "column_count", "columns", "colCount"); ok { - row["columnCount"] = v - } - if len(row) > 0 { - out = append(out, row) + if err := copySheetOptionalNumber(row, item, index, "columnCount", "columnCount", "column_count", "columns", "colCount"); err != nil { + return nil, err } + out = append(out, row) } - return out -} - -// listSheetsResolveList locates the worksheet array inside the response, -// tolerating a bare top-level array container or nesting one level deeper under -// a common envelope key. -func listSheetsResolveList(data map[string]any) []any { - if data == nil { - return []any{} - } - for _, key := range []string{"sheets", "result", "data", "list", "items", "worksheets"} { - v, ok := data[key] - if !ok { - continue - } - if arr, ok := v.([]any); ok { - return arr - } - if inner, ok := v.(map[string]any); ok { - for _, ik := range []string{"sheets", "list", "items", "worksheets", "result", "data"} { - if arr, ok := inner[ik].([]any); ok { - return arr - } - } - } - } - return []any{} -} - -// listSheetsFirst returns the first present candidate key's value. -func listSheetsFirst(m map[string]any, keys ...string) (any, bool) { - for _, k := range keys { - if v, ok := m[k]; ok { - return v, true - } - } - return nil, false + return out, nil } // SheetInfo returns the detail of a single worksheet. @@ -157,12 +154,13 @@ func listSheetsFirst(m map[string]any, keys ...string) (any, bool) { // Read reads structured per-cell data from a worksheet range. var Read = shortcut.Shortcut{ - Service: "sheet", - Command: "+read", - Product: "sheet", - Description: "读取工作表指定范围的结构化单元格数据", - Intent: "当你需要按单元格逐格获取数据(含类型、公式或格式化值等结构化信息)以便程序处理时使用;传入表格与可选范围(A1 表示法,不传则全部),可指定取格式化值/原始值/公式,返回结构化单元格数组。若只想要纯文本可改用 +csv-get。", - Risk: shortcut.RiskRead, + OutputRollout: output.RolloutUnifiedActive, + Service: "sheet", + Command: "+read", + Product: "sheet", + Description: "完整读取并严格校验在线电子表格范围;截断结果失败关闭", + Intent: "需要逐格读取在线电子表格的值、公式或格式化值,并要求结果未被服务端截断时", + Risk: shortcut.RiskRead, Safety: contract.SafetySpec{ Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", @@ -175,26 +173,57 @@ var Read = shortcut.Shortcut{ CLIPath: "sheet +read", PrimaryCLIPath: "sheet +read", }, - Description: "读取工作表指定范围的结构化单元格数据", + Description: "完整读取并严格校验工作表范围", Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", }, Selection: contract.SelectionSpec{ - AgentSummary: "读取工作表指定范围的结构化单元格数据", - UseWhen: []string{"当你需要按单元格逐格获取数据(含类型、公式或格式化值等结构化信息)以便程序处理时使用;传入表格与可选范围(A1 表示法,不传则全部),可指定取格式化值/原始值/公式,返回结构化单元格数组。若只想要纯文本可改用 +csv-get。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, + AgentSummary: "完整读取并严格校验在线电子表格范围;截断结果失败关闭", + UseWhen: []string{"需要逐格读取在线电子表格的值、公式或格式化值,并要求结果未被服务端截断时"}, + AvoidWhen: []string{"需要纯 CSV 时用 sheet csv-get;需要 AITable/Base 结构化记录时用 aitable;超大范围应拆小后重试,不能把截断结果当完整成功"}, Examples: []string{"dws sheet +read --node NODE_ID --sheet-id SHEET_ID --range \"A1:D10\""}, }, + Parameters: []contract.ParamDecl{ + {Name: "node", Property: "node"}, + {Name: "sheet-id", Property: "sheetId"}, + {Name: "range", Property: "range"}, + {Name: "value-render-option", Property: "valueRenderOption"}, + }, + DryRun: &contract.DryRunSpec{ + PreviewKind: contract.DryRunPreviewRequest, + RemoteReads: false, + }, + Result: &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"完整且未截断的单元格范围","properties":{"cells":{"type":"array","description":"与行列坐标对齐的二维单元格对象数组","items":{"type":"array","description":"一行单元格对象","items":{"type":"object","description":"单元格结构化数据","additionalProperties":true}}},"colIndices":{"type":"array","description":"与 cells 列对齐的 A1 列标","items":{"type":"string"}},"rowIndices":{"type":"array","description":"与 cells 行对齐的一基行号","items":{"type":"integer"}},"complete":{"type":"boolean","description":"结果已证明完整;成功结果恒为 true"},"hasMore":{"type":"boolean","description":"服务端截断标记;成功结果恒为 false"},"truncationReasons":{"type":"array","description":"截断原因;成功结果恒为空数组","items":{"type":"string"}},"resolvedRange":{"type":"string","description":"服务解析后的请求范围"},"returnedRange":{"type":"string","description":"服务实际返回的范围"},"message":{"type":"string","description":"可选服务说明"}},"required":["cells","colIndices","rowIndices","complete","hasMore","truncationReasons"],"additionalProperties":false}`), + SensitivePaths: []string{"cells"}, + }, }, Flags: []shortcut.Flag{ - {Name: "node", Type: shortcut.FlagString, Desc: "表格文档 ID 或 URL", Required: true}, - {Name: "sheet-id", Type: shortcut.FlagString, Desc: "工作表 ID 或名称 (不传则第一个工作表)"}, - {Name: "range", Type: shortcut.FlagString, Desc: "读取范围,A1 表示法 (不传则全部数据)"}, + {Name: "node", Type: shortcut.FlagString, Desc: "表格文档 ID 或 URL;--node 去除空白后不能为空", Required: true}, + {Name: "sheet-id", Type: shortcut.FlagString, Desc: "工作表 ID 或名称 (不传则第一个工作表);显式传入时去除空白后不能为空"}, + {Name: "range", Type: shortcut.FlagString, Desc: "读取范围,A1 表示法 (不传则全部数据);显式传入时去除空白后不能为空"}, {Name: "value-render-option", Type: shortcut.FlagString, Desc: "取值模式", Enum: []string{"formatted_value", "raw_value", "formula"}}, }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"node"}, Description: "--node 去除空白后不能为空"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"sheet-id"}, Description: "--sheet-id 显式传入时去除空白后不能为空"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"range"}, Description: "--range 显式传入时去除空白后不能为空"}, + }, Tips: []string{`dws sheet +read --node NODE_ID --sheet-id SHEET_ID --range "A1:D10"`}, + Validate: func(rt *shortcut.RuntimeContext) error { + if err := validateSheetNode(rt); err != nil { + return err + } + for _, name := range []string{"sheet-id", "range"} { + if err := validateOptionalSheetString(rt, name); err != nil { + return err + } + } + return nil + }, Execute: func(rt *shortcut.RuntimeContext) error { params := map[string]any{"nodeId": rt.Str("node")} if rt.Changed("sheet-id") { @@ -206,10 +235,204 @@ var Read = shortcut.Shortcut{ if rt.Changed("value-render-option") { params["valueRenderOption"] = rt.Str("value-render-option") } - return rt.CallMCP("get_cell_infos", params) + if rt.DryRun() { + return rt.CallMCP("get_cell_infos", params) + } + data, err := rt.CallMCPData("sheet", "get_cell_infos", params) + if err != nil { + return err + } + projected, err := projectSheetRead(data) + if err != nil { + return err + } + return rt.Output(projected) }, } +func validateSheetNode(rt *shortcut.RuntimeContext) error { + if strings.TrimSpace(rt.Str("node")) == "" { + return apperrors.NewValidation("--node 去除空白后不能为空") + } + return nil +} + +func validateOptionalSheetString(rt *shortcut.RuntimeContext, name string) error { + if rt.Changed(name) && strings.TrimSpace(rt.Str(name)) == "" { + return apperrors.NewValidation(fmt.Sprintf("--%s 显式传入时去除空白后不能为空", name)) + } + return nil +} + +func requireSheetString(item map[string]any, index int, key string) (string, error) { + value, present := item[key] + if !present { + return "", responsecheck.Error("sheet/get_all_sheets", "missing_sheet_identity", fmt.Sprintf("响应 sheets[%d] 缺少 %s", index, key)) + } + text, ok := value.(string) + if !ok || strings.TrimSpace(text) == "" { + return "", responsecheck.Error("sheet/get_all_sheets", "malformed_sheet_identity", fmt.Sprintf("响应 sheets[%d].%s 必须是非空字符串", index, key)) + } + return strings.TrimSpace(text), nil +} + +func requireSheetStringAliases(item map[string]any, index int, keys ...string) (string, error) { + for _, key := range keys { + if _, present := item[key]; present { + return requireSheetString(item, index, key) + } + } + return "", responsecheck.Error("sheet/get_all_sheets", "missing_sheet_title", fmt.Sprintf("响应 sheets[%d] 缺少工作表标题", index)) +} + +func copySheetOptionalNumber(target, source map[string]any, itemIndex int, targetKey string, sourceKeys ...string) error { + for _, key := range sourceKeys { + if value, present := source[key]; present { + number, ok := value.(float64) + if !ok || number < 0 || math.Trunc(number) != number { + return responsecheck.Error("sheet/get_all_sheets", "malformed_sheet_field", fmt.Sprintf("响应 sheets[%d].%s 必须是非负整数", itemIndex, key)) + } + target[targetKey] = value + return nil + } + } + return nil +} + +func copySheetOptionalVisibility(target, source map[string]any, itemIndex int, targetKey string, sourceKeys ...string) error { + for _, key := range sourceKeys { + value, present := source[key] + if !present { + continue + } + switch typed := value.(type) { + case bool: + target[targetKey] = typed + case string: + if strings.TrimSpace(typed) == "" { + return responsecheck.Error("sheet/get_all_sheets", "malformed_sheet_field", fmt.Sprintf("响应 sheets[%d].%s 不能为空字符串", itemIndex, key)) + } + target[targetKey] = strings.TrimSpace(typed) + default: + return responsecheck.Error("sheet/get_all_sheets", "malformed_sheet_field", fmt.Sprintf("响应 sheets[%d].%s 必须是布尔值或字符串", itemIndex, key)) + } + return nil + } + return nil +} + +func projectSheetRead(data map[string]any) (map[string]any, error) { + envelope, err := responsecheck.RequireSuccess(data, "sheet/get_cell_infos") + if err != nil { + return nil, err + } + cellsRaw, err := requireSheetArray(envelope, "cells") + if err != nil { + return nil, err + } + columnsRaw, err := requireSheetArray(envelope, "colIndices") + if err != nil { + return nil, err + } + rowsRaw, err := requireSheetArray(envelope, "rowIndices") + if err != nil { + return nil, err + } + columns := make([]any, len(columnsRaw)) + for index, value := range columnsRaw { + column, ok := value.(string) + if !ok || strings.TrimSpace(column) == "" { + return nil, responsecheck.Error("sheet/get_cell_infos", "malformed_column_index", fmt.Sprintf("响应 colIndices[%d] 必须是非空字符串", index)) + } + columns[index] = strings.TrimSpace(column) + } + rows := make([]any, len(rowsRaw)) + for index, value := range rowsRaw { + number, ok := value.(float64) + if !ok || number < 1 || math.Trunc(number) != number { + return nil, responsecheck.Error("sheet/get_cell_infos", "malformed_row_index", fmt.Sprintf("响应 rowIndices[%d] 必须是正整数", index)) + } + rows[index] = int(number) + } + if len(cellsRaw) != len(rows) { + return nil, responsecheck.Error("sheet/get_cell_infos", "row_count_mismatch", "响应 cells 行数与 rowIndices 数量不一致") + } + cells := make([]any, len(cellsRaw)) + for rowIndex, rowValue := range cellsRaw { + row, ok := rowValue.([]any) + if !ok { + return nil, responsecheck.Error("sheet/get_cell_infos", "malformed_cell_row", fmt.Sprintf("响应 cells[%d] 必须是数组", rowIndex)) + } + if len(row) != len(columns) { + return nil, responsecheck.Error("sheet/get_cell_infos", "column_count_mismatch", fmt.Sprintf("响应 cells[%d] 列数与 colIndices 数量不一致", rowIndex)) + } + clean := make([]any, len(row)) + for columnIndex, cellValue := range row { + cell, ok := cellValue.(map[string]any) + if !ok { + return nil, responsecheck.Error("sheet/get_cell_infos", "malformed_cell", fmt.Sprintf("响应 cells[%d][%d] 必须是对象", rowIndex, columnIndex)) + } + clean[columnIndex] = cell + } + cells[rowIndex] = clean + } + hasMoreValue, present := envelope["hasMore"] + if !present { + return nil, responsecheck.Error("sheet/get_cell_infos", "missing_completion_evidence", "响应缺少 hasMore,无法证明读取完整") + } + hasMore, ok := hasMoreValue.(bool) + if !ok { + return nil, responsecheck.Error("sheet/get_cell_infos", "malformed_completion_evidence", "响应 hasMore 必须是布尔值") + } + reasonsRaw, err := requireSheetArray(envelope, "truncationReasons") + if err != nil { + return nil, err + } + reasons := make([]any, len(reasonsRaw)) + for index, value := range reasonsRaw { + reason, ok := value.(string) + if !ok || strings.TrimSpace(reason) == "" { + return nil, responsecheck.Error("sheet/get_cell_infos", "malformed_truncation_reason", fmt.Sprintf("响应 truncationReasons[%d] 必须是非空字符串", index)) + } + reasons[index] = strings.TrimSpace(reason) + } + if hasMore { + if len(reasons) == 0 { + return nil, responsecheck.Error("sheet/get_cell_infos", "missing_truncation_reason", "响应 hasMore=true 但缺少截断原因") + } + return nil, responsecheck.Error("sheet/get_cell_infos", "incomplete_result", "工作表读取结果被服务端截断;请缩小 --range 后重试,不能把部分数据当作完整成功") + } + if len(reasons) != 0 { + return nil, responsecheck.Error("sheet/get_cell_infos", "conflicting_completion_evidence", "响应 hasMore=false 但仍声明截断原因") + } + out := map[string]any{ + "cells": cells, "colIndices": columns, "rowIndices": rows, + "complete": true, "hasMore": false, "truncationReasons": reasons, + } + for _, key := range []string{"resolvedRange", "returnedRange", "message"} { + if value, present := envelope[key]; present { + text, ok := value.(string) + if !ok { + return nil, responsecheck.Error("sheet/get_cell_infos", "malformed_optional_field", fmt.Sprintf("响应 %s 必须是字符串", key)) + } + out[key] = text + } + } + return out, nil +} + +func requireSheetArray(object map[string]any, key string) ([]any, error) { + value, present := object[key] + if !present { + return nil, responsecheck.Error("sheet/get_cell_infos", "missing_"+key, fmt.Sprintf("响应缺少 %s 数组", key)) + } + array, ok := value.([]any) + if !ok { + return nil, responsecheck.Error("sheet/get_cell_infos", "malformed_"+key, fmt.Sprintf("响应 %s 必须是数组", key)) + } + return array, nil +} + // Write updates a worksheet range with a 2D JSON array of cell objects. // Append appends rows to the end of a worksheet. // CSVGet reads worksheet data as RFC 4180 CSV. diff --git a/internal/shortcut/sheet/sheet_test.go b/internal/shortcut/sheet/sheet_test.go new file mode 100644 index 00000000..98c92ef1 --- /dev/null +++ b/internal/shortcut/sheet/sheet_test.go @@ -0,0 +1,342 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package sheet + +import ( + "context" + "errors" + "io" + "reflect" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/spf13/cobra" +) + +type sheetCoverageCaller struct { + responses map[string][]string + failures map[string]error + history []string + arguments []map[string]any +} + +func (caller *sheetCoverageCaller) CallTool(_ context.Context, _, tool string, arguments map[string]any) (*edition.ToolResult, error) { + caller.history = append(caller.history, tool) + caller.arguments = append(caller.arguments, arguments) + if err := caller.failures[tool]; err != nil { + return nil, err + } + queue := caller.responses[tool] + if len(queue) == 0 { + return nil, errors.New("missing fake response for " + tool) + } + caller.responses[tool] = queue[1:] + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: queue[0]}}}, nil +} + +func (*sheetCoverageCaller) Format() string { return "json" } +func (*sheetCoverageCaller) DryRun() bool { return false } +func (*sheetCoverageCaller) Fields() string { return "" } +func (*sheetCoverageCaller) JQ() string { return "" } + +func runSheetCoverage(t *testing.T, declaration shortcut.Shortcut, caller *sheetCoverageCaller, args ...string) error { + t.Helper() + helpers.InitDepsForTest(t, caller) + root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true} + root.PersistentFlags().Bool("yes", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().String("format", "json", "") + ctx, _ := output.WithResultStore(context.Background()) + root.SetContext(ctx) + service := &cobra.Command{Use: "sheet"} + service.AddCommand(corecmd.New(shortcut.FromShortcut(declaration))) + root.AddCommand(service) + root.SetOut(io.Discard) + root.SetErr(io.Discard) + root.SetArgs(append([]string{"sheet", declaration.Command}, args...)) + return root.Execute() +} + +func TestCrossPlatformCoverageSheetListRejectsFalseEmptySuccess(t *testing.T) { + for name, payload := range map[string]map[string]any{ + "empty response": {}, + "missing success": {"sheets": []any{}}, + "success false": {"success": false, "errorMessage": "denied"}, + "missing collection": {"success": true}, + "wrong collection type": {"success": true, "sheets": map[string]any{}}, + "bad element": {"success": true, "sheets": []any{"bad"}}, + "empty element": {"success": true, "sheets": []any{map[string]any{}}}, + "missing stable id": {"success": true, "sheets": []any{map[string]any{"name": "Sheet1"}}}, + "wrong stable id type": {"success": true, "sheets": []any{map[string]any{"sheetId": 1, "name": "Sheet1"}}}, + "missing title": {"success": true, "sheets": []any{map[string]any{"sheetId": "s1"}}}, + "duplicate stable id": {"success": true, "sheets": []any{map[string]any{"sheetId": "s1", "name": "One"}, map[string]any{"sheetId": "s1", "name": "Two"}}}, + "wrong optional index": {"success": true, "sheets": []any{map[string]any{"sheetId": "s1", "name": "One", "sheetIndex": -1.0}}}, + "wrong optional count": {"success": true, "sheets": []any{map[string]any{"sheetId": "s1", "name": "One", "rowCount": "many"}}}, + "wrong column count": {"success": true, "sheets": []any{map[string]any{"sheetId": "s1", "name": "One", "colCount": 1.5}}}, + "empty visibility": {"success": true, "sheets": []any{map[string]any{"sheetId": "s1", "name": "One", "visibility": " "}}}, + "wrong visibility": {"success": true, "sheets": []any{map[string]any{"sheetId": "s1", "name": "One", "hidden": 1}}}, + } { + t.Run(name, func(t *testing.T) { + if _, err := projectSheetList(payload, ""); err == nil { + t.Fatalf("payload unexpectedly accepted: %#v", payload) + } + }) + } + + payload := map[string]any{"success": true, "sheets": []any{ + map[string]any{"sheetId": "s1", "name": "Alpha"}, + map[string]any{"sheetId": "s2", "name": "Beta"}, + }} + nonempty, err := projectSheetList(payload, "Alpha") + if err != nil || len(nonempty) != 1 || nonempty[0]["sheetId"] != "s1" { + t.Fatalf("known nonempty projection = %#v, err=%v", nonempty, err) + } + empty, err := projectSheetList(payload, "guaranteed-missing") + if err != nil || len(empty) != 0 { + t.Fatalf("guaranteed empty projection = %#v, err=%v", empty, err) + } + optionalPayload := map[string]any{"success": true, "sheets": []any{ + map[string]any{ + "sheetId": "s1", "title": "Alpha", "index": float64(0), + "hidden": true, "row_count": float64(5), "colCount": float64(2), + }, + map[string]any{"sheetId": "s2", "title": "Beta", "visibility": " visible "}, + }} + optional, err := projectSheetList(optionalPayload, "") + if err != nil || len(optional) != 2 { + t.Fatalf("optional field projection=%#v err=%v", optional, err) + } + if optional[0]["index"] != float64(0) || optional[0]["visibility"] != true || optional[0]["rowCount"] != float64(5) || optional[0]["columnCount"] != float64(2) { + t.Fatalf("numeric/bool optional projection=%#v", optional[0]) + } + if optional[1]["visibility"] != "visible" { + t.Fatalf("string visibility projection=%#v", optional[1]) + } +} + +func TestCrossPlatformCoverageSheetReadRejectsMalformedAndIncompleteResults(t *testing.T) { + valid := func() map[string]any { + return map[string]any{ + "success": true, "cells": []any{[]any{map[string]any{"value": "x"}}}, + "colIndices": []any{"A"}, "rowIndices": []any{float64(1)}, + "hasMore": false, "truncationReasons": []any{}, + "resolvedRange": "A1:A1", "returnedRange": "A1:A1", + } + } + for name, mutate := range map[string]func(map[string]any){ + "missing success": func(v map[string]any) { delete(v, "success") }, + "success false": func(v map[string]any) { v["success"] = false }, + "missing cells": func(v map[string]any) { delete(v, "cells") }, + "wrong cells type": func(v map[string]any) { v["cells"] = map[string]any{} }, + "bad row": func(v map[string]any) { v["cells"] = []any{"bad"} }, + "bad cell": func(v map[string]any) { v["cells"] = []any{[]any{"bad"}} }, + "missing columns": func(v map[string]any) { delete(v, "colIndices") }, + "bad column": func(v map[string]any) { v["colIndices"] = []any{1} }, + "missing rows": func(v map[string]any) { delete(v, "rowIndices") }, + "bad row index": func(v map[string]any) { v["rowIndices"] = []any{1.5} }, + "row count mismatch": func(v map[string]any) { v["rowIndices"] = []any{} }, + "column count mismatch": func(v map[string]any) { v["cells"] = []any{[]any{}} }, + "missing hasMore": func(v map[string]any) { delete(v, "hasMore") }, + "wrong hasMore type": func(v map[string]any) { v["hasMore"] = "false" }, + "truncated": func(v map[string]any) { + v["hasMore"] = true + v["truncationReasons"] = []any{"max_cells"} + }, + "truncated without reason": func(v map[string]any) { + v["hasMore"] = true + v["truncationReasons"] = []any{} + }, + "missing truncation reasons": func(v map[string]any) { delete(v, "truncationReasons") }, + "wrong truncation reason": func(v map[string]any) { v["truncationReasons"] = []any{1} }, + "contradictory completion": func(v map[string]any) { v["truncationReasons"] = []any{"max_cells"} }, + "wrong optional field": func(v map[string]any) { v["message"] = true }, + } { + t.Run(name, func(t *testing.T) { + payload := valid() + mutate(payload) + if _, err := projectSheetRead(payload); err == nil { + t.Fatalf("payload unexpectedly accepted: %#v", payload) + } + }) + } + + projected, err := projectSheetRead(valid()) + if err != nil || projected["complete"] != true || projected["hasMore"] != false { + t.Fatalf("valid projection=%#v err=%v", projected, err) + } +} + +func TestCrossPlatformCoverageSheetShortcutsValidateBeforeCallsAndUseExactTools(t *testing.T) { + noCall := &sheetCoverageCaller{responses: map[string][]string{}} + if err := runSheetCoverage(t, ListSheets, noCall); err == nil || len(noCall.history) != 0 { + t.Fatalf("missing node err=%v calls=%v", err, noCall.history) + } + for _, declaration := range []shortcut.Shortcut{ListSheets, Read} { + t.Run(declaration.Command+" blank node", func(t *testing.T) { + blank := &sheetCoverageCaller{responses: map[string][]string{}} + err := runSheetCoverage(t, declaration, blank, "--node", " ") + if err == nil || !strings.Contains(err.Error(), "--node") || len(blank.history) != 0 { + t.Fatalf("blank node err=%v calls=%v", err, blank.history) + } + }) + } + for _, declaration := range []shortcut.Shortcut{ListSheets, Read} { + blankCommand := corecmd.New(shortcut.FromShortcut(declaration)) + if err := blankCommand.Flags().Set("node", " "); err != nil { + t.Fatal(err) + } + err := declaration.Validate(shortcut.RuntimeContextForTest(blankCommand, declaration)) + if err == nil || !strings.Contains(err.Error(), "--node") { + t.Fatalf("%s direct blank node validation=%v", declaration.Command, err) + } + } + for _, test := range []struct { + declaration shortcut.Shortcut + flag string + }{ + {declaration: ListSheets, flag: "title"}, + {declaration: Read, flag: "sheet-id"}, + {declaration: Read, flag: "range"}, + } { + t.Run(test.declaration.Command+" blank "+test.flag, func(t *testing.T) { + caller := &sheetCoverageCaller{responses: map[string][]string{}} + err := runSheetCoverage(t, test.declaration, caller, "--node", "node", "--"+test.flag, " ") + if err == nil || !strings.Contains(err.Error(), "--"+test.flag) || len(caller.history) != 0 { + t.Fatalf("blank --%s err=%v calls=%v", test.flag, err, caller.history) + } + }) + } + + listCaller := &sheetCoverageCaller{responses: map[string][]string{ + "get_all_sheets": {`{"success":true,"sheets":[{"sheetId":"s1","name":"Alpha"}]}`}, + }} + if err := runSheetCoverage(t, ListSheets, listCaller, "--node", "node", "--title", "Alpha"); err != nil { + t.Fatal(err) + } + if got := strings.Join(listCaller.history, ","); got != "get_all_sheets" { + t.Fatalf("list history=%q", got) + } + + readCaller := &sheetCoverageCaller{responses: map[string][]string{ + "get_cell_infos": {`{"success":true,"cells":[[{"value":"x"}]],"colIndices":["A"],"rowIndices":[1],"hasMore":false,"truncationReasons":[]}`}, + }} + if err := runSheetCoverage(t, Read, readCaller, + "--node", "node", "--sheet-id", "sheet-1", "--range", "A1:A1", "--value-render-option", "raw_value"); err != nil { + t.Fatal(err) + } + if got := strings.Join(readCaller.history, ","); got != "get_cell_infos" { + t.Fatalf("read history=%q", got) + } + if len(readCaller.arguments) != 1 || readCaller.arguments[0]["nodeId"] != "node" || readCaller.arguments[0]["sheetId"] != "sheet-1" || readCaller.arguments[0]["range"] != "A1:A1" || readCaller.arguments[0]["valueRenderOption"] != "raw_value" { + t.Fatalf("read arguments=%#v", readCaller.arguments) + } + + dryRun := &sheetCoverageCaller{responses: map[string][]string{}} + if err := runSheetCoverage(t, Read, dryRun, + "--node", "node", "--sheet-id", "sheet-1", "--range", "A1:A1", "--value-render-option", "raw_value", "--dry-run"); err != nil { + t.Fatalf("read dry-run: %v", err) + } + if len(dryRun.history) != 0 { + t.Fatalf("read dry-run reached remote calls=%v", dryRun.history) + } + + readFailure := &sheetCoverageCaller{ + responses: map[string][]string{}, + failures: map[string]error{"get_cell_infos": errors.New("fixture transport failure")}, + } + if err := runSheetCoverage(t, Read, readFailure, "--node", "node"); err == nil || len(readFailure.history) != 1 { + t.Fatalf("read transport err=%v calls=%v", err, readFailure.history) + } + + incomplete := &sheetCoverageCaller{responses: map[string][]string{ + "get_cell_infos": {`{"success":true,"cells":[[{"value":"x"}]],"colIndices":["A"],"rowIndices":[1],"hasMore":true,"truncationReasons":["max_cells"],"resolvedRange":"A1:A2","returnedRange":"A1:A1"}`}, + }} + err := runSheetCoverage(t, Read, incomplete, "--node", "node") + if err == nil || !strings.Contains(err.Error(), "截断") || len(incomplete.history) != 1 { + t.Fatalf("incomplete err=%v calls=%v", err, incomplete.history) + } +} + +func TestCrossPlatformCoverageSheetRuntimeRejectsEmptyMalformedAndWrongCollection(t *testing.T) { + for name, payload := range map[string]string{ + "empty body": "", + "malformed json": "{", + "success string": `{"success":"true","sheets":[]}`, + "missing array": `{"success":true}`, + "wrong array type": `{"success":true,"sheets":{}}`, + "bad item": `{"success":true,"sheets":["bad"]}`, + } { + t.Run(name, func(t *testing.T) { + caller := &sheetCoverageCaller{responses: map[string][]string{"get_all_sheets": {payload}}} + err := runSheetCoverage(t, ListSheets, caller, "--node", "node") + if err == nil || len(caller.history) != 1 { + t.Fatalf("payload=%q err=%v calls=%v", payload, err, caller.history) + } + }) + } +} + +func TestCrossPlatformCoverageSheetPublicContractsAreReviewedAndUnified(t *testing.T) { + for _, declaration := range []shortcut.Shortcut{ListSheets, Read} { + if declaration.Contract.Empty() || declaration.Contract.Result == nil { + t.Errorf("%s missing Contract.Result", declaration.Command) + } + if declaration.Contract.Pagination != nil { + t.Errorf("%s publishes unsupported pagination", declaration.Command) + } + if declaration.OutputRollout != output.RolloutUnifiedActive { + t.Errorf("%s rollout=%q", declaration.Command, declaration.OutputRollout) + } + if declaration.Safety.Confirmation != "not_required" || declaration.Safety.Effect != "read" { + t.Errorf("%s safety=%+v", declaration.Command, declaration.Safety) + } + } + if Read.Contract.DryRun == nil || Read.Contract.DryRun.PreviewKind != contract.DryRunPreviewRequest || Read.Contract.DryRun.RemoteReads { + t.Errorf("read dry-run contract=%+v", Read.Contract.DryRun) + } + for _, test := range []struct { + declaration shortcut.Shortcut + wantProperties map[string]string + }{ + {declaration: ListSheets, wantProperties: map[string]string{"node": "node", "title": "title"}}, + {declaration: Read, wantProperties: map[string]string{"node": "node", "sheet-id": "sheetId", "range": "range", "value-render-option": "valueRenderOption"}}, + } { + got := make(map[string]string, len(test.declaration.Contract.Parameters)) + for _, parameter := range test.declaration.Contract.Parameters { + got[parameter.Name] = parameter.Property + } + if !reflect.DeepEqual(got, test.wantProperties) { + t.Errorf("%s parameter properties=%v, want %v", test.declaration.Command, got, test.wantProperties) + } + } + for _, test := range []struct { + declaration shortcut.Shortcut + sensitive []string + constraints []string + }{ + {declaration: ListSheets, sensitive: []string{"sheets.sheetId", "sheets.title"}, constraints: []string{"node", "title"}}, + {declaration: Read, sensitive: []string{"cells"}, constraints: []string{"node", "sheet-id", "range"}}, + } { + if !reflect.DeepEqual(test.declaration.Contract.Result.SensitivePaths, test.sensitive) { + t.Errorf("%s sensitive paths=%v, want %v", test.declaration.Command, test.declaration.Contract.Result.SensitivePaths, test.sensitive) + } + gotConstraints := make([]string, 0, len(test.declaration.Constraints)) + for _, constraint := range test.declaration.Constraints { + if constraint.Kind != shortcut.ConstraintCustom || len(constraint.Flags) != 1 || !strings.Contains(constraint.Description, "--"+constraint.Flags[0]) { + t.Errorf("%s malformed custom constraint=%+v", test.declaration.Command, constraint) + continue + } + gotConstraints = append(gotConstraints, constraint.Flags[0]) + } + if !reflect.DeepEqual(gotConstraints, test.constraints) { + t.Errorf("%s custom constraint flags=%v, want %v", test.declaration.Command, gotConstraints, test.constraints) + } + } +} diff --git a/internal/shortcut/whiteboard/whiteboard.go b/internal/shortcut/whiteboard/whiteboard.go new file mode 100644 index 00000000..f3490ecb --- /dev/null +++ b/internal/shortcut/whiteboard/whiteboard.go @@ -0,0 +1,837 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +// Package whiteboard declares strict Shortcut adapters for DingTalk document +// whiteboards. The document card remains owned by doc; this package only reads +// and updates the OpenNodes content of an already identified whiteboard part. +package whiteboard + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "math" + "math/big" + "strconv" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/responsecheck" +) + +const ( + serverWhiteboard = "whiteboard" + toolQuery = "read_whiteboard_content" + toolUpdate = "update_whiteboard" +) + +var whiteboardMarshalNodes = json.Marshal + +type updateFile struct { + Overwrite bool `json:"overwrite"` + Source *openSource `json:"source"` +} + +type openSource struct { + SchemaVersion string `json:"schemaVersion"` + CatalogVersion string `json:"catalogVersion"` + Nodes json.RawMessage `json:"nodes"` +} + +type parsedUpdate struct { + Overwrite bool + NodesJSON string + Nodes []map[string]any +} + +func whiteboardReadSafety() contract.SafetySpec { + return contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + } +} + +func whiteboardWriteSafety() contract.SafetySpec { + return contract.SafetySpec{ + Effect: "write", Risk: "high", + Confirmation: "user_required", Idempotency: "unknown", + } +} + +func queryResultSpec() *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{ + "type":"object", + "description":"严格校验且绑定到同一文档与白板 part 的 OpenNodes 快照", + "properties":{ + "nodeId":{"type":"string","description":"承载白板的稳定文档身份"}, + "partId":{"type":"string","description":"文档内白板的稳定 part 身份"}, + "source":{"type":"object","description":"显式 OpenNodes V1 快照,包含 pages 以及每页的 nodes 数组","additionalProperties":true}, + "summary":{"type":"object","description":"服务端完整性计数、字节数与摘要证据","additionalProperties":true}, + "message":{"type":"string","description":"可选服务说明"} + }, + "required":["nodeId","partId","source","summary"], + "additionalProperties":false + }`), + SensitivePaths: []string{"nodeId", "partId", "source.pages.nodes.id"}, + } +} + +func updateResultSpec() *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{ + "type":"object", + "description":"白板更新回执与同一稳定目标的精确读回验证", + "properties":{ + "nodeId":{"type":"string","description":"承载白板的稳定文档身份"}, + "partId":{"type":"string","description":"文档内白板的稳定 part 身份"}, + "mode":{"type":"string","description":"实际执行的 append 或 overwrite 模式"}, + "verified":{"type":"boolean","description":"远端更新成功并完成独立读回时为 true;dry-run 请求预览为 false"}, + "verifiedNodeCount":{"type":"integer","description":"按请求稳定节点身份读回验证的节点数"}, + "source":{"type":"object","description":"更新后的严格 OpenNodes V1 快照","additionalProperties":true}, + "summary":{"type":"object","description":"更新后快照的完整性计数与摘要证据","additionalProperties":true}, + "receipt":{"type":"object","description":"显式 success=true 的下游写回执;最终成功仍以独立读回为准","additionalProperties":true} + }, + "required":["nodeId","partId","mode","verified","verifiedNodeCount","source","summary","receipt"], + "additionalProperties":false + }`), + SensitivePaths: []string{"nodeId", "partId", "source.pages.nodes.id", "receipt.resultJson.createdNodeIds", "receipt.resultJson.idMap"}, + } +} + +func whiteboardContract(command, name, description, interfaceReason string, result *contract.ResultSpec, dryRun *contract.DryRunSpec, params []contract.ParamDecl, useWhen, avoidWhen, example string) corecmd.ContractDecl { + path := "whiteboard " + command + return corecmd.ContractDecl{ + Identity: contract.ToolIdentitySpec{ + ProductID: "whiteboard", Name: name, CanonicalPath: "whiteboard." + name, + CLIPath: path, PrimaryCLIPath: path, + }, + Description: description, + Interface: &contract.InterfaceSpec{ + Mode: contract.InterfaceModeComposite, + Availability: contract.InterfaceAvailable, + Reason: interfaceReason, + }, + Selection: contract.SelectionSpec{ + AgentSummary: description, + UseWhen: []string{useWhen}, + AvoidWhen: []string{avoidWhen}, + Examples: []string{example}, + }, + Parameters: params, + Result: result, + DryRun: dryRun, + } +} + +// Query reads one already identified DingTalk document whiteboard. +var Query = shortcut.Shortcut{ + OutputRollout: output.RolloutUnifiedActive, + Service: "whiteboard", + Command: "+query", + Product: serverWhiteboard, + Description: "严格读取已有文档白板的 OpenNodes 快照", + Intent: "已知文档 nodeId 与白板 partId,需要读取节点、页面和服务端完整性摘要,并拒绝未知或畸形结果时", + Risk: shortcut.RiskRead, + Safety: whiteboardReadSafety(), + Contract: whiteboardContract( + "+query", "shortcut_query", "严格读取已有文档白板的 OpenNodes 快照", + "Reviewed composite adapter validates the whiteboard success envelope, every explicit pages[].nodes collection, stable page/node identities, and summary completeness before unified output.", + queryResultSpec(), nil, + []contract.ParamDecl{ + {Name: "node", Property: "nodeId"}, + {Name: "part-id", Property: "partId"}, + }, + "已知文档 nodeId 与白板 partId,需要读取节点、页面和服务端完整性摘要,并拒绝未知或畸形结果时", + "创建白板卡片路由到 doc whiteboard insert;Lark 风格 preview/SVG/source 导出当前不可由本命令替代", + "dws whiteboard +query --node --part-id ", + ), + Flags: []shortcut.Flag{ + {Name: "node", Type: shortcut.FlagString, Desc: "承载白板的钉钉文档 ID 或 URL;--node 去除空白后不能为空", Required: true}, + {Name: "part-id", Type: shortcut.FlagString, Desc: "文档内白板 part ID;--part-id 去除空白后不能为空", Required: true}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"node"}, Description: "--node 去除空白后不能为空"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"part-id"}, Description: "--part-id 去除空白后不能为空"}, + }, + Tips: []string{"dws whiteboard +query --node --part-id "}, + Validate: func(rt *shortcut.RuntimeContext) error { + return validateWhiteboardTarget(rt) + }, + Execute: func(rt *shortcut.RuntimeContext) error { + data, err := rt.CallMCPData(serverWhiteboard, toolQuery, whiteboardTarget(rt)) + if err != nil { + return err + } + projected, err := projectWhiteboardQuery(data, rt.Str("node"), rt.Str("part-id")) + if err != nil { + return err + } + return rt.Output(projected) + }, +} + +// Update appends or overwrites verified OpenNodes on one stable whiteboard. +var Update = shortcut.Shortcut{ + OutputRollout: output.RolloutUnifiedActive, + Service: "whiteboard", + Command: "+update", + Product: serverWhiteboard, + Description: "确认后更新白板并按同一稳定目标精确读回", + Intent: "已有合规 OpenNodes V1 内容,用户确认后要 append 或 overwrite,并要求按请求节点身份验证同一白板读回时", + Risk: shortcut.RiskHighWrite, + Safety: whiteboardWriteSafety(), + Contract: whiteboardContract( + "+update", "shortcut_update", "确认后更新白板并按同一稳定目标精确读回", + "Reviewed composite adapter validates OpenNodes locally, requires confirmation, verifies the terminal receipt and request-to-real ID mapping, then reads the same target back exactly.", + updateResultSpec(), &contract.DryRunSpec{PreviewKind: contract.DryRunPreviewRequest, RemoteReads: false}, + []contract.ParamDecl{ + {Name: "node", Property: "nodeId"}, + {Name: "part-id", Property: "partId"}, + {Name: "source", Property: "source"}, + }, + "已有合规 OpenNodes V1 内容,用户确认后要 append 或 overwrite,并要求按请求节点身份验证同一白板读回时", + "只读使用 whiteboard +query;创建卡片使用 doc whiteboard insert;Mermaid、PlantUML、SVG 和真实节点局部更新当前不可用", + `dws whiteboard +update --node --part-id --source '{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[{"id":"sample-shape","type":"shape","x":40,"y":40,"width":120,"height":80,"geometry":"dml:roundRect"}]}}'`, + ), + Flags: []shortcut.Flag{ + {Name: "node", Type: shortcut.FlagString, Desc: "承载白板的钉钉文档 ID 或 URL;--node 去除空白后不能为空", Required: true}, + {Name: "part-id", Type: shortcut.FlagString, Desc: "文档内白板 part ID;--part-id 去除空白后不能为空", Required: true}, + {Name: "source", Type: shortcut.FlagString, Desc: "OpenNodes V1 JSON,不能为空;支持字面量、@相对文件或 - 从 stdin 读取", Required: true, Input: []string{"file", "stdin"}}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"node"}, Description: "--node 去除空白后不能为空"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"part-id"}, Description: "--part-id 去除空白后不能为空"}, + { + Kind: shortcut.ConstraintCustom, + Flags: []string{"source"}, + Description: "--source 不能为空且必须是单一 OpenNodes V1 对象;source.nodes 是含稳定唯一 id 和非空 type 的显式数组,append 模式至少一个节点", + }, + }, + Tips: []string{"dws whiteboard +update --node --part-id --source @whiteboard.json"}, + Validate: func(rt *shortcut.RuntimeContext) error { + if err := validateWhiteboardTarget(rt); err != nil { + return err + } + _, err := parseWhiteboardSource(rt.Str("source")) + return err + }, + Execute: func(rt *shortcut.RuntimeContext) error { + parsed, err := parseWhiteboardSource(rt.Str("source")) + if err != nil { + return err + } + mode := "append" + if parsed.Overwrite { + mode = "overwrite" + } + target := whiteboardTarget(rt) + request := map[string]any{ + "nodeId": target["nodeId"], "partId": target["partId"], + "mode": mode, "nodes": parsed.NodesJSON, + } + if rt.DryRun() { + return rt.Output(map[string]any{ + "nodeId": target["nodeId"], "partId": target["partId"], "mode": mode, + "verified": false, "verifiedNodeCount": 0, + "source": map[string]any{"schemaVersion": "1.0", "catalogVersion": "dml-v1", "nodes": parsed.Nodes, "pages": []any{}}, + "summary": map[string]any{"preview": true}, "receipt": map[string]any{"dryRun": true, "executed": false}, + }) + } + + receiptData, err := rt.CallMCPWriteDataStrict(serverWhiteboard, toolUpdate, request) + if err != nil { + return err + } + receipt, err := requireWhiteboardUpdateReceipt(receiptData, target, mode, parsed) + if err != nil { + return err + } + readback, err := rt.CallMCPData(serverWhiteboard, toolQuery, target) + if err != nil { + return err + } + projected, err := projectWhiteboardQuery(readback, rt.Str("node"), rt.Str("part-id")) + if err != nil { + return err + } + if err := verifyWhiteboardUpdate(parsed, projected, receipt.IDMap); err != nil { + return err + } + return rt.Output(map[string]any{ + "nodeId": target["nodeId"], "partId": target["partId"], "mode": mode, + "verified": true, "verifiedNodeCount": len(parsed.Nodes), + "source": projected["source"], "summary": projected["summary"], "receipt": receipt.Envelope, + }) + }, +} + +func validateWhiteboardTarget(rt *shortcut.RuntimeContext) error { + if strings.TrimSpace(rt.Str("node")) == "" { + return apperrors.NewValidation("--node 去除空白后不能为空") + } + if strings.TrimSpace(rt.Str("part-id")) == "" { + return apperrors.NewValidation("--part-id 去除空白后不能为空") + } + return nil +} + +func whiteboardTarget(rt *shortcut.RuntimeContext) map[string]any { + return map[string]any{"nodeId": rt.Str("node"), "partId": rt.Str("part-id")} +} + +func parseWhiteboardSource(raw string) (*parsedUpdate, error) { + if strings.TrimSpace(raw) == "" { + return nil, apperrors.NewValidation("--source 必须是非空 OpenNodes V1 JSON") + } + decoder := json.NewDecoder(strings.NewReader(raw)) + decoder.DisallowUnknownFields() + decoder.UseNumber() + var input updateFile + if err := decoder.Decode(&input); err != nil { + return nil, apperrors.NewValidation(fmt.Sprintf("--source 不是合法 OpenNodes V1 JSON: %v", err)) + } + if err := requireJSONEOF(decoder); err != nil { + return nil, apperrors.NewValidation(fmt.Sprintf("--source 不是单一 JSON 对象: %v", err)) + } + if input.Source == nil { + return nil, apperrors.NewValidation("--source 缺少 source 对象") + } + if input.Source.SchemaVersion != "1.0" { + return nil, apperrors.NewValidation(`--source source.schemaVersion 必须为 "1.0"`) + } + if input.Source.CatalogVersion != "dml-v1" { + return nil, apperrors.NewValidation(`--source source.catalogVersion 必须为 "dml-v1"`) + } + nodes, err := decodeNodeArray(input.Source.Nodes, "--source source.nodes") + if err != nil { + return nil, err + } + if !input.Overwrite && len(nodes) == 0 { + return nil, apperrors.NewValidation("append 模式至少需要一个 source.nodes 节点") + } + encoded, err := whiteboardMarshalNodes(nodes) + if err != nil { + return nil, apperrors.NewInternal(fmt.Sprintf("编码 OpenNodes 请求失败: %v", err)) + } + return &parsedUpdate{Overwrite: input.Overwrite, NodesJSON: string(encoded), Nodes: nodes}, nil +} + +func decodeNodeArray(raw json.RawMessage, field string) ([]map[string]any, error) { + if len(raw) == 0 { + return nil, apperrors.NewValidation(field + " 必须是显式数组") + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + var values []any + if err := decoder.Decode(&values); err != nil { + return nil, apperrors.NewValidation(fmt.Sprintf("%s 必须是数组: %v", field, err)) + } + if values == nil { + return nil, apperrors.NewValidation(field + " 不能为 null") + } + nodes := make([]map[string]any, len(values)) + seen := make(map[string]struct{}, len(values)) + for index, value := range values { + node, ok := value.(map[string]any) + if !ok || len(node) == 0 { + return nil, apperrors.NewValidation(fmt.Sprintf("%s[%d] 必须是非空对象", field, index)) + } + id, ok := nonEmptyString(node["id"]) + if !ok { + return nil, apperrors.NewValidation(fmt.Sprintf("%s[%d].id 必须是非空稳定身份", field, index)) + } + if _, duplicate := seen[id]; duplicate { + return nil, apperrors.NewValidation(fmt.Sprintf("%s 含重复节点 id %q", field, id)) + } + seen[id] = struct{}{} + if _, ok := nonEmptyString(node["type"]); !ok { + return nil, apperrors.NewValidation(fmt.Sprintf("%s[%d].type 必须是非空字符串", field, index)) + } + nodes[index] = node + } + return nodes, nil +} + +func projectWhiteboardQuery(data map[string]any, nodeID, partID string) (map[string]any, error) { + envelope, err := requireWhiteboardSuccess(data, toolQuery) + if err != nil { + return nil, err + } + value, present := envelope["resultJson"] + if !present || value == nil { + return nil, responsecheck.Error(serverWhiteboard+"/"+toolQuery, "missing_result_json", "成功响应缺少非空 resultJson") + } + result, err := decodeResultJSON(value) + if err != nil { + return nil, err + } + if version, ok := nonEmptyString(result["schemaVersion"]); !ok || version != "1.0" { + return nil, responsecheck.Error(serverWhiteboard+"/"+toolQuery, "invalid_schema_version", `resultJson.schemaVersion 必须为 "1.0"`) + } + if version, ok := nonEmptyString(result["catalogVersion"]); !ok || version != "dml-v1" { + return nil, responsecheck.Error(serverWhiteboard+"/"+toolQuery, "invalid_catalog_version", `resultJson.catalogVersion 必须为 "dml-v1"`) + } + pagesValue, present := result["pages"] + if !present { + return nil, responsecheck.Error(serverWhiteboard+"/"+toolQuery, "missing_pages", "resultJson 缺少显式 pages 数组") + } + pages, nodes, err := validateWhiteboardPages(pagesValue) + if err != nil { + return nil, err + } + summaryValue, present := envelope["resultSummary"] + if !present { + return nil, responsecheck.Error(serverWhiteboard+"/"+toolQuery, "missing_result_summary", "成功响应缺少 resultSummary 完整性证据") + } + summary, ok := summaryValue.(map[string]any) + if !ok || len(summary) == 0 { + return nil, responsecheck.Error(serverWhiteboard+"/"+toolQuery, "malformed_result_summary", "resultSummary 必须是非空对象") + } + if err := validateSummary(summary, len(nodes), len(pages)); err != nil { + return nil, err + } + result["pages"] = mapsToAny(pages) + out := map[string]any{ + "nodeId": strings.TrimSpace(nodeID), "partId": strings.TrimSpace(partID), + "source": result, "summary": summary, + } + if message, present := envelope["message"]; present { + text, ok := message.(string) + if !ok { + return nil, responsecheck.Error(serverWhiteboard+"/"+toolQuery, "malformed_message", "响应 message 必须是字符串") + } + out["message"] = text + } + return out, nil +} + +func requireWhiteboardSuccess(data map[string]any, tool string) (map[string]any, error) { + envelope, err := responsecheck.RequireSuccess(data, serverWhiteboard+"/"+tool) + if err != nil { + return nil, err + } + for _, key := range []string{"error", "errorMessage", "errorMsg"} { + if text, ok := envelope[key].(string); ok && strings.TrimSpace(text) != "" { + return nil, responsecheck.Error(serverWhiteboard+"/"+tool, "conflicting_error", "响应同时声明 success=true 与非空 "+key) + } + } + return envelope, nil +} + +type verifiedUpdateReceipt struct { + Envelope map[string]any + Result map[string]any + IDMap map[string]string +} + +func requireWhiteboardUpdateReceipt(data, target map[string]any, mode string, expected *parsedUpdate) (*verifiedUpdateReceipt, error) { + receipt, err := requireWhiteboardSuccess(data, toolUpdate) + if err != nil { + return nil, err + } + for _, field := range []string{"nodeId", "partId"} { + value, ok := nonEmptyString(receipt[field]) + wanted, _ := target[field].(string) + if !ok || value != strings.TrimSpace(wanted) { + return nil, responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "receipt_target_mismatch", "写回执的 "+field+" 与请求稳定目标不一致") + } + } + value, present := receipt["resultJson"] + if !present || value == nil { + return nil, responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "missing_terminal_receipt", "成功写响应缺少非空 resultJson 终态回执;远端效果未知") + } + result, err := decodeResultJSONForTool(value, toolUpdate) + if err != nil { + return nil, err + } + receiptMode, ok := nonEmptyString(result["mode"]) + if !ok || receiptMode != mode { + return nil, responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "receipt_mode_mismatch", "写回执 mode 与请求不一致") + } + message, ok := nonEmptyString(result["message"]) + if !ok { + return nil, responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "missing_terminal_receipt", "成功写响应缺少非空 resultJson.message 终态回执;远端效果未知") + } + result["message"] = message + created, err := nonEmptyStringArray(result["createdNodeIds"], "resultJson.createdNodeIds") + if err != nil { + return nil, err + } + if len(created) != len(expected.Nodes) { + return nil, responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "receipt_count_mismatch", "createdNodeIds 数量与请求节点数不一致") + } + idMapValue, ok := result["idMap"].(map[string]any) + if !ok { + return nil, responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "malformed_id_map", "resultJson.idMap 必须是显式对象") + } + idMap := make(map[string]string, len(idMapValue)) + for key, raw := range idMapValue { + requestID := strings.TrimSpace(key) + realID, valid := nonEmptyString(raw) + if requestID == "" || !valid { + return nil, responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "malformed_id_map", "resultJson.idMap 含空请求或真实节点身份") + } + idMap[requestID] = realID + } + if len(idMap) != len(expected.Nodes) { + return nil, responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "receipt_count_mismatch", "idMap 数量与请求节点数不一致") + } + for index, node := range expected.Nodes { + requestID, _ := nonEmptyString(node["id"]) + if idMap[requestID] != created[index] { + return nil, responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "receipt_identity_mismatch", "idMap 没有按请求顺序精确映射 createdNodeIds") + } + } + deleted, ok := nonNegativeInt(result["deletedNodeCount"]) + if !ok || (!expected.Overwrite && deleted != 0) { + return nil, responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "malformed_deleted_count", "deletedNodeCount 必须是非负整数,append 时必须为 0") + } + receipt["resultJson"] = result + return &verifiedUpdateReceipt{Envelope: receipt, Result: result, IDMap: idMap}, nil +} + +func decodeResultJSON(value any) (map[string]any, error) { + return decodeResultJSONForTool(value, toolQuery) +} + +func decodeResultJSONForTool(value any, tool string) (map[string]any, error) { + switch typed := value.(type) { + case map[string]any: + if len(typed) == 0 { + return nil, responsecheck.Error(serverWhiteboard+"/"+tool, "empty_result_json", "resultJson 对象为空") + } + return typed, nil + case string: + if strings.TrimSpace(typed) == "" { + return nil, responsecheck.Error(serverWhiteboard+"/"+tool, "empty_result_json", "resultJson 字符串为空") + } + decoder := json.NewDecoder(strings.NewReader(typed)) + decoder.UseNumber() + var result map[string]any + if err := decoder.Decode(&result); err != nil { + return nil, responsecheck.Error(serverWhiteboard+"/"+tool, "invalid_result_json", fmt.Sprintf("resultJson 不是合法 JSON 对象: %v", err)) + } + if err := requireJSONEOF(decoder); err != nil || len(result) == 0 { + return nil, responsecheck.Error(serverWhiteboard+"/"+tool, "invalid_result_json", "resultJson 必须是单一非空 JSON 对象") + } + return result, nil + default: + return nil, responsecheck.Error(serverWhiteboard+"/"+tool, "malformed_result_json", fmt.Sprintf("resultJson 应为对象或 JSON 字符串,实际为 %T", value)) + } +} + +func validateWhiteboardPages(value any) ([]map[string]any, []map[string]any, error) { + raw, ok := value.([]any) + if !ok { + return nil, nil, responsecheck.Error(serverWhiteboard+"/"+toolQuery, "malformed_collection", fmt.Sprintf("resultJson.pages 必须是数组,实际为 %T", value)) + } + if len(raw) == 0 { + return nil, nil, responsecheck.Error(serverWhiteboard+"/"+toolQuery, "missing_page", "单页白板必须显式返回一个 page") + } + pages := make([]map[string]any, len(raw)) + nodes := make([]map[string]any, 0) + pageIDs := make(map[string]struct{}, len(raw)) + nodeIDs := make(map[string]struct{}) + for pageIndex, item := range raw { + page, ok := item.(map[string]any) + if !ok || len(page) == 0 { + return nil, nil, responsecheck.Error(serverWhiteboard+"/"+toolQuery, "malformed_item", fmt.Sprintf("resultJson.pages[%d] 必须是非空对象", pageIndex)) + } + pageID, ok := nonEmptyString(page["id"]) + if !ok { + return nil, nil, responsecheck.Error(serverWhiteboard+"/"+toolQuery, "missing_page_identity", fmt.Sprintf("resultJson.pages[%d].id 必须是非空稳定身份", pageIndex)) + } + if _, duplicate := pageIDs[pageID]; duplicate { + return nil, nil, responsecheck.Error(serverWhiteboard+"/"+toolQuery, "duplicate_page_identity", "resultJson.pages 含重复 page id") + } + pageIDs[pageID] = struct{}{} + nodesValue, present := page["nodes"] + if !present { + return nil, nil, responsecheck.Error(serverWhiteboard+"/"+toolQuery, "missing_nodes", fmt.Sprintf("resultJson.pages[%d] 缺少显式 nodes 数组", pageIndex)) + } + pageNodes, err := whiteboardNodeArray(nodesValue, fmt.Sprintf("resultJson.pages[%d].nodes", pageIndex), nodeIDs) + if err != nil { + return nil, nil, err + } + page["id"] = pageID + page["nodes"] = mapsToAny(pageNodes) + pages[pageIndex] = page + nodes = append(nodes, pageNodes...) + } + return pages, nodes, nil +} + +func whiteboardNodeArray(value any, field string, seen map[string]struct{}) ([]map[string]any, error) { + raw, ok := value.([]any) + if !ok { + return nil, responsecheck.Error(serverWhiteboard+"/"+toolQuery, "malformed_collection", fmt.Sprintf("%s 必须是数组,实际为 %T", field, value)) + } + items := make([]map[string]any, len(raw)) + for index, item := range raw { + object, ok := item.(map[string]any) + if !ok || len(object) == 0 { + return nil, responsecheck.Error(serverWhiteboard+"/"+toolQuery, "malformed_item", fmt.Sprintf("%s[%d] 必须是非空对象", field, index)) + } + id, ok := nonEmptyString(object["id"]) + if !ok { + return nil, responsecheck.Error(serverWhiteboard+"/"+toolQuery, "missing_node_identity", fmt.Sprintf("%s[%d].id 必须是非空稳定身份", field, index)) + } + if _, duplicate := seen[id]; duplicate { + return nil, responsecheck.Error(serverWhiteboard+"/"+toolQuery, "duplicate_node_identity", "resultJson.pages 跨页含重复节点 id") + } + seen[id] = struct{}{} + if _, ok := nonEmptyString(object["type"]); !ok { + return nil, responsecheck.Error(serverWhiteboard+"/"+toolQuery, "missing_node_type", fmt.Sprintf("%s[%d].type 必须是非空字符串", field, index)) + } + object["id"] = id + items[index] = object + } + return items, nil +} + +func nonEmptyStringArray(value any, field string) ([]string, error) { + raw, ok := value.([]any) + if !ok { + return nil, responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "malformed_collection", field+" 必须是显式数组") + } + result := make([]string, len(raw)) + seen := make(map[string]struct{}, len(raw)) + for index, item := range raw { + id, ok := nonEmptyString(item) + if !ok { + return nil, responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "malformed_item", fmt.Sprintf("%s[%d] 必须是非空身份", field, index)) + } + if _, duplicate := seen[id]; duplicate { + return nil, responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "duplicate_node_identity", field+" 含重复节点身份") + } + seen[id] = struct{}{} + result[index] = id + } + return result, nil +} + +func validateSummary(summary map[string]any, nodeCount, pageCount int) error { + for key, expected := range map[string]int{"nodeCount": nodeCount, "pageCount": pageCount} { + value, ok := nonNegativeInt(summary[key]) + if !ok || value != expected { + return responsecheck.Error(serverWhiteboard+"/"+toolQuery, "summary_count_mismatch", fmt.Sprintf("resultSummary.%s 必须等于显式数组长度 %d", key, expected)) + } + } + readOnly, ok := nonNegativeInt(summary["readOnlyNodeCount"]) + if !ok || readOnly > nodeCount { + return responsecheck.Error(serverWhiteboard+"/"+toolQuery, "malformed_read_only_count", "resultSummary.readOnlyNodeCount 必须是不超过 nodeCount 的非负整数") + } + unknown, ok := nonNegativeInt(summary["unknownNodeCount"]) + if !ok || unknown > nodeCount { + return responsecheck.Error(serverWhiteboard+"/"+toolQuery, "malformed_unknown_count", "resultSummary.unknownNodeCount 必须是不超过 nodeCount 的非负整数") + } + if _, ok := nonNegativeInt(summary["resultBytes"]); !ok { + return responsecheck.Error(serverWhiteboard+"/"+toolQuery, "malformed_result_bytes", "resultSummary.resultBytes 必须是非负整数") + } + if _, ok := nonEmptyString(summary["resultSha256"]); !ok { + return responsecheck.Error(serverWhiteboard+"/"+toolQuery, "missing_result_digest", "resultSummary.resultSha256 必须是非空字符串") + } + return nil +} + +func verifyWhiteboardUpdate(expected *parsedUpdate, projected map[string]any, idMap map[string]string) error { + source, ok := projected["source"].(map[string]any) + if !ok { + return responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "missing_readback_source", "更新后读回缺少 OpenNodes source") + } + _, readback, err := validateWhiteboardPages(source["pages"]) + if err != nil { + return err + } + byID := make(map[string]map[string]any, len(readback)) + for _, node := range readback { + id, _ := nonEmptyString(node["id"]) + byID[id] = node + } + if expected.Overwrite && whiteboardPageOwnedNodeCount(readback) != len(expected.Nodes) { + return responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "overwrite_count_mismatch", "overwrite 后读回节点数量与请求不一致") + } + for _, requested := range expected.Nodes { + requestID, _ := nonEmptyString(requested["id"]) + realID := idMap[requestID] + actual := byID[realID] + if actual == nil { + return responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "readback_identity_mismatch", fmt.Sprintf("更新后未按回执真实身份读回请求节点 %q", requestID)) + } + requestedType, _ := nonEmptyString(requested["type"]) + actualType, _ := nonEmptyString(actual["type"]) + if requestedType != actualType { + return responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "readback_type_mismatch", fmt.Sprintf("节点 %q 的 type 读回不一致", requestID)) + } + critical := make(map[string]any, len(requested)-1) + for key, value := range requested { + if key != "id" { + critical[key] = normalizeRequestedReadback(value, idMap, key) + } + } + if err := requireRequestedValue(critical, actual, "node "+requestID); err != nil { + return err + } + } + return nil +} + +func normalizeRequestedReadback(value any, idMap map[string]string, field string) any { + switch typed := value.(type) { + case map[string]any: + normalized := make(map[string]any, len(typed)) + requestScope, isRequestRef := typed["scope"].(string) + requestID, hasRequestID := nonEmptyString(typed["id"]) + for key, child := range typed { + normalized[key] = normalizeRequestedReadback(child, idMap, key) + } + if isRequestRef && requestScope == "request" && hasRequestID { + if realID := idMap[requestID]; realID != "" { + normalized["scope"] = "document" + normalized["id"] = realID + } + } + return normalized + case []any: + normalized := make([]any, len(typed)) + for index, child := range typed { + normalized[index] = normalizeRequestedReadback(child, idMap, field) + } + return normalized + case string: + if field == "parentId" { + if realID := idMap[typed]; realID != "" { + return realID + } + } + } + return value +} + +func whiteboardPageOwnedNodeCount(nodes []map[string]any) int { + count := 0 + for _, node := range nodes { + if source, ok := nonEmptyString(node["source"]); ok && source == "master" { + continue + } + count++ + } + return count +} + +func requireRequestedValue(expected, actual any, path string) error { + switch wanted := expected.(type) { + case map[string]any: + got, ok := actual.(map[string]any) + if !ok { + return responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "readback_field_mismatch", fmt.Sprintf("%s 读回类型不一致", path)) + } + for key, value := range wanted { + readback, present := got[key] + if !present { + return responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "readback_field_missing", fmt.Sprintf("%s.%s 未读回", path, key)) + } + if err := requireRequestedValue(value, readback, path+"."+key); err != nil { + return err + } + } + return nil + case []any: + got, ok := actual.([]any) + if !ok || len(got) != len(wanted) { + return responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "readback_field_mismatch", fmt.Sprintf("%s 数组读回不一致", path)) + } + for index := range wanted { + if err := requireRequestedValue(wanted[index], got[index], fmt.Sprintf("%s[%d]", path, index)); err != nil { + return err + } + } + return nil + } + if expectedNumber, expectedOK := numericValue(expected); expectedOK { + actualNumber, actualOK := numericValue(actual) + if !actualOK || expectedNumber.Cmp(actualNumber) != 0 { + return responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "readback_field_mismatch", fmt.Sprintf("%s 数值读回不一致", path)) + } + return nil + } + if expected != actual { + return responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "readback_field_mismatch", fmt.Sprintf("%s 读回值不一致", path)) + } + return nil +} + +func numericValue(value any) (*big.Rat, bool) { + switch typed := value.(type) { + case json.Number: + parsed, ok := new(big.Rat).SetString(typed.String()) + return parsed, ok + case float64: + if math.IsNaN(typed) || math.IsInf(typed, 0) { + return nil, false + } + return new(big.Rat).SetFloat64(typed), true + case int: + return new(big.Rat).SetInt64(int64(typed)), true + default: + return nil, false + } +} + +func requireJSONEOF(decoder *json.Decoder) error { + var trailing any + if err := decoder.Decode(&trailing); err == nil { + return fmt.Errorf("multiple JSON values are not allowed") + } else if !errorsIsEOF(err) { + return err + } + return nil +} + +func errorsIsEOF(err error) bool { return err == io.EOF } + +func nonEmptyString(value any) (string, bool) { + text, ok := value.(string) + text = strings.TrimSpace(text) + return text, ok && text != "" +} + +func nonNegativeInt(value any) (int, bool) { + switch typed := value.(type) { + case int: + return typed, typed >= 0 + case float64: + if typed < 0 || typed != math.Trunc(typed) || math.IsNaN(typed) || math.IsInf(typed, 0) || typed > float64(math.MaxInt) { + return 0, false + } + return int(typed), true + case json.Number: + parsed, err := strconv.ParseInt(typed.String(), 10, 64) + if err != nil || parsed < 0 || parsed > int64(math.MaxInt) { + return 0, false + } + return int(parsed), true + default: + return 0, false + } +} + +func mapsToAny(values []map[string]any) []any { + out := make([]any, len(values)) + for index := range values { + out[index] = values[index] + } + return out +} + +func init() { + shortcut.Register(Query, Update) +} diff --git a/internal/shortcut/whiteboard/whiteboard_test.go b/internal/shortcut/whiteboard/whiteboard_test.go new file mode 100644 index 00000000..1687956b --- /dev/null +++ b/internal/shortcut/whiteboard/whiteboard_test.go @@ -0,0 +1,709 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package whiteboard + +import ( + "context" + "encoding/json" + "errors" + "io" + "math" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/spf13/cobra" +) + +type whiteboardCoverageCall struct { + server string + tool string + args map[string]any +} + +type whiteboardCoverageCaller struct { + responses map[string][]string + calls []whiteboardCoverageCall + dry bool +} + +func (caller *whiteboardCoverageCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) { + caller.calls = append(caller.calls, whiteboardCoverageCall{server: server, tool: tool, args: args}) + queue := caller.responses[tool] + if len(queue) == 0 { + return nil, errors.New("missing fake response for " + tool) + } + caller.responses[tool] = queue[1:] + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: queue[0]}}}, nil +} + +func (*whiteboardCoverageCaller) Format() string { return "json" } +func (caller *whiteboardCoverageCaller) DryRun() bool { return caller.dry } +func (*whiteboardCoverageCaller) Fields() string { return "" } +func (*whiteboardCoverageCaller) JQ() string { return "" } + +func runWhiteboardCoverage(t *testing.T, declaration shortcut.Shortcut, caller *whiteboardCoverageCaller, stdin string, args ...string) error { + t.Helper() + helpers.InitDepsForTest(t, caller) + root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true} + root.PersistentFlags().Bool("yes", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().String("format", "json", "") + ctx, _ := output.WithResultStore(context.Background()) + root.SetContext(ctx) + root.SetIn(strings.NewReader(stdin)) + root.SetOut(io.Discard) + root.SetErr(io.Discard) + service := &cobra.Command{Use: "whiteboard"} + service.AddCommand(corecmd.New(shortcut.FromShortcut(declaration))) + root.AddCommand(service) + root.SetArgs(append([]string{"whiteboard", declaration.Command}, args...)) + return root.Execute() +} + +func directWhiteboardRuntime(t *testing.T, declaration shortcut.Shortcut, caller *whiteboardCoverageCaller, args ...string) *shortcut.RuntimeContext { + t.Helper() + helpers.InitDepsForTest(t, caller) + cmd := corecmd.New(shortcut.FromShortcut(declaration)) + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + if err := cmd.Flags().Parse(args); err != nil { + t.Fatal(err) + } + return shortcut.RuntimeContextForTest(cmd, declaration) +} + +func validWhiteboardQueryResponse(nodes string) string { + var decoded []any + if err := json.Unmarshal([]byte(nodes), &decoded); err != nil { + panic(err) + } + result, err := json.Marshal(map[string]any{ + "schemaVersion": "1.0", "catalogVersion": "dml-v1", + "pages": []any{map[string]any{"id": "page-1", "nodes": decoded}}, + }) + if err != nil { + panic(err) + } + envelope, err := json.Marshal(map[string]any{ + "success": true, "resultJson": string(result), + "resultSummary": map[string]any{ + "nodeCount": len(decoded), "pageCount": 1, "readOnlyNodeCount": 0, + "unknownNodeCount": 0, "resultBytes": len(result), "resultSha256": "0123456789abcdef", + }, + }) + if err != nil { + panic(err) + } + return string(envelope) +} + +func validWhiteboardUpdateResponse(mode string, requestIDs, realIDs []string, deleted int) string { + idMap := make(map[string]any, len(requestIDs)) + created := make([]any, len(realIDs)) + for index := range requestIDs { + idMap[requestIDs[index]] = realIDs[index] + created[index] = realIDs[index] + } + result, err := json.Marshal(map[string]any{ + "mode": mode, "createdNodeIds": created, "idMap": idMap, + "deletedNodeCount": deleted, "message": "completed", + }) + if err != nil { + panic(err) + } + envelope, err := json.Marshal(map[string]any{ + "success": true, "nodeId": "doc", "partId": "part", "resultJson": string(result), + }) + if err != nil { + panic(err) + } + return string(envelope) +} + +func TestCrossPlatformCoverageWhiteboardQueryRejectsFalseSuccessAndMalformedNodes(t *testing.T) { + broken := map[string]map[string]any{ + "empty": {}, + "missing success": {"resultJson": map[string]any{}}, + "success false": {"success": false, "resultJson": map[string]any{}}, + "success wrong type": {"success": "true", "resultJson": map[string]any{}}, + "missing result": {"success": true}, + "result wrong type": {"success": true, "resultJson": []any{}}, + "result invalid string": {"success": true, "resultJson": "{"}, + "missing pages": {"success": true, "resultJson": map[string]any{ + "schemaVersion": "1.0", "catalogVersion": "dml-v1", + }}, + "empty pages": {"success": true, "resultJson": map[string]any{ + "schemaVersion": "1.0", "catalogVersion": "dml-v1", "pages": []any{}, + }}, + "missing page id": {"success": true, "resultJson": map[string]any{ + "schemaVersion": "1.0", "catalogVersion": "dml-v1", "pages": []any{map[string]any{"nodes": []any{}}}, + }}, + "duplicate page id": {"success": true, "resultJson": map[string]any{ + "schemaVersion": "1.0", "catalogVersion": "dml-v1", "pages": []any{ + map[string]any{"id": "page", "nodes": []any{}}, map[string]any{"id": "page", "nodes": []any{}}, + }, + }}, + "missing nodes": {"success": true, "resultJson": map[string]any{ + "schemaVersion": "1.0", "catalogVersion": "dml-v1", "pages": []any{map[string]any{"id": "page"}}, + }}, + "nodes wrong type": {"success": true, "resultJson": map[string]any{ + "schemaVersion": "1.0", "catalogVersion": "dml-v1", "pages": []any{map[string]any{"id": "page", "nodes": map[string]any{}}}, + }}, + "bad node": {"success": true, "resultJson": map[string]any{ + "schemaVersion": "1.0", "catalogVersion": "dml-v1", "pages": []any{map[string]any{"id": "page", "nodes": []any{"bad"}}}, + }}, + "missing node id": {"success": true, "resultJson": map[string]any{ + "schemaVersion": "1.0", "catalogVersion": "dml-v1", "pages": []any{map[string]any{"id": "page", "nodes": []any{map[string]any{"type": "text"}}}}, + }}, + "cross-page duplicate node id": {"success": true, "resultJson": map[string]any{ + "schemaVersion": "1.0", "catalogVersion": "dml-v1", "pages": []any{ + map[string]any{"id": "page-1", "nodes": []any{map[string]any{"id": "same", "type": "text"}}}, + map[string]any{"id": "page-2", "nodes": []any{map[string]any{"id": "same", "type": "shape"}}}, + }, + }}, + "missing summary": {"success": true, "resultJson": map[string]any{ + "schemaVersion": "1.0", "catalogVersion": "dml-v1", "pages": []any{map[string]any{"id": "page", "nodes": []any{}}}, + }}, + "summary count conflict": {"success": true, "resultJson": map[string]any{ + "schemaVersion": "1.0", "catalogVersion": "dml-v1", "pages": []any{map[string]any{"id": "page", "nodes": []any{}}}, + }, "resultSummary": map[string]any{ + "nodeCount": float64(1), "pageCount": float64(1), "readOnlyNodeCount": float64(0), + "unknownNodeCount": float64(0), "resultBytes": float64(2), "resultSha256": "hash", + }}, + } + for name, payload := range broken { + t.Run(name, func(t *testing.T) { + if _, err := projectWhiteboardQuery(payload, "doc", "part"); err == nil { + t.Fatalf("payload unexpectedly accepted: %#v", payload) + } + }) + } + + explicitEmpty := map[string]any{ + "success": true, + "resultJson": map[string]any{ + "schemaVersion": "1.0", "catalogVersion": "dml-v1", "pages": []any{map[string]any{"id": "page", "nodes": []any{}}}, + }, + "resultSummary": map[string]any{ + "nodeCount": float64(0), "pageCount": float64(1), "readOnlyNodeCount": float64(0), + "unknownNodeCount": float64(0), "resultBytes": float64(2), "resultSha256": "hash", + }, + } + got, err := projectWhiteboardQuery(explicitEmpty, "doc", "part") + if err != nil || got["nodeId"] != "doc" || got["partId"] != "part" { + t.Fatalf("explicit empty result = %#v, err=%v", got, err) + } + source := got["source"].(map[string]any) + pages := source["pages"].([]any) + if len(pages) != 1 || len(pages[0].(map[string]any)["nodes"].([]any)) != 0 { + t.Fatalf("explicit nested empty was not preserved: %#v", source) + } +} + +func TestCrossPlatformCoverageWhiteboardSourceValidationFailsClosed(t *testing.T) { + for name, source := range map[string]string{ + "empty": "", + "invalid json": "{", + "trailing json": `{} {}`, + "missing source": `{}`, + "unknown top field": `{"extra":true}`, + "wrong schema": `{"source":{"schemaVersion":"2.0","catalogVersion":"dml-v1","nodes":[]}}`, + "wrong catalog": `{"source":{"schemaVersion":"1.0","catalogVersion":"v2","nodes":[]}}`, + "missing nodes": `{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1"}}`, + "nodes wrong type": `{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":{}}}`, + "append empty": `{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[]}}`, + "bad node": `{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[1]}}`, + "missing node id": `{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[{"type":"text"}]}}`, + "duplicate node id": `{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[{"id":"same","type":"text"},{"id":"same","type":"shape"}]}}`, + } { + t.Run(name, func(t *testing.T) { + if _, err := parseWhiteboardSource(source); err == nil { + t.Fatalf("source unexpectedly accepted: %q", source) + } + }) + } + + for _, source := range []string{ + `{"overwrite":true,"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[]}}`, + `{"overwrite":false,"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[{"id":"n1","type":"text"}]}}`, + } { + if _, err := parseWhiteboardSource(source); err != nil { + t.Fatalf("valid source rejected: %v", err) + } + } +} + +func TestCrossPlatformCoverageWhiteboardExactToolsConfirmationAndReadback(t *testing.T) { + queryCaller := &whiteboardCoverageCaller{responses: map[string][]string{ + "read_whiteboard_content": {validWhiteboardQueryResponse(`[{"id":"n1","type":"text"}]`)}, + }} + if err := runWhiteboardCoverage(t, Query, queryCaller, "", "--node", "doc", "--part-id", "part"); err != nil { + t.Fatal(err) + } + if len(queryCaller.calls) != 1 || queryCaller.calls[0].server != "whiteboard" || queryCaller.calls[0].tool != "read_whiteboard_content" { + t.Fatalf("query calls = %#v", queryCaller.calls) + } + + source := `{"overwrite":false,"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[{"id":"n1","type":"text"}]}}` + declined := &whiteboardCoverageCaller{responses: map[string][]string{}} + if err := runWhiteboardCoverage(t, Update, declined, "", "--node", "doc", "--part-id", "part", "--source", source); err == nil { + t.Fatal("update without confirmation unexpectedly succeeded") + } + if len(declined.calls) != 0 { + t.Fatalf("calls before confirmation = %#v", declined.calls) + } + + invalid := &whiteboardCoverageCaller{responses: map[string][]string{}} + if err := runWhiteboardCoverage(t, Update, invalid, "", "--node", "doc", "--part-id", "part", "--source", `{}`, "--yes"); err == nil { + t.Fatal("invalid update source unexpectedly succeeded") + } + if len(invalid.calls) != 0 { + t.Fatalf("invalid source reached remote calls = %#v", invalid.calls) + } + + dryRun := &whiteboardCoverageCaller{dry: true, responses: map[string][]string{}} + if err := runWhiteboardCoverage(t, Update, dryRun, "", "--node", "doc", "--part-id", "part", "--source", source, "--dry-run"); err != nil { + t.Fatal(err) + } + if len(dryRun.calls) != 0 { + t.Fatalf("dry-run reached remote calls = %#v", dryRun.calls) + } + + missingReceipt := &whiteboardCoverageCaller{responses: map[string][]string{ + "update_whiteboard": {`{"success":true}`}, + }} + if err := runWhiteboardCoverage(t, Update, missingReceipt, "", "--node", "doc", "--part-id", "part", "--source", source, "--yes"); err == nil { + t.Fatal("empty terminal receipt unexpectedly succeeded") + } + if len(missingReceipt.calls) != 1 { + t.Fatalf("missing receipt calls = %#v", missingReceipt.calls) + } + + verified := &whiteboardCoverageCaller{responses: map[string][]string{ + "update_whiteboard": {validWhiteboardUpdateResponse("append", []string{"n1"}, []string{"real-1"}, 0)}, + "read_whiteboard_content": {validWhiteboardQueryResponse(`[{"id":"real-1","type":"text","source":"page"}]`)}, + }} + if err := runWhiteboardCoverage(t, Update, verified, "", "--node", "doc", "--part-id", "part", "--source", source, "--yes"); err != nil { + t.Fatal(err) + } + if len(verified.calls) != 2 || verified.calls[0].tool != "update_whiteboard" || verified.calls[1].tool != "read_whiteboard_content" { + t.Fatalf("verified calls = %#v", verified.calls) + } + if verified.calls[0].args["nodeId"] != "doc" || verified.calls[0].args["partId"] != "part" || verified.calls[1].args["nodeId"] != "doc" || verified.calls[1].args["partId"] != "part" { + t.Fatalf("stable target identity was not preserved: %#v", verified.calls) + } + + mismatch := &whiteboardCoverageCaller{responses: map[string][]string{ + "update_whiteboard": {validWhiteboardUpdateResponse("append", []string{"n1"}, []string{"real-1"}, 0)}, + "read_whiteboard_content": {validWhiteboardQueryResponse(`[{"id":"other","type":"text"}]`)}, + }} + if err := runWhiteboardCoverage(t, Update, mismatch, "", "--node", "doc", "--part-id", "part", "--source", source, "--yes"); err == nil { + t.Fatal("readback mismatch unexpectedly succeeded") + } + if len(mismatch.calls) != 2 { + t.Fatalf("readback mismatch calls = %#v", mismatch.calls) + } + + richSource := `{"overwrite":false,"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[{"id":"n1","type":"text","text":"wanted","style":{"fontSize":16},"points":[1,2]}]}}` + fieldMismatch := &whiteboardCoverageCaller{responses: map[string][]string{ + "update_whiteboard": {validWhiteboardUpdateResponse("append", []string{"n1"}, []string{"real-1"}, 0)}, + "read_whiteboard_content": {validWhiteboardQueryResponse(`[{"id":"real-1","type":"text","text":"different","style":{"fontSize":16},"points":[1,2]}]`)}, + }} + if err := runWhiteboardCoverage(t, Update, fieldMismatch, "", "--node", "doc", "--part-id", "part", "--source", richSource, "--yes"); err == nil { + t.Fatal("request-critical field mismatch unexpectedly succeeded") + } +} + +func TestCrossPlatformCoverageWhiteboardReceiptRejectsFalseSuccessAndBadIdentityEvidence(t *testing.T) { + source := `{"overwrite":false,"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[{"id":"n1","type":"text"}]}}` + for name, response := range map[string]string{ + "missing target": `{"success":true,"resultJson":{"mode":"append","createdNodeIds":["real-1"],"idMap":{"n1":"real-1"},"deletedNodeCount":0,"message":"done"}}`, + "target mismatch": `{"success":true,"nodeId":"other","partId":"part","resultJson":{"mode":"append","createdNodeIds":["real-1"],"idMap":{"n1":"real-1"},"deletedNodeCount":0,"message":"done"}}`, + "missing collections": `{"success":true,"nodeId":"doc","partId":"part","resultJson":{"mode":"append","deletedNodeCount":0,"message":"done"}}`, + "created wrong type": `{"success":true,"nodeId":"doc","partId":"part","resultJson":{"mode":"append","createdNodeIds":{},"idMap":{"n1":"real-1"},"deletedNodeCount":0,"message":"done"}}`, + "id map wrong type": `{"success":true,"nodeId":"doc","partId":"part","resultJson":{"mode":"append","createdNodeIds":["real-1"],"idMap":[],"deletedNodeCount":0,"message":"done"}}`, + "identity disagreement": `{"success":true,"nodeId":"doc","partId":"part","resultJson":{"mode":"append","createdNodeIds":["real-1"],"idMap":{"n1":"real-2"},"deletedNodeCount":0,"message":"done"}}`, + "missing message": `{"success":true,"nodeId":"doc","partId":"part","resultJson":{"mode":"append","createdNodeIds":["real-1"],"idMap":{"n1":"real-1"},"deletedNodeCount":0}}`, + "append deleted nodes": `{"success":true,"nodeId":"doc","partId":"part","resultJson":{"mode":"append","createdNodeIds":["real-1"],"idMap":{"n1":"real-1"},"deletedNodeCount":1,"message":"done"}}`, + } { + t.Run(name, func(t *testing.T) { + caller := &whiteboardCoverageCaller{responses: map[string][]string{"update_whiteboard": {response}}} + if err := runWhiteboardCoverage(t, Update, caller, "", "--node", "doc", "--part-id", "part", "--source", source, "--yes"); err == nil { + t.Fatal("malformed terminal receipt unexpectedly succeeded") + } + if len(caller.calls) != 1 { + t.Fatalf("malformed receipt calls = %#v", caller.calls) + } + }) + } +} + +func TestCrossPlatformCoverageWhiteboardReadbackNormalizesRequestScopedReferences(t *testing.T) { + idMap := map[string]string{"frame": "real-frame", "left": "real-left"} + requested := map[string]any{ + "parentId": "frame", + "start": map[string]any{ + "type": "node", + "nodeRef": map[string]any{"scope": "request", "id": "left"}, + }, + } + actual := map[string]any{ + "parentId": "real-frame", + "start": map[string]any{ + "type": "node", + "nodeRef": map[string]any{"scope": "document", "id": "real-left"}, + "resolvedPoint": map[string]any{"x": float64(1), "y": float64(2)}, + }, + } + normalized := normalizeRequestedReadback(requested, idMap, "node") + if err := requireRequestedValue(normalized, actual, "node connector"); err != nil { + t.Fatal(err) + } +} + +func TestCrossPlatformCoverageWhiteboardPublicContractsStayStrictAndUnified(t *testing.T) { + for _, declaration := range []shortcut.Shortcut{Query, Update} { + if declaration.Contract.Empty() || declaration.Contract.Result == nil { + t.Errorf("%s missing Contract.Result", declaration.Command) + } + if declaration.Contract.Pagination != nil { + t.Errorf("%s publishes unsupported pagination", declaration.Command) + } + if declaration.OutputRollout != output.RolloutUnifiedActive { + t.Errorf("%s rollout=%q", declaration.Command, declaration.OutputRollout) + } + if declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != "available" || strings.TrimSpace(declaration.Contract.Interface.Reason) == "" { + t.Errorf("%s interface=%+v", declaration.Command, declaration.Contract.Interface) + } + } + if Query.Safety.Confirmation != "not_required" || Query.Safety.Effect != "read" { + t.Errorf("query safety=%+v", Query.Safety) + } + if Update.Safety.Confirmation != "user_required" || Update.Safety.Effect != "write" { + t.Errorf("update safety=%+v", Update.Safety) + } +} + +func TestCrossPlatformCoverageWhiteboardExecutorErrorsAndExactCallOrder(t *testing.T) { + queryCallError := &whiteboardCoverageCaller{responses: map[string][]string{}} + if err := runWhiteboardCoverage(t, Query, queryCallError, "", "--node", "doc", "--part-id", "part"); err == nil || len(queryCallError.calls) != 1 { + t.Fatalf("query call error=%v calls=%#v", err, queryCallError.calls) + } + queryProjectionError := &whiteboardCoverageCaller{responses: map[string][]string{ + toolQuery: {`{"success":true}`}, + }} + if err := runWhiteboardCoverage(t, Query, queryProjectionError, "", "--node", "doc", "--part-id", "part"); err == nil || len(queryProjectionError.calls) != 1 { + t.Fatalf("query projection error=%v calls=%#v", err, queryProjectionError.calls) + } + + invalidDirect := directWhiteboardRuntime(t, Update, &whiteboardCoverageCaller{responses: map[string][]string{}}, + "--node", "doc", "--part-id", "part", "--source", `{}`) + if err := Update.Execute(invalidDirect); err == nil { + t.Fatal("direct update accepted invalid source") + } + + overwriteSource := `{"overwrite":true,"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[]}}` + overwriteDryRun := &whiteboardCoverageCaller{dry: true, responses: map[string][]string{}} + if err := runWhiteboardCoverage(t, Update, overwriteDryRun, "", "--node", "doc", "--part-id", "part", "--source", overwriteSource, "--dry-run"); err != nil { + t.Fatal(err) + } + if len(overwriteDryRun.calls) != 0 { + t.Fatalf("overwrite dry-run calls=%#v", overwriteDryRun.calls) + } + + appendSource := `{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[{"id":"n1","type":"text"}]}}` + writeCallError := &whiteboardCoverageCaller{responses: map[string][]string{}} + if err := runWhiteboardCoverage(t, Update, writeCallError, "", "--node", "doc", "--part-id", "part", "--source", appendSource, "--yes"); err == nil || len(writeCallError.calls) != 1 || writeCallError.calls[0].tool != toolUpdate { + t.Fatalf("write call error=%v calls=%#v", err, writeCallError.calls) + } + + readCallError := &whiteboardCoverageCaller{responses: map[string][]string{ + toolUpdate: {validWhiteboardUpdateResponse("append", []string{"n1"}, []string{"real-1"}, 0)}, + }} + if err := runWhiteboardCoverage(t, Update, readCallError, "", "--node", "doc", "--part-id", "part", "--source", appendSource, "--yes"); err == nil || len(readCallError.calls) != 2 || readCallError.calls[1].tool != toolQuery { + t.Fatalf("read call error=%v calls=%#v", err, readCallError.calls) + } + + readProjectionError := &whiteboardCoverageCaller{responses: map[string][]string{ + toolUpdate: {validWhiteboardUpdateResponse("append", []string{"n1"}, []string{"real-1"}, 0)}, + toolQuery: {`{"success":true}`}, + }} + if err := runWhiteboardCoverage(t, Update, readProjectionError, "", "--node", "doc", "--part-id", "part", "--source", appendSource, "--yes"); err == nil || len(readProjectionError.calls) != 2 { + t.Fatalf("read projection error=%v calls=%#v", err, readProjectionError.calls) + } + + typeMismatch := &whiteboardCoverageCaller{responses: map[string][]string{ + toolUpdate: {validWhiteboardUpdateResponse("append", []string{"n1"}, []string{"real-1"}, 0)}, + toolQuery: {validWhiteboardQueryResponse(`[{"id":"real-1","type":"shape"}]`)}, + }} + if err := runWhiteboardCoverage(t, Update, typeMismatch, "", "--node", "doc", "--part-id", "part", "--source", appendSource, "--yes"); err == nil || len(typeMismatch.calls) != 2 { + t.Fatalf("type mismatch error=%v calls=%#v", err, typeMismatch.calls) + } + + overwriteVerified := &whiteboardCoverageCaller{responses: map[string][]string{ + toolUpdate: {validWhiteboardUpdateResponse("overwrite", nil, nil, 1)}, + toolQuery: {validWhiteboardQueryResponse(`[{"id":"master-1","type":"shape","source":"master"}]`)}, + }} + if err := runWhiteboardCoverage(t, Update, overwriteVerified, "", "--node", "doc", "--part-id", "part", "--source", overwriteSource, "--yes"); err != nil { + t.Fatal(err) + } + if len(overwriteVerified.calls) != 2 || overwriteVerified.calls[0].args["mode"] != "overwrite" || overwriteVerified.calls[1].tool != toolQuery { + t.Fatalf("overwrite call order=%#v", overwriteVerified.calls) + } +} + +func TestCrossPlatformCoverageWhiteboardQueryEnvelopeSummaryAndMessageMatrix(t *testing.T) { + baseResult := func() map[string]any { + return map[string]any{ + "schemaVersion": "1.0", "catalogVersion": "dml-v1", + "pages": []any{ + map[string]any{ + "id": "page", + "nodes": []any{ + map[string]any{"id": "node", "type": "text"}, + }, + }, + }, + } + } + baseSummary := func() map[string]any { + return map[string]any{ + "nodeCount": 1, "pageCount": 1, "readOnlyNodeCount": 0, + "unknownNodeCount": 0, "resultBytes": 1, "resultSha256": "hash", + } + } + fixtures := map[string]map[string]any{ + "wrong schema": {"success": true, "resultJson": map[string]any{"schemaVersion": "2.0", "catalogVersion": "dml-v1", "pages": []any{}}, "resultSummary": baseSummary()}, + "wrong catalog": {"success": true, "resultJson": map[string]any{"schemaVersion": "1.0", "catalogVersion": "v2", "pages": []any{}}, "resultSummary": baseSummary()}, + "bad page item": {"success": true, "resultJson": map[string]any{"schemaVersion": "1.0", "catalogVersion": "dml-v1", "pages": []any{"bad"}}, "resultSummary": baseSummary()}, + "missing node type": {"success": true, "resultJson": map[string]any{ + "schemaVersion": "1.0", "catalogVersion": "dml-v1", + "pages": []any{ + map[string]any{ + "id": "page", + "nodes": []any{ + map[string]any{"id": "node"}, + }, + }, + }, + }, "resultSummary": baseSummary()}, + "empty summary": {"success": true, "resultJson": baseResult(), "resultSummary": map[string]any{}}, + "wrong message": {"success": true, "resultJson": baseResult(), "resultSummary": baseSummary(), "message": 1}, + "conflicting error": {"success": true, "resultJson": baseResult(), "resultSummary": baseSummary(), "errorMsg": "failed"}, + "read only too high": {"success": true, "resultJson": baseResult(), "resultSummary": map[string]any{"nodeCount": 1, "pageCount": 1, "readOnlyNodeCount": 2, "unknownNodeCount": 0, "resultBytes": 1, "resultSha256": "hash"}}, + "unknown too high": {"success": true, "resultJson": baseResult(), "resultSummary": map[string]any{"nodeCount": 1, "pageCount": 1, "readOnlyNodeCount": 0, "unknownNodeCount": 2, "resultBytes": 1, "resultSha256": "hash"}}, + "bad result bytes": {"success": true, "resultJson": baseResult(), "resultSummary": map[string]any{"nodeCount": 1, "pageCount": 1, "readOnlyNodeCount": 0, "unknownNodeCount": 0, "resultBytes": -1, "resultSha256": "hash"}}, + "missing digest": {"success": true, "resultJson": baseResult(), "resultSummary": map[string]any{"nodeCount": 1, "pageCount": 1, "readOnlyNodeCount": 0, "unknownNodeCount": 0, "resultBytes": 1}}, + } + for name, fixture := range fixtures { + if projected, err := projectWhiteboardQuery(fixture, "doc", "part"); err == nil { + t.Errorf("%s returned success: %#v", name, projected) + } + } + valid := map[string]any{"success": true, "resultJson": baseResult(), "resultSummary": baseSummary(), "message": "ok"} + projected, err := projectWhiteboardQuery(valid, " doc ", " part ") + if err != nil || projected["nodeId"] != "doc" || projected["partId"] != "part" || projected["message"] != "ok" { + t.Fatalf("valid message projection=%#v err=%v", projected, err) + } +} + +func TestCrossPlatformCoverageWhiteboardReceiptAndResultJSONRemainingMatrix(t *testing.T) { + expected, err := parseWhiteboardSource(`{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[{"id":"n1","type":"text"}]}}`) + if err != nil { + t.Fatal(err) + } + target := map[string]any{"nodeId": "doc", "partId": "part"} + fixtures := map[string]map[string]any{ + "business failure": {"success": false}, + "missing terminal result": {"success": true, "nodeId": "doc", "partId": "part"}, + "empty result map": {"success": true, "nodeId": "doc", "partId": "part", "resultJson": map[string]any{}}, + "empty result string": {"success": true, "nodeId": "doc", "partId": "part", "resultJson": " "}, + "invalid result string": {"success": true, "nodeId": "doc", "partId": "part", "resultJson": "{"}, + "trailing result string": {"success": true, "nodeId": "doc", "partId": "part", "resultJson": `{"mode":"append"} {}`}, + "wrong result type": {"success": true, "nodeId": "doc", "partId": "part", "resultJson": []any{}}, + "wrong mode": {"success": true, "nodeId": "doc", "partId": "part", "resultJson": map[string]any{ + "mode": "overwrite", "message": "done", "createdNodeIds": []any{"real"}, "idMap": map[string]any{"n1": "real"}, "deletedNodeCount": 0, + }}, + "created count mismatch": {"success": true, "nodeId": "doc", "partId": "part", "resultJson": map[string]any{ + "mode": "append", "message": "done", "createdNodeIds": []any{}, "idMap": map[string]any{"n1": "real"}, "deletedNodeCount": 0, + }}, + "empty created identity": {"success": true, "nodeId": "doc", "partId": "part", "resultJson": map[string]any{ + "mode": "append", "message": "done", "createdNodeIds": []any{" "}, "idMap": map[string]any{"n1": "real"}, "deletedNodeCount": 0, + }}, + "duplicate created identity": {"success": true, "nodeId": "doc", "partId": "part", "resultJson": map[string]any{ + "mode": "append", "message": "done", "createdNodeIds": []any{"real", "real"}, "idMap": map[string]any{"n1": "real"}, "deletedNodeCount": 0, + }}, + "empty id map key": {"success": true, "nodeId": "doc", "partId": "part", "resultJson": map[string]any{ + "mode": "append", "message": "done", "createdNodeIds": []any{"real"}, "idMap": map[string]any{" ": "real"}, "deletedNodeCount": 0, + }}, + "id map count mismatch": {"success": true, "nodeId": "doc", "partId": "part", "resultJson": map[string]any{ + "mode": "append", "message": "done", "createdNodeIds": []any{"real"}, "idMap": map[string]any{}, "deletedNodeCount": 0, + }}, + } + for name, fixture := range fixtures { + if receipt, err := requireWhiteboardUpdateReceipt(fixture, target, "append", expected); err == nil { + t.Errorf("%s returned success: %#v", name, receipt) + } + } +} + +func TestCrossPlatformCoverageWhiteboardReadbackComparatorAndScalarMatrices(t *testing.T) { + if count := whiteboardPageOwnedNodeCount([]map[string]any{ + {"id": "master", "source": "master"}, {"id": "page", "source": "page"}, {"id": "plain"}, + }); count != 2 { + t.Fatalf("page-owned count=%d", count) + } + + comparisons := []struct { + name string + expected any + actual any + wantErr bool + }{ + {"map wrong type", map[string]any{"a": 1}, "bad", true}, + {"map missing key", map[string]any{"a": 1}, map[string]any{}, true}, + {"array wrong length", []any{1}, []any{}, true}, + {"array child mismatch", []any{1}, []any{2}, true}, + {"array equal", []any{1}, []any{float64(1)}, false}, + {"number wrong type", json.Number("1"), "1", true}, + {"number mismatch", json.Number("1"), float64(2), true}, + {"number equal cross type", json.Number("1"), int(1), false}, + {"large adjacent JSON integers mismatch", json.Number("9007199254740992"), json.Number("9007199254740993"), true}, + {"large JSON integer equal", json.Number("9007199254740993"), json.Number("9007199254740993"), false}, + {"scalar mismatch", "one", "two", true}, + {"scalar equal", "same", "same", false}, + } + for _, test := range comparisons { + err := requireRequestedValue(test.expected, test.actual, test.name) + if (err != nil) != test.wantErr { + t.Errorf("%s err=%v wantErr=%t", test.name, err, test.wantErr) + } + } + + for _, test := range []struct { + value any + ok bool + }{ + {json.Number("1.5"), true}, {json.Number("1e3"), true}, {json.Number("bad"), false}, + {float64(1), true}, {math.NaN(), false}, {math.Inf(1), false}, + {int(1), true}, {"1", false}, + } { + _, ok := numericValue(test.value) + if ok != test.ok { + t.Errorf("numericValue(%#v) ok=%t want=%t", test.value, ok, test.ok) + } + } + + for _, test := range []struct { + value any + want int + ok bool + }{ + {int(1), 1, true}, {int(-1), -1, false}, + {float64(2), 2, true}, {float64(-1), 0, false}, {1.5, 0, false}, {math.NaN(), 0, false}, {math.Inf(1), 0, false}, + {json.Number("3"), 3, true}, {json.Number("bad"), 0, false}, {json.Number("-1"), 0, false}, + {"4", 0, false}, + } { + got, ok := nonNegativeInt(test.value) + if got != test.want || ok != test.ok { + t.Errorf("nonNegativeInt(%#v)=(%d,%t), want (%d,%t)", test.value, got, ok, test.want, test.ok) + } + } + + decoder := json.NewDecoder(strings.NewReader(`{} ]`)) + var first map[string]any + if err := decoder.Decode(&first); err != nil { + t.Fatal(err) + } + if err := requireJSONEOF(decoder); err == nil { + t.Fatal("malformed trailing JSON returned EOF success") + } +} + +func TestCrossPlatformCoverageWhiteboardBlankTargetsStopBeforeRPC(t *testing.T) { + validSource := `{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[{"id":"n1","type":"text"}]}}` + tests := []struct { + name string + declaration shortcut.Shortcut + args []string + }{ + {"query blank node", Query, []string{"--node", " ", "--part-id", "part"}}, + {"query blank part", Query, []string{"--node", "doc", "--part-id", " "}}, + {"update blank node", Update, []string{"--node", " ", "--part-id", "part", "--source", validSource, "--yes"}}, + {"update blank part", Update, []string{"--node", "doc", "--part-id", " ", "--source", validSource, "--yes"}}, + } + for _, test := range tests { + caller := &whiteboardCoverageCaller{responses: map[string][]string{}} + if err := runWhiteboardCoverage(t, test.declaration, caller, "", test.args...); err == nil { + t.Errorf("%s returned success", test.name) + } + if len(caller.calls) != 0 { + t.Errorf("%s crossed RPC boundary: %#v", test.name, caller.calls) + } + } + directBlankNode := directWhiteboardRuntime(t, Query, &whiteboardCoverageCaller{responses: map[string][]string{}}, "--node", " ", "--part-id", "part") + if err := Query.Validate(directBlankNode); err == nil { + t.Fatal("direct query validation accepted blank node") + } + directBlankPart := directWhiteboardRuntime(t, Query, &whiteboardCoverageCaller{responses: map[string][]string{}}, "--node", "doc", "--part-id", " ") + if err := Query.Validate(directBlankPart); err == nil { + t.Fatal("direct query validation accepted blank part") + } + directUpdate := directWhiteboardRuntime(t, Update, &whiteboardCoverageCaller{responses: map[string][]string{}}, + "--node", " ", "--part-id", "part", "--source", validSource) + if err := Update.Validate(directUpdate); err == nil { + t.Fatal("direct update validation accepted blank node") + } +} + +func TestCrossPlatformCoverageWhiteboardSourceAndDirectVerificationRemainingMatrix(t *testing.T) { + for name, source := range map[string]string{ + "null nodes": `{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":null}}`, + "missing type": `{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[{"id":"node"}]}}`, + } { + if parsed, err := parseWhiteboardSource(source); err == nil { + t.Errorf("%s returned success: %#v", name, parsed) + } + } + t.Run("marshal failure", func(t *testing.T) { + testseam.Swap(t, &whiteboardMarshalNodes, func(any) ([]byte, error) { + return nil, errors.New("injected marshal failure") + }) + if parsed, err := parseWhiteboardSource(`{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[{"id":"node","type":"text"}]}}`); err == nil { + t.Fatalf("marshal failure returned success: %#v", parsed) + } + }) + + expected := &parsedUpdate{Overwrite: true, Nodes: []map[string]any{{"id": "request", "type": "text"}}} + if err := verifyWhiteboardUpdate(expected, map[string]any{}, map[string]string{"request": "real"}); err == nil { + t.Fatal("missing source returned success") + } + if err := verifyWhiteboardUpdate(expected, map[string]any{"source": map[string]any{"pages": "bad"}}, map[string]string{"request": "real"}); err == nil { + t.Fatal("bad pages returned success") + } + overwriteCountMismatch := map[string]any{ + "source": map[string]any{ + "pages": []any{ + map[string]any{ + "id": "page", + "nodes": []any{ + map[string]any{"id": "real", "type": "text"}, + map[string]any{"id": "extra", "type": "text"}, + }, + }, + }, + }, + } + if err := verifyWhiteboardUpdate(expected, overwriteCountMismatch, map[string]string{"request": "real"}); err == nil { + t.Fatal("overwrite count mismatch returned success") + } +} diff --git a/scripts/gen_shortcut_public_catalog.py b/scripts/gen_shortcut_public_catalog.py index de3a1f2d..b7696a6a 100644 --- a/scripts/gen_shortcut_public_catalog.py +++ b/scripts/gen_shortcut_public_catalog.py @@ -32,6 +32,8 @@ SEMANTIC_PATHS = [ ROOT / "internal" / "shortcut" / "semantic_catalog_oa.json", ROOT / "internal" / "shortcut" / "semantic_catalog_ding.json", ROOT / "internal" / "shortcut" / "semantic_catalog_report.json", + ROOT / "internal" / "shortcut" / "semantic_catalog_sheet.json", + ROOT / "internal" / "shortcut" / "semantic_catalog_whiteboard.json", ] diff --git a/scripts/gen_skill_shortcut_sections.py b/scripts/gen_skill_shortcut_sections.py index 1df63b03..64b5cbf5 100644 --- a/scripts/gen_skill_shortcut_sections.py +++ b/scripts/gen_skill_shortcut_sections.py @@ -44,8 +44,12 @@ SERVICE_TO_SKILL = { "report": ROOT / "skills" / "multi" / "dingtalk-misc" / "references" / "report.md", "sheet": ROOT / "skills" / "multi" / "dingtalk-misc" / "references" / "sheet.md", "todo": ROOT / "skills" / "multi" / "dingtalk-todo" / "SKILL.md", + "whiteboard": ROOT / "skills" / "multi" / "dingtalk-misc" / "references" / "whiteboard.md", "wiki": ROOT / "skills" / "multi" / "dingtalk-wiki" / "SKILL.md", } +SERVICE_TO_SKILL_MIRRORS = { + "whiteboard": [ROOT / "skills" / "mono" / "references" / "products" / "whiteboard.md"], +} MONO_START = "" MONO_END = "" @@ -226,17 +230,19 @@ def update_product_skills(items: list[dict[str, Any]], check: bool) -> list[Path for item in items: by_service[item["service"]].append(item) changed = [] - for service, path in SERVICE_TO_SKILL.items(): + for service, primary_path in SERVICE_TO_SKILL.items(): if service not in by_service: continue - if not path.exists(): - raise RuntimeError(f"skill file not found for {service}: {path}") - text = path.read_text(encoding="utf-8") - block = product_section(service, by_service[service]) - anchor = "## 概念地图" if service == "devapp" else "## 意图表" - updated = replace_block(text, PRODUCT_START, PRODUCT_END, block, anchor) - if apply_update(path, text, updated, check): - changed.append(path) + paths = [primary_path, *SERVICE_TO_SKILL_MIRRORS.get(service, [])] + for path in paths: + if not path.exists(): + raise RuntimeError(f"skill file not found for {service}: {path}") + text = path.read_text(encoding="utf-8") + block = product_section(service, by_service[service]) + anchor = "## 概念地图" if service == "devapp" else "## 意图表" + updated = replace_block(text, PRODUCT_START, PRODUCT_END, block, anchor) + if apply_update(path, text, updated, check): + changed.append(path) return changed diff --git a/skills/mono/SKILL.md b/skills/mono/SKILL.md index 3d226752..80c793f0 100644 --- a/skills/mono/SKILL.md +++ b/skills/mono/SKILL.md @@ -58,6 +58,7 @@ cli_version: ">=1.0.15" | `report` | 4 | `dingtalk-misc` | | `sheet` | 2 | `dingtalk-misc` | | `todo` | 21 | `dingtalk-todo` | +| `whiteboard` | 2 | `dingtalk-misc` | | `wiki` | 20 | `dingtalk-wiki` | diff --git a/skills/mono/references/products/sheet.md b/skills/mono/references/products/sheet.md index 6b45e267..980aa3e7 100644 --- a/skills/mono/references/products/sheet.md +++ b/skills/mono/references/products/sheet.md @@ -218,8 +218,8 @@ Flags: | Shortcut | 风险 | 适用场景 | |---|---|---| -| `dws sheet +list-sheets` | read | 获取表格文档中全部工作表列表 | -| `dws sheet +read` | read | 读取工作表指定范围的结构化单元格数据 | +| `dws sheet +list-sheets` | read | 严格列出在线电子表格的工作表,并可按完整标题精确筛选 | +| `dws sheet +read` | read | 完整读取并严格校验在线电子表格范围;截断结果失败关闭 | ## 意图表 diff --git a/skills/mono/references/products/whiteboard.md b/skills/mono/references/products/whiteboard.md index 306d3ea5..c8f46176 100644 --- a/skills/mono/references/products/whiteboard.md +++ b/skills/mono/references/products/whiteboard.md @@ -8,14 +8,27 @@ OpenNodes V1 的完整字段、节点类型、目录枚举和错误语义按需 `geometry`、`catalogId` 等枚举值。渐变卡片、Frame 分支、SVG/Vector 等完整 工作流见 [常用 Recipes](./whiteboard/recipes.md)。 + +## Shortcuts(无专用脚本/recipe 时优先) + +以下 shortcut 同时进入公开 catalog 与 Runtime Schema。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。命令已选中时直接执行;只在参数或安全语义不确定时读取 Agent leaf Schema(例如 `dws schema --cli-path "whiteboard +" --compact --format json`),在当前 Cobra flags 不确定时读取 `dws whiteboard --help`。只有参数映射、接口绑定或 provenance 审计才省略 `--compact`。仅当现有路由和 reference 都无法定位低频能力时,才用 `dws shortcut list --service whiteboard --format json` 批量发现。 + +| Shortcut | 风险 | 适用场景 | +|---|---|---| +| `dws whiteboard +query` | read | 严格读取已有文档白板的 OpenNodes 快照 | +| `dws whiteboard +update` | high-risk-write | 确认后更新白板并按同一稳定目标精确读回 | + + ## 标准流程 1. 从用户输入或真实文档 JSONML 取得 `nodeId` 和 card `metadata.id`(partId)。 -2. `dws whiteboard query --node --part-id --format json` 保存当前内容。 +2. `dws whiteboard +query --node --part-id --format json` 保存并严格校验当前内容。 3. 生成 OpenNodes V1 文件;不能把 query 响应直接回写。 4. 向用户展示写入范围并取得确认。 -5. `dws whiteboard update --node --part-id --source --yes --format json`。 -6. 再次 query 验证节点、层级和连接关系。 +5. `dws whiteboard +update --node --part-id --source --format json`;该 shortcut 会验证终态 receipt,并按同一白板执行独立读回。 +6. 仅在需要人工审阅完整节点细节时,再执行一次 `+query`。 + +执行层只能在已取得本次写入确认后添加 `--yes`;可复制的存储示例不得预置该参数。 更新文件: diff --git a/skills/multi/dingtalk-misc/references/sheet.md b/skills/multi/dingtalk-misc/references/sheet.md index 1fb2d5d1..0ea12239 100644 --- a/skills/multi/dingtalk-misc/references/sheet.md +++ b/skills/multi/dingtalk-misc/references/sheet.md @@ -218,8 +218,8 @@ Flags: | Shortcut | 风险 | 适用场景 | |---|---|---| -| `dws sheet +list-sheets` | read | 获取表格文档中全部工作表列表 | -| `dws sheet +read` | read | 读取工作表指定范围的结构化单元格数据 | +| `dws sheet +list-sheets` | read | 严格列出在线电子表格的工作表,并可按完整标题精确筛选 | +| `dws sheet +read` | read | 完整读取并严格校验在线电子表格范围;截断结果失败关闭 | ## 意图表 diff --git a/skills/multi/dingtalk-misc/references/whiteboard.md b/skills/multi/dingtalk-misc/references/whiteboard.md index 125c88fc..057b6995 100644 --- a/skills/multi/dingtalk-misc/references/whiteboard.md +++ b/skills/multi/dingtalk-misc/references/whiteboard.md @@ -8,6 +8,17 @@ OpenNodes V1 的完整字段、节点类型、目录枚举和错误语义按需 `geometry`、`catalogId` 等枚举值。渐变卡片、Frame 分支、SVG/Vector 等完整 工作流见 [常用 Recipes](./whiteboard/recipes.md)。 + +## Shortcuts(无专用脚本/recipe 时优先) + +以下 shortcut 同时进入公开 catalog 与 Runtime Schema。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。命令已选中时直接执行;只在参数或安全语义不确定时读取 Agent leaf Schema(例如 `dws schema --cli-path "whiteboard +" --compact --format json`),在当前 Cobra flags 不确定时读取 `dws whiteboard --help`。只有参数映射、接口绑定或 provenance 审计才省略 `--compact`。仅当现有路由和 reference 都无法定位低频能力时,才用 `dws shortcut list --service whiteboard --format json` 批量发现。 + +| Shortcut | 风险 | 适用场景 | +|---|---|---| +| `dws whiteboard +query` | read | 严格读取已有文档白板的 OpenNodes 快照 | +| `dws whiteboard +update` | high-risk-write | 确认后更新白板并按同一稳定目标精确读回 | + + ## 定位白板 每次操作都需要真实的文档 `nodeId` 和白板 `partId`。缺少 `partId` 时先读取文档 @@ -21,7 +32,7 @@ dws doc read --node --content-format jsonml --scope tags --tags card -- ## 读取 ```bash -dws whiteboard query --node --part-id --format json +dws whiteboard +query --node --part-id --format json ``` CLI 会把服务端 `resultJson` 字符串解析为结构化 JSON。白板命令不支持全局 @@ -61,14 +72,16 @@ CLI 会把服务端 `resultJson` 字符串解析为结构化 JSON。白板命令 - `overwrite=false`:追加,`nodes` 至少一个对象。 - `overwrite=true`:整页重建,允许空数组;执行前必须先 query 保存当前内容。 -- 所有更新都是远端写入,必须先获得用户确认,再加 `--yes`。 +- 所有更新都是远端写入,必须先获得用户对本次写入的确认;存储示例不携带 `--yes`,执行层只能在确认后添加。 - Query 返回不能直接作为 update 输入;真实节点 ID 不能用于局部修改。 ```bash -dws whiteboard update --node --part-id \ - --source ./whiteboard.json --yes --format json +dws whiteboard +update --node --part-id \ + --source @whiteboard.json --format json ``` +`+update` 会严格验证终态 receipt、请求节点到真实节点的映射,并对同一 `nodeId` / `partId` 执行独立读回;不需要再以手写原子命令拼装验证链。 + 常用节点类型包括 `text`、`shape`、`frame`、`group`、`connector`、`vector`。 节点可用请求内临时 `id` 建立 `parentId` 或 connector 引用;服务端负责完整字段、 层级和枚举校验,未知字段会使整次更新失败。