Compare commits

..
Author SHA1 Message Date
chichuan b3fdab44c9 Merge latest main into codex/sync-wukong-drive-list-latest
main 已封版 v1.0.57-beta.3 与 v1.0.57,[Unreleased] 被清空,与本分支的
--latest 条目冲突。解法:CHANGELOG 取 main 整份(已封版小节逐字节不动,
满足 check-changelog-pr.sh 的不可变约束),再把 --latest 条目补回
[Unreleased]。其余文件全部自动合并,main 未动过 internal/helpers/drive.go。
2026-08-06 16:07:22 +08:00
chichuan c5b537c8cf fix(drive): 修正 --latest 不完整扫描建议的自相矛盾并补齐叶子 Selection
P2-1 LATEST_SCAN_INCOMPLETE 的 Suggestion 与示例自相矛盾

问题:正文让用户「去掉 --latest 重跑」看失败明细,紧跟的唯一示例却是
dws drive list --folder <子目录ID> --latest N —— 照抄必然复现同一个错误。
根因:两条互斥的出路(缩范围后仍取 Top-N / 去掉 --latest 看明细)挤在一句里,
却只给了一条命令。
修法:每个「;」子句自带与该子句正文一致的命令 —— 重取 Top-N 那条保留 --latest,
去掉 --latest 那条给 --depth 版本。同时把 partial + errors[] 的承诺显式限定在
--depth>1:单层(含知识库 maxDepth=1)去掉 --latest 会路由回普通单层 list,
本就没有 errors[] 契约,无条件承诺只是把矛盾换成另一种不准确。
LATEST_SCAN_TRUNCATED 那条只有一个子句且命令保留 --latest,本身自洽,不动。

测试:新增 assertDriveLatestSuggestion 逐子句校验自洽性,替换 5 处只断言
Suggestion != "" 的弱断言。该断言对修改前的文案是红的(3 个 INCOMPLETE 用例
精确报出矛盾子句),对未改动的 TRUNCATED 文案是绿的 —— 证明它不是为新文案量身定做。

P2-2 drive list / drive recent 的 Selection 没跟上 Agent 路由目标

问题:上一轮改了 skill 文档与 flag help,但叶子 Contract 一字未动 ——
drive list 的 UseWhen 无 --latest 正选、Examples 两条都是 --limit 分页、
AvoidWhen 仍无条件让路给 drive recent(与 skill 里的三者路由矛盾);
drive recent 也没有反向指回 drive list --latest。
修法:drive list 追加 --latest 正选,AvoidWhen 第 3 条改写成「个人访问记录 →
recent / 文件修改时间 → 本命令 --latest」的分流;drive recent 追加对应反选。
措辞与 skills/**/drive.md 的三者路由一致。

Examples 是替换而非新增:schema_agent_examples.go 硬限最多 2 条
(has %d examples; maximum is 2),加第 3 条直接挂门禁。替换第 2 条不丢覆盖面 ——
--limit 仍由第 1 条示范、--folder 由第 2 条继续示范,第 2 条顺带示范 --latest,
且无 --limit(运行时互斥)、无 --yes(门禁禁止)。

删掉 docs/drive-list-latest-design.md

设计细节收敛到 skills/**/drive.md 单一出处。本轮 P2-2 正是「文档写对了、
Contract 没跟上」造成的,两份文档并存只会让下一次再跑偏。CHANGELOG 里唯一
指向它的链接行一并删除(第 23 行本身以句号结尾,bullet 依然完整)。
2026-08-06 15:29:14 +08:00
chichuan bc56373ef2 test(drive): 补齐 --latest 拒绝产出错误的目录名回落分支覆盖
CI Coverage 门禁要求改动代码 100% 覆盖,d1c8c903 的 changed-code 覆盖率
99.1379%(232 条语句缺 2 条)。缺口是 driveLatestIncompleteError 里
folderName 为空时的两级回落:先落 folderId,再落 <root>。

这两条不是防御性死代码:BFS 的根目录入队时是 driveDepthFolder{id, depth:0},
从不携带 name,所以「根目录首页有收获、后续页读取失败」这一真实路径产出的
driveDepthError 的 folderName 必然为空 —— 若不回落,用户拿到的就是
"folder=" 空洞提示。原有的 folder-failure 测试用的是有 name 的子目录 dirA,
正好绕过了这两条。

补两个测试(共用新 fixture driveLatestRootPageFailureCaller:根目录首页 1 个
文件 + nextToken,第 2 页 403):
- 未指定 --folder:folderId 也为空,消息回落到 folder=<root>
- 指定 --folder rootFolder:回落到 folder=rootFolder 而不是 <root>

无生产代码改动。本地复现并验证:改动代码覆盖率 99.1379% → 100.0000%
(232/232),internal/helpers 包整体 99.9% → 100.0%。
2026-08-06 14:05:51 +08:00
chichuan d1c8c903f6 fix(drive): 修复 --latest 的字段泄露、不完整扫描误报与别名互斥缺口
修复 PR #885 review 提出的三个问题,均已实机复现确认真实存在。

1. [P1] 内部排序字段 sortTime 泄露进输出契约
sortTime 无条件写入每个 BFS 项,却只在 latest>0 && depth==1 时随装饰字段一起剥除,
于是普通 --depth 2 与 depth>1 --latest 的输出都带上它,违反设计文档「不新增业务字段」。
改为只在 latest>0 时写入,并在 emitDriveDepthResult 发射前无条件删除(含 SIGINT 路径),
与 depth==1 的整体剥除解耦。

2. [P1] 扫描不完整仍以退出码 0 产出「全局 Top-N」
拒绝产出的防线只看 truncated,递归途中目录读取失败只进 errors[] 并继续执行。BFS 序与
修改时间无关,未读全的区域完全可能含更新文件,这种集合上的 Top-N 不是全局最新。改为
latest>0 时 truncated 或 errors[] 非空一律拒绝产出(新 token LATEST_SCAN_INCOMPLETE,
与 LATEST_SCAN_TRUNCATED 同 code,便于消费方区分「范围太大」与「读不到」);errors[]
不再进 stdout,失败目录数与首个失败详情因此搬进错误消息。不可恢复错误(token 过期 /
网络不可达)在 latest 下不吐 partial,原样上抛根因错误。不带 --latest 的路径行为不变
(partial + truncated:true + errors[] + 退出码 0),有独立护栏测试;SIGINT 路径不动
(退出码 130 已显式标记结果不完整)。

3. [P2] RegisterCrossProductAliases 自动注册的隐藏别名逃过互斥校验
校验手写枚举 limit/max 与 cursor/next-token,漏掉自动注册的 --page-size(limit 语义组)
与 --page-token(cursor 语义组):--latest 3 --page-size 1 此前退出 0 并静默忽略该 flag。
改为从注册表按语义组取别名(新增只读查询 crossProductAliasPeers),报错点名用户实际传入
的那个 flag 而非语义组主名。string 型按值判定(保住 Agent 的 --cursor "$CURSOR" 首页空值
模板)、其余按 Changed 判定,与改造前 flagOrFallback / Changed 逐字等价,只把覆盖面扩到
全部别名。

本 PR 之外的一处主动扩围(单列说明,便于单独判断):validateDriveListDepth 有完全相同的
枚举缺口。--depth 2 --limit 50 今天已报错,而 --depth 2 --page-size 50 静默忽略、
--depth 2 --page-token X 更糟(用户以为在续页,游标实际被丢掉)。共享 helper 后是一行
改动;已确认无代码以编程方式 Set 这些别名、无文档示例这样组合,行为变化面只是「静默忽略
→ 明确报错」。

测试:sortTime 不泄露(带/不带 latest 两侧)、目录失败与不可恢复错误下拒绝产出且 stdout
为空、对应的 latest=0 partial 护栏、用真实 newDriveCommand 构造的别名互斥回归(--latest
与 --depth 两侧共 14 组)、空值游标模板不误伤、crossProductAliasPeers 单测。
文档同步:设计文档 §3.2/§4/§5/§6/§7、两份 skill 镜像、flag 帮助文案与 CHANGELOG。
2026-08-06 12:14:55 +08:00
chichuan 432b8742c4 Merge latest main into codex/sync-wukong-drive-list-latest 2026-08-06 09:26:19 +08:00
chichuan a9af027ed4 feat: drive list 支持 --latest 按修改时间取最新 N 个文件
同步 dws-wukong CR 28929931(feature/drive-list-latest),按本仓结构重新落地。

一条命令直达「最近改的那份 XX」,替代「拉全量再自己比时间」的既有做法:

- 钉盘单层走服务端 orderBy=modifyTime&order=desc,凑够 N 即停(无 pattern 时
  通常一次请求),最多扫描 1000 条
- 知识库(--workspace)与递归(--depth>1)复用现有 BFS 遍历 + 共享后置处理器
  客户端排序,零新增请求
- 文件夹不进 Top-N;排序 tie-break 链 sortTime desc → rel_path asc → ID asc
- 与 --order-by/--order/--limit/--cursor/--versions 互斥,报错均附等效改写
- 递归触 2000 条上限时报 LATEST_SCAN_TRUNCATED 且 stdout 为空(截断集的 Top-N
  不是全局最新);凑不满 N 不算失败,退出码 0 + stderr 提示

本仓侧的三处结构性适配:
- 时间归一化新写为独立 helper internal/helpers/drive_time.go(wukong 侧已有)
- wukong 的 pytest e2e 套件折叠进 Go 测试,全部 TestCrossPlatformCoverage* 前缀
- envelope 契约由 test/fixtures/cli-interface-baseline.txt 承接

顺带补齐遗漏已久的 --depth / --pattern / --quiet 文档(mono + multi 双份)。
2026-08-05 23:05:12 +08:00
204 changed files with 7083 additions and 6490 deletions
+2 -1
View File
@@ -152,6 +152,7 @@ jobs:
filename.startsWith('internal/interfacesnapshot/') ||
filename.startsWith('internal/app/upgrade') ||
filename.startsWith('internal/transport/') ||
filename.startsWith('internal/recovery/') ||
filename.startsWith('internal/syncdata/') ||
filename.includes('/testdata/') ||
filename.startsWith('testdata/') ||
@@ -1175,7 +1176,7 @@ jobs:
FULL_SUITE: ${{ needs.lint.outputs.full_suite }}
COVERAGE_TARGET: "100"
COVERAGE_ENFORCE_OVERALL: "false"
COVERAGE_OVERALL_TOLERANCE: "0.1"
COVERAGE_OVERALL_TOLERANCE: "0"
run: |
policy_profile=coverage-policy.txt
if [ "$FULL_SUITE" != true ]; then
+12 -15
View File
@@ -302,8 +302,9 @@ on the leaf:
```bash
dws auth status # token_valid should be true
dws schema <mcp-canonical> --jq '{canonical_path,interface_ref,parameters}' -f json
# or CLI path: dws schema --cli-path "drive copy" --jq '{canonical_path,interface_ref,parameters}' -f json
dws cache refresh # deprecated no-op: prints a retirement notice (discovery cache is gone; refreshes nothing)
dws schema <mcp-canonical> -f json
# or CLI path: dws schema --cli-path "drive copy" -f json
```
Resolve MCP identity via declared `interface_ref` when CLI canonical ≠ MCP path
@@ -320,10 +321,8 @@ Skill (evidence only)**.
Split work by product groups. Each agent must:
- Read Skill, Cobra/`--help`, Runtime confirmation sites, and live
`dws schema <leaf> --compact` for its tools. Mapping/interface/provenance
audits may query the full leaf only through a narrow `--jq` / `--fields`
projection; do not load an entire full leaf into Agent context.
- Read Skill, Cobra/`--help`, Runtime confirmation sites, and live `dws schema`
for its tools.
- Hand-write selection prose and leaf Contract / ProductDecl declarations;
forbid wholesale JSON merges from review dumps.
- Edit only its product’s leaf declarations (and `ProductDecl` when needed).
@@ -472,15 +471,13 @@ path; a generator unit test or JSON count alone is insufficient.
`parameters` object for commands without flags. Keep it suitable for the #602
compatibility baseline and fail rather than silently emitting a partial
export.
- `schema --all` is not normal command discovery. Use overview -> compact
product/group -> compact leaf for routine Agent work. `--compact` is the
reviewed positive-field allowlist for Agent context: new full/audit fields
must not appear there until explicitly reviewed. A compact full export is not
a complete compatibility baseline.
- `schema --all` is not normal command discovery. Use overview -> product/group
-> leaf for routine Agent work. `--compact` is supported for context-saving
projections, but a compact full export is not a complete compatibility
baseline.
- `dws <path> --help` defines whether Cobra exposes a path and which flags the
executable accepts. A compact leaf defines Agent selection, CLI parameters,
constraints, and safety/confirmation semantics. Full leaf fields such as
`property`, `interface_ref`, and provenance are audit facts. A conflict is
contract drift, not permission to guess.
executable accepts. A leaf Schema defines Agent selection, parameter mapping
and constraints, and safety/confirmation semantics. A conflict is contract
drift, not permission to guess.
- Schema and Help describe commands; neither returns DingTalk business data.
After discovery, execute the real read/search/list command to obtain data.
+14 -6
View File
@@ -6,13 +6,21 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
### Deprecated
### Added
- **`dws recovery` CLI** — keeps a visible Deprecated compatibility stub for
plan/execute/finalize that returns an explicit “不再支持” notice. The
`internal/recovery` package is removed; Skills must not teach this path.
Transport errors keep generic retry/auth guidance without pointing callers
at recovery.
- **`drive list --latest N`** — returns the N most recently modified files (1–50) in one command,
replacing the "fetch everything and sort client-side" workaround. Combines with `--pattern`
("newest N among name matches"), `--folder` / `--space-id`, `--workspace` (Knowledge Base), and
`--depth`. The DingTalk Drive single-layer route uses server-side `orderBy=modifyTime&order=desc`
and stops as soon as N is filled (typically one request); the Knowledge Base and recursive routes
reuse the existing BFS walk and sort client-side. Folders never enter the Top-N. Mutually
exclusive with `--order-by` / `--order` / `--limit` / `--cursor` / `--versions` — including the
auto-registered hidden aliases `--page-size` / `--page-token` / `--next-token` / `--max`, and the
error names the flag you actually passed — and every exclusivity error carries the equivalent
rewrite. When a recursive scan hits the 2000-item cap (`LATEST_SCAN_TRUNCATED`) or any folder in
the walk fails to read (`LATEST_SCAN_INCOMPLETE`) the command **fails** with empty stdout rather
than emitting a Top-N that may not be globally newest; falling short of N is not a failure
(exit 0 plus a stderr hint). Internal sort state never reaches the output contract.
## [1.0.57] - 2026-08-06
-3
View File
@@ -131,9 +131,6 @@ skill-context-budget:
multi-im-skill-chain-integrity:
@./scripts/policy/check-multi-im-skill-chain.sh
skill-mono-multi-content:
@./scripts/policy/check-mono-multi-skill-content.sh
cli-smoke:
@./scripts/policy/check-cli-smoke.sh
+14 -11
View File
@@ -71,7 +71,9 @@ The installer ships skills in one of two layouts. CLI commands (`dws aitable ...
| Mode | What gets installed | Best for |
|------|----------------------|----------|
| **mono** (stable, default) | One `dws` skill covering all products | Cross-product workflows; single entry point |
| **multi** | Per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
| **multi** 🧪 **EXPERIMENTAL** | Per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
> 🧪 **`multi` is currently EXPERIMENTAL / preview.** All product-scoped skills pass the dispatch verifier, but interface, naming and cross-skill references may change in future releases. For production / shared environments, prefer `mono`. File issues if you hit problems.
How to pick:
@@ -369,7 +371,7 @@ dws contact user get-self --jq '.result[0].orgEmployeeModel | {name: .orgUserNam
Use Cobra help and Schema for different parts of the command contract:
- `dws <path> --help` is the source of truth for whether a command exists and which flags the binary accepts.
- `dws schema "<path>" --compact` is the normative Agent view for command selection, CLI parameters and constraints, risk, and confirmation; use a full leaf with a narrow `--jq` projection for mapping or provenance audits.
- `dws schema "<path>"` is the Agent contract for command selection, parameter mappings and constraints, risk, and confirmation semantics.
- If Help and Schema disagree, treat it as contract drift: pass only flags accepted by Cobra and use the more conservative safety semantics.
- Schema describes commands; it does not read or search DingTalk business data. Execute the real product command after discovery.
@@ -378,21 +380,21 @@ Use Cobra help and Schema for different parts of the command contract:
dws aitable record query --help
# Discover within a product, then inspect the selected leaf contract
dws schema aitable --compact
dws schema "aitable record query" --compact
dws schema aitable
dws schema "aitable record query"
# Execute the real business query
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
```
`dws schema --all` exports the complete contract for tooling, CI, audits, and compatibility baselines. Agents should query progressively with `--compact`; its positive field allowlist prevents new full/audit fields from silently expanding Agent context.
`dws schema --all` exports the complete contract for tooling, CI, audits, and compatibility baselines. Agents should prefer product/group discovery followed by a leaf query to avoid loading the full Catalog into context.
### Agent Skills
The repo ships a complete Agent Skill system under `skills/`, organized into two layouts:
- `skills/mono/` — single-skill layout (one `SKILL.md` + `references/products/`), recommended default.
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ...), each with its own `SKILL.md`.
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ...), each with its own `SKILL.md`. 🧪 **EXPERIMENTAL / preview — see banner in each multi `SKILL.md` for caveats.**
Leaf safety/parameters/selection prose for Schema generation come from ProductDecl / ContractFinal declarations in Go. The former `internal/cli/schema_hints/` HintFile tree is fully retired and must not reappear.
@@ -441,6 +443,7 @@ Env vars: `DWS_SKILL_MODE=mono|multi` (also honored by `install.sh` / `install.p
| Intent guide | `skills/mono/references/intent-guide.md` | Disambiguation for confusing scenarios (e.g. report vs todo) |
| Global reference | `skills/mono/references/global-reference.md` | Auth, output formats, global flags |
| Error codes | `skills/mono/references/error-codes.md` | Error codes + debugging workflows |
| Recovery guide | `skills/mono/references/recovery-guide.md` | `RECOVERY_EVENT_ID` handling |
| Ready-made scripts | `skills/mono/scripts/*.py` | 13 batch operation scripts (see below) |
<details>
@@ -536,7 +539,7 @@ For one-to-one and specified-sender events, use exactly one target identity: `--
| Observability | `status` shows remote subscriptions, the personal bus, and local consumers |
| Cross-platform | Unix Socket on macOS/Linux, Windows Named Pipe on Windows |
See `skills/multi/dingtalk-misc/references/event.md` for the Agent workflow and supported event parameters.
See `skills/multi/dingtalk-event/SKILL.md` for the Agent workflow and supported event parameters.
</details>
@@ -621,7 +624,7 @@ dws aitable record query --base-id BASE_ID --tabel-id TABLE_ID # --tabel-i
```bash
# Built-in jq expressions
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --jq '.invocation.params'
dws schema "dev app create" --jq '.parameters'
dws schema "dev app create" --jq '.tool.required'
# Return only specific fields
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocation,response
@@ -633,9 +636,9 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocati
<summary><strong>Schema Introspection</strong> — Agent command discovery and execution contracts</summary>
```bash
dws schema aitable --compact # discover product commands
dws schema "aitable record query" --compact # view the selected Agent leaf contract
dws schema "aitable record query" --jq '[.parameters | to_entries[] | select(.value.required)]' # view required fields
dws schema aitable # discover product commands
dws schema "aitable record query" # view the selected leaf contract
dws schema "aitable record query" --jq '.tool.required' # view required fields
dws schema --all # full export for CI/audit/baselines
```
+14 -11
View File
@@ -71,7 +71,9 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
| 模式 | 安装内容 | 适合场景 |
|------|----------|----------|
| **mono**(稳定,默认) | 一个 `dws` skill,覆盖全部产品 | 跨产品组合操作;单一入口召唤 |
| **multi** | 按产品拆分的独立 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
| **multi** 🧪 **试验版 / Preview** | 按产品拆分的独立 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
> 🧪 **multi 模式当前为 EXPERIMENTAL(试验版 / Preview)**。全部独立 skill 均通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整。生产 / 共享环境建议优先用 `mono`。问题请提 issue 反馈。
怎么选:
@@ -363,7 +365,7 @@ dws contact user get-self --jq '.result[0].orgEmployeeModel | {name: .orgUserNam
命令帮助和 Schema 分别负责命令契约的不同部分:
- `dws <path> --help` 是命令是否存在、当前二进制接受哪些 flags 的事实源。
- `dws schema "<path>" --compact` 是 Agent 选命令、CLI 参数与约束、风险和确认语义的规范视图;映射或 provenance 审计使用 full leaf 配合 `--jq` 精确投影。
- `dws schema "<path>"` 是 Agent 选命令、参数映射与约束、风险和确认语义的契约。
- Help 与 Schema 冲突时视为契约漂移:执行只传 Cobra 接受的参数,安全语义取更保守值。
- Schema 只描述命令,不读取或搜索钉钉业务数据;发现命令后仍需执行真实产品命令。
@@ -372,21 +374,21 @@ dws contact user get-self --jq '.result[0].orgEmployeeModel | {name: .orgUserNam
dws aitable record query --help
# 先在产品内发现命令,再查看选中 leaf 的契约
dws schema aitable --compact
dws schema "aitable record query" --compact
dws schema aitable
dws schema "aitable record query"
# 执行真实业务查询
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
```
`dws schema --all` 会完整导出命令契约,供工具、CI、审计和兼容性基线使用。Agent 应使用 `--compact` 渐进查询;该视图采用正向字段白名单,full 新增的审计字段不会自动进入 Agent 上下文。
`dws schema --all` 会完整导出命令契约,供工具、CI、审计和兼容性基线使用。Agent 应优先按产品/分组发现后查询 leaf,避免把整个 Catalog 加载进上下文。
### Agent Skills
仓库内置完整的 Agent Skill 体系(`skills/` 目录),分为两套布局:
- `skills/mono/` — 单 skill 布局(一个 `SKILL.md` + `references/products/`),默认推荐。
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ...),每个 skill 自带 `SKILL.md`。
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ...),每个 skill 自带 `SKILL.md`。🧪 **试验版 / Preview — 各 multi `SKILL.md` 头部有详细注意事项。**
Schema 生成的叶子 safety/参数/选型文案由 Go 中的 ProductDecl / ContractFinal 声明驱动。原 `internal/cli/schema_hints/` HintFile 目录已完全退役,不得重新引入。
@@ -435,6 +437,7 @@ DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
| 意图指南 | `skills/mono/references/intent-guide.md` | 易混淆场景消歧(如 report vs todo) |
| 全局参考 | `skills/mono/references/global-reference.md` | 认证、输出格式、全局 flag |
| 错误码 | `skills/mono/references/error-codes.md` | 错误码 + 调试流程 |
| Recovery 指南 | `skills/mono/references/recovery-guide.md` | `RECOVERY_EVENT_ID` 处理 |
| 现成脚本 | `skills/mono/scripts/*.py` | 13 个批量操作脚本(见下方) |
<details>
@@ -530,7 +533,7 @@ dws event stop <subscribe_id>
| 状态可观测 | `status` 同时显示服务端订阅、personal bus 和本地 consumers |
| 跨平台 | macOS/Linux 使用 Unix Socket,Windows 使用 Named Pipe |
Agent 工作流和事件参数详见 `skills/multi/dingtalk-misc/references/event.md`。
Agent 工作流和事件参数详见 `skills/multi/dingtalk-event/SKILL.md`。
</details>
@@ -615,7 +618,7 @@ dws aitable record query --base-id BASE_ID --tabel-id TABLE_ID # --tabel-i
```bash
# 内置 jq 表达式
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --jq '.invocation.params'
dws schema "dev app create" --jq '.parameters'
dws schema "dev app create" --jq '.tool.required'
# 只返回指定字段
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocation,response
@@ -627,9 +630,9 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocati
<summary><strong>Schema 自省</strong> — Agent 命令发现与执行契约</summary>
```bash
dws schema aitable --compact # 发现产品命令
dws schema "aitable record query" --compact # 查看 Agent leaf 契约
dws schema "aitable record query" --jq '[.parameters | to_entries[] | select(.value.required)]' # 定向查看必填字段
dws schema aitable # 发现产品命令
dws schema "aitable record query" # 查看选中 leaf 契约
dws schema "aitable record query" --jq '.tool.required' # 查看必填字段
dws schema --all # CI/审计/基线的全量导出
```
+1
View File
@@ -40,6 +40,7 @@
- `internal/output`: response formatting (json, table, raw, pretty)
- `internal/logging`: structured logging and argument sanitization
- `internal/tui`: terminal UI helpers
- `internal/recovery`: panic recovery and graceful degradation
- `pkg/configmeta`: environment variable registry and documentation
- `pkg/config`: configuration constants and paths
- `pkg/edition`: edition detection (oss vs enterprise)
+1 -1
View File
@@ -43,7 +43,7 @@ repository root while preserving repo-local guidance for automation.
- Error message or category issues: inspect `internal/errors`
- Audit log issues: inspect `internal/audit`
- Plugin loading or command surface: inspect `internal/plugin`
- Failure or degraded mode: inspect `internal/errors`
- Failure or degraded mode: inspect `internal/errors`, `internal/recovery`
## Policy Checks
+1 -1
View File
@@ -1,7 +1,7 @@
# dws dev 命令集 · Agent 人肉手工评测集(10 条复合用例)
> 性质:**人肉手工评测集**——由测评人逐条手工跑、肉眼核对、人工判分,不是自动化脚本。
> 用途:评测 agent(加载 `dingtalk-misc` 的 `references/devapp.md` 后)能否正确处理开放平台 dev 任务。
> 用途:评测 agent(加载 `dingtalk-dev` 技能后)能否正确处理开放平台 dev 任务。
> 特点:10 条**复合用例**,每条串多个子任务,一条覆盖一类完整场景;10 条合起来覆盖全部 34 个子命令 + 8 类横切行为。
> 约定:所有命令应带 `--format json`;写操作应先 `--dry-run` 预览、用户确认后再 `--yes`;应用定位只用 `--unified-app-id`。
+3 -3
View File
@@ -6,7 +6,7 @@
## 一键安装
`dws dev` 能力已经合入主干并随正式版发布。专用安装脚本会下载预编译二进制 + `dingtalk-misc` skill(开放平台应用文档落在 misc),**只需要 curl + tar,不需要 git / go / make**。
`dws dev` 能力已经合入主干并随正式版发布。专用安装脚本会下载预编译二进制 + `dingtalk-dev` skill,**只需要 curl + tar,不需要 git / go / make**。
### macOS / Linux
@@ -24,7 +24,7 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
1. 从 `DingTalk-Real-AI/dingtalk-workspace-cli` 的最新 Release 下载对应平台的预编译二进制。
2. 安装 `dws` 到默认目录 `~/.local/bin`。
3. 从 Release 的 skills 包里安装 `dingtalk-misc` skill 到本机已检测到的 Agent 目录。
3. 从 Release 的 skills 包里安装 `dingtalk-dev` skill 到本机已检测到的 Agent 目录。
支持这些环境变量(全部可选):
@@ -33,7 +33,7 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
| `DEVAPP_REPO` | 覆盖发布仓库,默认 `DingTalk-Real-AI/dingtalk-workspace-cli` |
| `DEVAPP_VERSION` | 钉某个 release tag,默认取最新 release |
| `DWS_INSTALL_DIR` | 二进制安装目录,默认 `~/.local/bin` |
| `DWS_NO_SKILLS` | 设为 `1` 跳过 `dingtalk-misc` skill 安装 |
| `DWS_NO_SKILLS` | 设为 `1` 跳过 `dingtalk-dev` skill 安装 |
> `dws dev` 已在正式版里,所以你也可以直接用标准安装脚本 `install.sh`,二者都会带上 `dws dev`。
+10 -11
View File
@@ -94,7 +94,7 @@ With `-f json`, error responses include structured payloads: `category`, `reason
dws contact user search --query "Alice" -f table # Table (default, human-friendly / 表格,默认)
dws contact user search --query "Alice" -f json # JSON (for agents and piping / 适合 agent)
dws contact user search --query "Alice" -f raw # Raw API response / 原始响应
dws schema -f pretty "calendar event create" --compact # Pretty Agent schema view / Agent Schema 彩色查看
dws schema -f pretty "calendar event create" # Pretty Agent schema view / Agent Schema 彩色查看
```
## Dry Run / 试运行
@@ -119,28 +119,27 @@ Schema 的稳定 `canonical_path`、主 CLI 路径和 aliases 收集自命令树
```bash
dws schema # 当前公开产品面的紧凑概览
dws schema calendar --compact # Agent 产品视图
dws schema "calendar event" --compact # Agent 分组视图
dws schema "calendar event create" --compact # Agent leaf(CLI 空格路径)
dws schema calendar.create_calendar_event --compact # Agent leaf(canonical path)
dws schema --cli-path "calendar event create" --compact # Agent leaf(显式 CLI path)
dws schema "calendar event create" # full leaf,仅用于映射/provenance 审计
dws schema calendar # 展开一个产品
dws schema "calendar event" # 展开一个命令分组
dws schema "calendar event create" # 按 CLI 空格路径查询工具
dws schema calendar.create_calendar_event # 按 canonical path 查询工具
dws schema --cli-path "calendar event create" # 显式 CLI path
dws schema "calendar event create" --compact # 支持:省略 provenance/debug 字段
dws schema --all # 全部工具的完整 leaf Schema,用于审计/CI/baseline
```
兼容入口 `dws schema list` 等价于根概览。`schema --all` 是完整导出:每个工具都包含完整 leaf 参数、约束和安全语义。它输出很大,只用于明确要求的全量导出、审计、CI 或参数 baseline;普通 Agent 任务应按概览、产品/分组、leaf 渐进查询,不要把 `--all` 直接注入上下文。`schema --all --compact` 虽受支持,但会裁掉 provenance 和接口映射字段,不能作为完整 baseline。
省略 `--compact` 的 full leaf、`--all` 中对应工具和 Catalog full tool 均由同一个 resolved `ToolSpec` 投影,内容必须一致;compact leaf 仅做字段白名单投影,不重新解析语义。概览、产品/分组和 Catalog summary 也来自同一 `ToolSpec`。通过 alias 查询时,只允许路径视图发生变化,参数、安全和接口契约不得变化。
Leaf 查询、`--all` 中对应工具和 Catalog full tool 均由同一个 resolved `ToolSpec` 投影,内容必须一致;概览、产品/分组和 Catalog summary 也由该 `ToolSpec` 的统一 summary 投影生成。通过 alias 查询时,只允许 `cli_path` 和 `is_alias` 发生视图变化,参数、安全和接口契约不得变化。
`--compact` 是 Schema 的稳定 Agent 字段白名单,也是普通 Agent 查询的规范选项。它保留 CLI 参数、组合约束、选择和安全语义,但有意省略 `interface_ref`、参数 `property/interface_type` 与 provenance。检查这些映射/审计字段时,使用 full leaf 并通过 `--jq` / `--fields` 精确投影。若兼容旧二进制时收到 `unknown_flag: --compact`,用同一个 Schema 查询去掉 `--compact` 重试;这只降低输出裁剪能力,不表示 leaf 缺失。
`--compact` 是 Schema 的展示选项。当前版本支持该 flag;若兼容旧二进制时收到 `unknown_flag: --compact`,用同一个 Schema 查询去掉 `--compact` 重试。这只降低输出裁剪能力,不表示 leaf 不存在,也不能改用 Schema 查询业务数据。
### Schema、Help 与业务数据的边界
| 问题 | 事实源 |
|------|--------|
| 命令是否由当前二进制暴露、Cobra 接受哪些 flags | `dws <path> --help` |
| Agent 选哪个命令、CLI 参数与组合约束、risk/confirmation | 对应的 Agent leaf `dws schema "<path>" --compact` |
| CLI↔RPC 参数映射、接口绑定与 provenance | full leaf 配合 `--jq` / `--fields` 精确投影 |
| Agent 选哪个命令、参数映射与组合约束、risk/confirmation | 对应的 leaf `dws schema "<path>"` |
| 当前钉钉中的文档、文件、日程、消息等业务数据 | 实际执行 `dws doc read`、`dws drive search` 等 read/search/list 命令 |
Schema 与 Help 冲突表示发布契约漂移,不能静默猜测。执行参数必须以 Cobra 实际接受的 flag 为准;安全语义冲突时采用更保守的处理(例如先确认)或停止执行并报告漂移。完成命令发现后,仍必须执行真实业务命令;`dws schema` 本身不会读取或搜索业务内容。
+1 -1
View File
@@ -6,7 +6,7 @@
## 第一步:安装 dws
一键脚本会自动下载最新版二进制 + `dingtalk-misc` skill(开放平台应用文档落在 misc),只需要 curl(无需 go / git)。
一键脚本会自动下载最新版二进制 + `dingtalk-dev` skill,只需要 curl(无需 go / git)。
### macOS / Linux
+6 -7
View File
@@ -231,8 +231,7 @@ Cobra hard-required 是独立的 executable fact,并通过 `cli_required`/prov
| 问题 | 事实源 |
|---|---|
| 当前二进制是否暴露命令、Cobra 接受哪些 flags | `dws <path> --help` |
| Agent 选哪个命令、CLI 参数/required/约束、risk/confirmation | Agent leaf `dws schema "<path>" --compact` |
| CLI↔RPC 参数映射、接口绑定、provenance | full leaf 配合 `--jq` / `--fields` 精确投影 |
| Agent 选哪个命令、参数映射/required/约束、risk/confirmation | 对应 leaf `dws schema "<path>"` |
| 钉钉中的文档、文件、日程、消息等实际数据 | 真正执行 `dws doc read`、`dws drive search` 等 read/search/list 命令 |
Schema 和 Help 冲突是契约漂移,不能静默猜测:
@@ -247,10 +246,10 @@ Schema 和 Help 冲突是契约漂移,不能静默猜测:
```bash
dws schema # 产品紧凑概览
dws schema calendar --compact # Agent 产品摘要
dws schema "calendar event" --compact # Agent 分组摘要
dws schema "calendar event create" --compact # Agent leaf
dws schema "calendar event create" # full leaf,仅用于映射/provenance 审计
dws schema calendar # 产品摘要
dws schema "calendar event" # 分组摘要
dws schema "calendar event create" # 完整 leaf
dws schema "calendar event create" --compact # 支持:裁掉 provenance/debug 字段
dws schema --all # 所有工具的完整 leaf 导出
```
@@ -258,7 +257,7 @@ dws schema --all # 所有工具的完整 leaf 导
`schema --all` 必须包含最终 `SchemaIndex` 中每个 tool 的完整 leaf 参数、约束和安全语义;无业务参数的命令也要包含空 `parameters` 对象。它用于审计、CI 和参数防丢 baseline,但输出很大,普通 Agent 命令发现不得使用,应按 overview -> product/group -> leaf 渐进查询。
`--compact` 是普通 Agent 查询的规范视图:通过正向字段白名单保留选参、约束与安全语义,full 新增字段不会自动进入 Agent 上下文。省略它的 leaf 包含参数 property、接口绑定和 provenance,只用于定向审计;`schema --all --compact` 也可执行,但不能作为完整兼容性 baseline。
`--compact` 当前受支持,适合减少常规 leaf 查询上下文。`schema --all --compact` 也可执行,但会移除 provenance/debug 和接口映射字段,不能作为完整兼容性 baseline。
兼容旧二进制时,如果 Schema 查询返回 `unknown_flag: --compact`,只去掉 `--compact` 重试同一个查询。这是展示能力降级,不代表 leaf 缺失,也不能改用 Schema 查询业务数据。
-105
View File
@@ -1,105 +0,0 @@
# DWS Skill 内容框架合同
> 本分支权威合同:`skills/mono` / `skills/multi` 的**内容组织**与 zip 内容树形状。
> 不做安装/升级行为约定。质检见 [skill-mono-multi-qa.md](skill-mono-multi-qa.md)。
> 对齐调研:[skill-wukong-align-plan.md](skill-wukong-align-plan.md)。
## 1. 两棵内容树
| 树 | 路径 | 角色 |
|---|---|---|
| **mono**(单 skill) | `skills/mono/` | 单一 `SKILL.md` 入口 + `references/products/*` 产品面 + 全局协议 |
| **multi**(多 skill) | `skills/multi/` | 平铺 `dingtalk-*` 产品 skill + 必选 `dingtalk-shared` |
Agent / 安装面选哪棵树由**行为分支**决定;本文件只规定树内合同。
## 2. Multi 目录合同(如何新增一个产品 skill)
新建 `skills/multi/<name>/` 时必须满足:
1. **命名**
- 产品 skill:`dingtalk-<product>`(小写、连字符)
- 共享 skill:仅允许 `dingtalk-shared`
2. **根文件**
- 必有 `SKILL.md`(YAML frontmatter + 正文)
- `references/` 推荐;无 reference 的 skill(如极简 profile)须在质检 omit 表登记
- `scripts/` 可选;脚本须被本 skill 树内某 `.md` 引用,或进入 orphan allowlist
3. **Frontmatter 最小集**(产品 / shared)
- `name`:与目录名一致
- `description`:非空,含触发意图与边界
- `metadata.category`:`product` 或 `shared`(允许历史写法把 `cli_version` 放在 frontmatter 顶层)
- `metadata.requires.bins`:含 `dws`
4. **契约块**
- 产品 skill 推荐内嵌 `<!-- DWS_RUNTIME_CONTRACT_START -->…END -->` **或** 明确 PREREQUISITE 指向 `dingtalk-shared`
- `dingtalk-shared` 承载跨产品路由与全局协议落点
5. **与 mono 映射**
- 每个 mono `references/products/<stem>`(文件或目录)必须在
`skills/content-qa/mono-multi-coverage.yaml` 有 `coverage` 或 `omit_coverage` 行
### 2.1 推荐骨架
```text
skills/multi/dingtalk-example/
├── SKILL.md
├── references/
│ ├── example.md # 主产品面
│ └── … # 子章节 / 意图表
└── scripts/ # 可选;须被 md 引用
└── example_helper.py
```
## 3. Mono 目录合同(质检对照基准)
```text
skills/mono/
├── SKILL.md
├── references/
│ ├── products/ # 覆盖质检主源
│ ├── error-codes.md # 全局协议示例
│ ├── error-codes.md
│ └── …
└── scripts/
```
- `references/products/` 下每个顶层 stem(`.md` 去后缀或子目录名)计入覆盖索引。
- 同 stem 的 `.md` + 子目录视为同一产品面(如 `doc.md` + `doc/`)。
## 4. 共享内容(`dingtalk-shared`)
| 职责 | 落点 |
|---|---|
| 跨产品路由 / 工作流 | `references/routing.md`、`workflow-routing.md`、`intent-guide.md` |
| 运行时最小契约长文 | `references/runtime-contract.md`(受 context-budget 约束) |
| 全局协议(确认门禁 / Schema 教学等) | `references/`;见质检基线 |
| 与 mono 全局文同名迁移 | `error-codes`、`url-patterns`、`capability-limits`、`channel-login`、`global-reference`、`recipes/`(`conventions.md`、`meta.md`、`lite-catalog.md`) |
产品专属规则(如 AI 表格 `field-rules`)允许下沉到对应 `dingtalk-*`,须在覆盖表注明。
## 5. Zip 内容布局合同(形状,非安装默认)
发布物 `dws-skills.zip`(及 embed 同源)内容树形状:
| Zip 路径 | 含义 |
|---|---|
| `<root>/` | mono 内容副本(兼容旧面) |
| `<root>/mono/` | 与 `skills/mono/` 同构 |
| `<root>/multi/` | 与 `skills/multi/` 同构 |
质检可断言源树形状;**不**断言安装器默认解压哪棵。
## 6. 与悟空 `dingtalk-skills/` 对照(组织概念 only)
| 维度 | DWS `skills/multi` | 悟空 `dingtalk-skills/`(develop) |
|---|---|---|
| 布局 | flat `dingtalk-*` + `dingtalk-shared` | 同构 flat |
| 集合 | 产品 skill + shared(含 event/profile/…;dev/skill 等长尾落在 misc) | 更小产品集(如 attendance/report 独立目录) |
| 质检权威 | **mono 单 skill 树** | 不作为 DWS 覆盖基准 |
| 不移植 | `_install.sh` / bundle / dual / Qwen overlay | — |
悟空独有命名(如 `dingtalk-attendance`)在 DWS 中由 `dingtalk-misc` 承接对应 mono `attendance*` / `report` / `oa` / `sheet` / `dev` 等面——见覆盖表。
## 7. 变更流程
1. 改 / 增内容 → 更新 `skills/content-qa/mono-multi-coverage.yaml`(coverage 或 omit)
2. 跑 `make skill-mono-multi-content`(或 `make policy`)
3. 失败则修内容或更新 reviewed omit(disposition + 原因),**禁止**用安装默认值绕过
-65
View File
@@ -1,65 +0,0 @@
# Mono↔Multi Skill 内容质检规格
> 对照基准:`skills/mono`(单 skill)。被测主体:`skills/multi`。
> 机读合同:`skills/content-qa/mono-multi-coverage.yaml`。
> 执行:`make skill-mono-multi-content`(已挂入 `make policy`)。
## 1. 质检矩阵
| ID | 类型 | 输入 | 通过准则 |
|---|---|---|---|
| **G1 形状** | 结构 | `skills/multi/*` | 仅 `dingtalk-*`(含必选 `dingtalk-shared`);每目录有 `SKILL.md` |
| **G2 结构** | 结构 | 各 `SKILL.md` frontmatter | `name`==目录名;非空 `description`;`category`∈{product,shared};`requires.bins` 含 `dws` |
| **G3 覆盖** | 覆盖 | mono `references/products/*` 顶层 stem | 每 stem ∈ `coverage` 或 `omit_coverage`;coverage 目标 skill/refs 存在 |
| **G4 漂移** | 漂移 | scripts、成对文件、全局协议 | orphan 脚本 ∈ allowlist;paired 一致;全局协议存在或 ∈ `omit_global` |
已有门禁(继续复用,不替代本矩阵):`check-skill-commands`、`check-skill-context-budget`、`check-multi-im-skill-chain`、`skill_docs_policy`、whiteboard 成对测试。
## 2. 有意省略 / 延期登记格式
YAML(见 coverage 文件):
```yaml
omit_coverage:
- mono: simple
disposition: covered_by # covered_by | defer | wontfix
via: dingtalk-misc # optional
reason: "拆入 oa/devdoc…"
omit_global:
- id: field-rules-global
mono_path: references/field-rules.md
expected_multi: dingtalk-aitable/references/field-rules.md
disposition: covered_by
reason: "AI 表格字段规则已下沉到 dingtalk-aitable;G3/coverage 不强制全局同名"
orphan_scripts_allowlist:
- path: dingtalk-misc/scripts/report_received_today.py
disposition: defer
reason: "pending report.md reference"
```
**处置原则**:质检失败 → 修**内容**或更新 reviewed omit;**不**改安装/升级默认。
## 3. 缺口基线(相对 mono)
| ID | 项 | disposition | 说明 |
|---|---|---|---|
| M1 | recovery-guide / RECOVERY_EVENT_ID 闭环 | **removed** | 已从 mono/multi skill 文档删除;不做移植 |
| M2 | confirmation_required 全局协议 | **done** | `dingtalk-shared/references/confirmation.md` + SKILL 导航 |
| M3 | Schema 渐进查询教学 | **done** | `dingtalk-shared/references/schema-usage.md` |
| M4 | `report_inbox_today.py` | `defer` / orphan 侧 | 验证后迁 misc 或删 |
| M5 | multi LICENSE/NOTICE | `defer` | 内容或打包注入 |
| M6 | aiapp 路由 vs orphan 脚本 | **done(标明未产品化)** | mono 死链移除;`unsupported-scripts.md` |
| X1 | yida/finance/aiapp orphan scripts | **done(登记)** | 由 unsupported-scripts 具名引用 |
| X2 | chat 死链 `extract_media_id.py` | n/a | 现仅为反模式提及 |
| X3 | routing → markdown 错路径 | **done** | 已指 `dingtalk-misc/references/markdown.md`;drive 尾链已修 |
| X4 | event 缺 metadata | **done** | |
| X5 | multi skill 横幅 /「优先 mono」文案 | **done** | 横幅已全部移除 |
| X6 | SAFETY_PREAMBLE_INJECT 无注入器 | **done** | 标记已移除 |
产品面覆盖:见 YAML `coverage`——mono products 均有 multi 承接(misc 聚合 attendance/oa/sheet/…)。
## 4. 与悟空
借鉴 frontmatter / 断链 / requires 等**检查维度**;不运行悟空 bundle zip 校验脚本。覆盖权威始终是 DWS mono。
-272
View File
@@ -1,272 +0,0 @@
# DWS multi-skill **内容框架**对齐方案(相对 dws-wukong develop)
> 状态:**执行中** — Phase 1–3 已落地;M2/M3 已补;**M1 recovery 闭环已从 skill 删除(不做移植)**。
> 合同短文:[skill-content-framework.md](skill-content-framework.md)
> 质检规格:[skill-mono-multi-qa.md](skill-mono-multi-qa.md)
> 机读合同:`skills/content-qa/mono-multi-coverage.yaml`
> 门禁:`make skill-mono-multi-content`(已入 `make policy`)
>
> 撰写 / 收窄 / 质检增补 / 执行:2026-08-05
> 工作树:`/Users/john/GolandProjects/open-source/dws-multi-skill-align`
> 分支:`feat/multi-skill-framework-align`(自 `origin/main` @ `a37e6e68`)
> **本分支范围:只做 skill 内容的这个框架**(目录布局、文档契约、共享内容约定、zip 内容树合同、**相对 mono 的内容质检**)。
> **不做**安装/升级引擎、agent-home、脚本 skill-install 行为翻转。
>
> 对照仓:
>
> | 仓 | 路径 | 基线 |
> |---|---|---|
> | DWS OSS CLI(本工作树) | `dws-multi-skill-align` | `origin/main` |
> | dws-wukong | `~/GolandProjects/open-source/dws-wukong` | `origin/develop` @ `ab76629a`(调研时) |
> | 行为参考(**另一分支**) | `dws-skill-mode-migration` @ `402429ac`/`d5c8982c` | 安装默认 multi / upgrade 强制 multi —— **不在本分支排期** |
> | 内容缺口留档(参考) | 同迁移分支 `docs/skill-capability-completion.md`(M1–M6 / X1 等) | **仅作质检目标线索**,非本分支权威 |
---
## 0. TL;DR
1. **本分支 = skill 内容框架 + 相对 mono 的内容质检**:固化 `skills/multi` 组织合同,并用 **mono 单 skill 布局作对照基准**做覆盖/结构/漂移门禁(文档 + CI 内容护栏)。
2. **对齐悟空**:只取内容树组织概念;质检以 **DWS-native** 设计为主(已有 policy/测试可复用)。悟空 `validate-multiskill-bundle.py` 仅借鉴「frontmatter / 断链 / requires」类检查思路,**不**移植 bundle/安装校验。
3. **安装/升级行为**与 `402429ac`/`d5c8982c` → **单独 follow-up 分支**,本方案只登记。
4. 质检 **不改**默认安装哪棵树;只保证 multi 内容相对 mono **可解释、可覆盖、可回归**。
### 0.1 IN SCOPE
| 类别 | 包含 |
|---|---|
| 内容树结构 | `skills/mono/` 与 `skills/multi/<name>/` 目录合同 |
| 单 skill 约定 | `SKILL.md` frontmatter / 契约块 / Golden Route;`references/`;可选 `scripts/` |
| 共享内容 | `dingtalk-shared` 职责与被引用方式;与 mono 全局文映射(文档级) |
| 命名与集合 | `dingtalk-*` + `dingtalk-shared`;相对悟空的共有/独有清单(文档) |
| Zip **内容布局合同** | `mono/` / `multi/` / 根 mono 副本的内容含义与树形状;不改安装默认 |
| **Mono↔multi 内容质检** | 覆盖、结构、漂移三类门禁;复用/扩展现有 policy 与测试;缺口修复属内容编辑(另批或同分支内容 Phase) |
| 内容架构文档 | 本文件 + 可选短文(架构合同 + 质检矩阵) |
### 0.2 OUT OF SCOPE
| 类别 | 去向 |
|---|---|
| 安装默认 multi、upgrade always-multi | Follow-up 分支(`402429ac`/`d5c8982c`) |
| `LocateSkillsRoot` / `skill_setup` / `paths.go` / `skillhome` / install 脚本行为 | 同上 |
| 安装/运行时 manifest、state.json、mode 切换、telemetry header | 拒绝或行为分支 |
| 悟空 `_install.sh` / dual / Qwen / RewindDesktop / pod | 拒绝 |
| 非 skill 内容的 CLI 功能(schema/shortcut 代码等) | 拒绝 |
| 把质检做成「改安装默认值」的后门 | 拒绝 |
---
## 1. 内容现状盘点
### 1.1 DWS `skills/mono`(质检对照基准 · 单 skill)
```text
skills/mono/
├── SKILL.md
├── references/
│ ├── products/<area>.md|…/ # 产品能力面(质检「覆盖」主源)
│ ├── error-codes.md、… # 全局协议(无 recovery 闭环)
│ └── best_practices/…
└── scripts/
```
### 1.2 DWS `skills/multi`(内容主体)
```text
skills/multi/
├── dingtalk-shared/ # 跨产品契约 / routing / 全局协议应落点
└── dingtalk-*/ # 19 产品 + 各 references、scripts
```
仅 DWS 有(悟空无):dev, event, hrbrain, markdown, pat, profile, skill。
### 1.3 悟空 `dingtalk-skills/`(内容组织对照,非质检权威)
Flat `dingtalk-*` + `dingtalk-shared`;单 skill 骨架同构。**不作为 mono 覆盖基准**(集合更小、不同源)。
### 1.4 Zip 内容布局合同
| Zip 路径 | 内容含义 |
|---|---|
| `<root>/` | mono 副本(兼容) |
| `<root>/mono/` | 显式 mono 内容源 |
| `<root>/multi/` | 与 `skills/multi/` 同构 |
质检可断言「源树形状」;**不**断言安装面默认选哪棵。
### 1.5 现有 DWS skill 内容质检资产(复用清单)
| 资产 | 作用 | 与 mono↔multi 质检关系 |
|---|---|---|
| `scripts/policy/check-skill-commands.sh` + `skill-command-check/` | Skill 文内 `dws …` 命令路径存在性 | **复用**(命令真实性);非覆盖映射 |
| `scripts/policy/check-skill-context-budget.sh` | chat/event/mono/`dingtalk-shared` 上下文预算与冷启动约束 | **复用**(结构/预算);可扩展 shared 引用规则 |
| `scripts/policy/check-multi-im-skill-chain.sh` + `multi-im-skill-chain/` | IM 意图单默认路由、retired scripts、handoff | **复用**(chat/event 链);面窄 |
| `test/unit/skill_docs_policy_test.go` | 退役命令、event 扁平输出契约等 | **复用**;可加 mono↔multi 断言 |
| `test/unit/whiteboard_skill_docs_test.go` | mono/multi whiteboard recipes **字节一致** | **样板**:产品面「同源文件」门禁范式 |
| `test/skill_static`(`-tags skill_verify`) | 文内命令 vs Cobra;multi 查 flag | **复用**(opt-in 深度);非 CI 默认全量时可保持 tags |
| `test/skill_e2e` / `test/run_skill_tests.py` | 执行层 / 用例驱动 | **偏行为**;本分支质检默认不依赖 e2e |
| `Makefile` → `policy` 含 context-budget、multi-im-skill-chain;`skill-command-integrity` 独立 | 已有 CI 钩子 | 新门禁优先挂同类 policy / `test/unit` |
**缺口(尚无的门禁)**:系统的「mono `references/products/*` → multi 目录/文」覆盖表;frontmatter 全集完备性;orphan scripts。全局协议中 **确认门禁 / Schema 教学已补**;**recovery 闭环已从 skill 移除(不再作为缺口)**。
### 1.6 悟空侧类比质检
| 悟空 | 说明 | 本分支 |
|---|---|---|
| `scripts/validate-multiskill-bundle.py` | 校验 **已打好的 bundle zip**:frontmatter keys/category、`requires`、markdown 断链、scenario 编排 | **Adapt 思路** → DWS 源树(`skills/multi` + 对照 mono),不跑 zip 安装语义 |
| `sync-monolith-to-multiskill.py` | mono→multi 派生 | **不**作默认质检手段;DWS 直接维护 multi |
结论:**DWS-native mono↔multi 质检**;悟空仅参考检查维度。
---
## 2. Diff(内容组织 + 质检视角)
### 2.1 已同构
Flat `dingtalk-*` + `dingtalk-shared`;`SKILL.md` + `references/`(+ 可选 `scripts/`)。
### 2.2 分叉与已知内容风险(质检要盯的)
| 风险 ID | 现象(线索) | 质检类型 |
|---|---|---|
| **C-cov** | mono `products/*` 能力面在 multi 无对应 skill/reference,或未登记「有意省略」 | 覆盖 |
| **C-struct** | multi 缺 frontmatter 字段、`references/`、`DWS_RUNTIME_CONTRACT`、对 `dingtalk-shared` 引用不一致 | 结构 |
| **C-drift-global** | 曾关注 recovery / 确认 / Schema;现确认与 Schema 已在 `dingtalk-shared`,**recovery skill 文档已删除** | 漂移(协议) |
| **C-drift-orphan** | multi(或 mono)scripts/refs 无文档引用;或 routing 指向无索引产品(留档 X1/M6) | 漂移(孤儿) |
| **C-pair** | 应对齐的成对文件(如 whiteboard recipes)内容不一致 | 漂移(成对) |
### 2.3 Reject
悟空安装包校验整文件照搬、内容集 19→12 砍产品、安装行为门禁冒充内容质检。
---
## 3. Goals / Non-goals
### 3.1 Goals
1. 固化 multi **内容目录合同**与 mono↔multi **映射说明**。
2. 建立 **质检矩阵**(覆盖 / 结构 / 漂移)并以 mono 为对照基准;有意省略必须 reviewed 登记。
3. **复用** §1.5 资产;新增门禁走 `scripts/policy` 或 `test/unit`,内容-only。
4. (可选)纯内容元数据;**禁止**被安装引擎读取改行为。
5. 质检失败 → 修 **内容**或更新「有意省略」表,不改 setup/upgrade。
### 3.2 Non-goals
安装/升级翻转;cherry-pick 行为提交;取消产品;悟空客户端;非 skill CLI 功能;用质检驱动默认 multi 安装。
---
## 4. 分期(内容框架 + 质检 · 均无安装引擎)
> 批准前 **零编码**(含不实现新 gates)。**已执行**:Phase 1–3 见文首状态。
### Phase 0 — 方案冻结(本文)
| | |
|---|---|
| **范围** | 本文件;§7(含质检轨)勾选 |
| **验收** | owner 重新批准 → ✅「现在开始执行」 |
### Phase 1 — Multi 内容目录合同 + 架构短文 ✅
| | |
|---|---|
| **范围** | `skills/multi` 目录合同;与悟空内容树对照表;zip `multi/` 同构合同 |
| **触达** | `docs/skill-content-framework.md` |
| **验收** | 可指导「如何新增 dingtalk-* 内容目录」 |
### Phase 2 — Mono↔multi **内容质检规格**(矩阵 + 缺口基线) ✅
| | |
|---|---|
| **范围** | 质检规格 + 覆盖/omit 机读表 + 缺口 disposition |
| **触达** | `docs/skill-mono-multi-qa.md`、`skills/content-qa/mono-multi-coverage.yaml` |
| **验收** | 矩阵可人工抽查;缺口均有 disposition |
### Phase 3 — 质检落地:CI 内容护栏(复用 + 新 gate) ✅
| | |
|---|---|
| **范围** | G1–G4 自动门禁 |
| **触达** | `test/unit/mono_multi_skill_content_test.go`、`scripts/policy/check-mono-multi-skill-content.sh`、`Makefile` |
| **验收** | `make skill-mono-multi-content` 绿;已知缺口走 reviewed omit |
### Phase 4 — 可选:内容包元数据 + 缺口修复波次
| | |
|---|---|
| **范围 A** | 纯内容 layout/skill 列表元数据(人不读安装器) |
| **范围 B** | 按 Phase 2 disposition **修内容**:确认 / Schema 已补;**recovery skill 文档已删除(wontfix 移植)**;orphan 脚本仍走 allowlist(M4 等) |
| **验收** | 元数据不驱动安装;修复项关闭对应质检失败或转入 omit |
### 延期登记(非本分支)
| 主题 | 载体 |
|---|---|
| 默认 multi + upgrade always-multi | 行为分支 ← `402429ac`/`d5c8982c` |
| skillhome / 安装面 bootstrap | 行为分支 |
---
## 5. Port / Adapt / Reject
| 项 | 决策 | 说明 |
|---|---|---|
| flat + `dingtalk-shared` 内容模型 | **Port** | 已有;合同 + 质检加固 |
| 悟空 bundle frontmatter/断链/requires 检查维度 | **Adapt** | 做成 DWS 源树门禁,不校验 bundle zip/安装 |
| whiteboard 式 mono/multi 成对一致 | **Port(范式)** | 推广到 reviewed 文件对 |
| `validate-multiskill-bundle.py` 整脚本 | **Reject** | 绑定悟空 zip/Qwen 语义 |
| `_install.sh` / dual / overlay | **Reject** | 非内容 |
| 行为 cherry-pick | **Defer** | 另分支 |
---
## 6. 与 `402429ac` / `d5c8982c`
| | |
|---|---|
| 本分支 cherry-pick? | **否** |
| 质检是否替代行为翻转? | **否** |
| 行为分支 | 另开;可与内容/质检并行 |
---
## 7. 批准清单(请重新勾选)
**范围**
- [x] 本分支 = skill **内容**框架 + **mono↔multi 内容质检**(§0.1);无安装/升级引擎
- [x] `402429ac`/`d5c8982c` 及 setup/paths/install 脚本行为 **不在本分支**
- [x] 取消产品与悟空客户端链路仍拒绝
**内容框架 Phase**
- [x] **Phase 1**:multi 目录合同 + 悟空内容树对照短文
**质检轨 Phase**
- [x] **Phase 2**:质检矩阵 + mono↔multi 覆盖/缺口基线规格(先文档,可执行)
- [x] **Phase 3**:CI 内容护栏(G1–G4)—— 本迭代做 / 拆 PR / 只要规格暂不落地
- [x] 质检失败处置原则:修内容或 reviewed omit,**不**改安装默认
**可选**
- [ ] **Phase 4A** 纯内容元数据:做 / 不做 / 以后
- [x] **Phase 4B** recovery skill 文档 **removed/wontfix**;确认/Schema 已补;剩余 orphan(M4 等)仍 defer / allowlist
**Follow-up 知悉**
- [ ] 安装默认 multi + upgrade always-multi → **另一分支**
---
## 8. 下一步
**Phase 1–3 已落地**(合同短文 + 质检规格 + `skills/content-qa` + CI 门禁)。
Phase 4B:recovery 已删除(不做移植);确认/Schema 已补。剩余 defer:orphan scripts(M4 等)、LICENSE/NOTICE(M5)、Phase 4A 元数据。
安装默认 multi 等行为仍走 **另一分支**。
---
*锚点:`skills/mono`、`skills/multi`、§1.5 policy/测试、wukong `dingtalk-skills/`(组织对照 only)。*
+9 -7
View File
@@ -335,6 +335,12 @@ func TestCrossPlatformCoverageOverlayRecoveryHostAndHelperRemainingCoverage(t *t
edition.Override(&edition.Hooks{ConfigDir: func() string { return "" }})
captureRuntimeFailure(executor.Invocation{}, nil, nil)
captureRuntimeFailure(executor.Invocation{}, errors.New("raw"), nil)
oldArgs := os.Args
os.Args = []string{"dws", "doc", "download", "--node", "n"}
if got := runtimeCommandPath(executor.Invocation{}); len(got) != 2 {
t.Fatalf("runtime command path = %#v", got)
}
os.Args = oldArgs
t.Setenv(authpkg.AgentCodeEnv, "")
if hostControlProviderFromEnv() != "" {
@@ -354,10 +360,6 @@ func TestCrossPlatformCoverageOverlayRecoveryHostAndHelperRemainingCoverage(t *t
func TestCrossPlatformCoverageConfigAndCacheCommandRemainingCoverage(t *testing.T) {
for _, command := range []*cobra.Command{newConfigCommand(), newCacheCommand()} {
command.SetOut(io.Discard)
rootWrap := &cobra.Command{Use: "dws"}
rootWrap.PersistentFlags().String("format", "json", "")
rootWrap.AddCommand(command)
command.SetOut(io.Discard)
if err := command.RunE(command, nil); err != nil {
t.Fatal(err)
@@ -387,18 +389,18 @@ func TestCrossPlatformCoverageConfigAndCacheCommandRemainingCoverage(t *testing.
for _, format := range []string{"json", "pretty", "table"} {
_ = cacheRoot.PersistentFlags().Set("format", format)
cacheCmd.SetOut(io.Discard)
if err := printCacheCompatNotice(cacheCmd, "dws cache status"); err != nil {
if err := printCacheCompatNotice(cacheCmd, "status"); err != nil {
t.Fatal(err)
}
}
fail := errors.New("write")
cacheCmd.SetOut(appFailWriter{err: fail})
_ = cacheRoot.PersistentFlags().Set("format", "pretty")
if err := printCacheCompatNotice(cacheCmd, "dws cache status"); !errors.Is(err, fail) {
if err := printCacheCompatNotice(cacheCmd, "status"); !errors.Is(err, fail) {
t.Fatalf("pretty write error = %v", err)
}
_ = cacheRoot.PersistentFlags().Set("format", "table")
if err := printCacheCompatNotice(cacheCmd, "dws cache status"); !errors.Is(err, fail) {
if err := printCacheCompatNotice(cacheCmd, "status"); !errors.Is(err, fail) {
t.Fatalf("table write error = %v", err)
}
}
+11 -22
View File
@@ -21,10 +21,6 @@ import (
"github.com/spf13/cobra"
)
const cacheUnsupportedMessage = "dws cache 不再支持:服务发现已下线,当前版本使用编译期静态端点目录;dws cache 仅保留为兼容入口,不会刷新端点。"
const cacheReplacementHint = "如遇 endpoint_not_resolved,请先执行 dws upgrade 获取包含最新 internal/syncdata 端点的版本;仍失败时检查 internal/syncdata.StaticServers() 是否覆盖目标 product/server。"
type cacheCompatNotice struct {
Status string `json:"status"`
Command string `json:"command"`
@@ -32,32 +28,24 @@ type cacheCompatNotice struct {
Replacement string `json:"replacement,omitempty"`
}
// newCacheCommand keeps a visible Deprecated compatibility surface for
// historical argv (refresh/status/clean). Behavior is a successful no-op notice.
// Skills must not teach this path. Deprecated leaves are excluded from Schema
// via cobra.IsAvailableCommand() — do not add schema_command_exclusions entries.
func newCacheCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "cache",
Short: "不再支持:服务发现缓存兼容入口",
Long: "此命令组仅为历史 argv 兼容保留。静态端点模式下无需服务发现缓存;Skill / Agent 请勿引导此路径。",
Deprecated: "不再支持;" + cacheUnsupportedMessage,
Args: cobra.NoArgs,
Short: "服务发现缓存兼容入口(静态端点模式已弃用)",
Hidden: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return printCacheCompatNotice(cmd, "dws cache")
return cmd.Help()
},
}
for _, name := range []string{"refresh", "status", "clean"} {
subName := name
sub := &cobra.Command{
Use: subName,
Short: "不再支持:静态端点模式无需服务发现缓存",
Deprecated: "不再支持;" + cacheUnsupportedMessage,
Use: name,
Short: "已弃用:静态端点模式无需服务发现缓存",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return printCacheCompatNotice(cmd, "dws cache "+subName)
return printCacheCompatNotice(cmd, name)
},
}
cmd.AddCommand(sub)
@@ -68,9 +56,9 @@ func newCacheCommand() *cobra.Command {
func printCacheCompatNotice(cmd *cobra.Command, command string) error {
notice := cacheCompatNotice{
Status: "deprecated",
Command: command,
Message: cacheUnsupportedMessage,
Replacement: cacheReplacementHint,
Command: "dws cache " + command,
Message: "服务发现已下线,当前版本使用编译期静态端点目录;dws cache 仅保留为兼容入口,不会刷新端点。",
Replacement: "如遇 endpoint_not_resolved,请先执行 dws upgrade 获取包含最新 internal/syncdata 端点的版本;仍失败时检查 internal/syncdata.StaticServers() 是否覆盖目标 product/server。",
}
format, _ := cmd.Root().PersistentFlags().GetString("format")
switch strings.ToLower(strings.TrimSpace(format)) {
@@ -78,7 +66,8 @@ func printCacheCompatNotice(cmd *cobra.Command, command string) error {
return json.NewEncoder(cmd.OutOrStdout()).Encode(notice)
case "pretty":
data, _ := json.MarshalIndent(notice, "", " ")
_, err := fmt.Fprintln(cmd.OutOrStdout(), string(data))
var err error
_, err = fmt.Fprintln(cmd.OutOrStdout(), string(data))
return err
default:
_, err := fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n%s\n", notice.Command, notice.Message, notice.Replacement)
-111
View File
@@ -1,111 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"bytes"
"strings"
"testing"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageCacheDeprecatedCompatShim(t *testing.T) {
root := NewRootCommand()
group := mustFindCommand(t, root, "cache")
if group.Hidden || group.Deprecated == "" || !group.Runnable() {
t.Fatalf("cache group contract: hidden=%v deprecated=%q runnable=%v", group.Hidden, group.Deprecated, group.Runnable())
}
if group.IsAvailableCommand() {
t.Fatal("deprecated cache group must not be IsAvailableCommand")
}
for _, leaf := range []string{"refresh", "status", "clean"} {
cmd := mustFindCommand(t, root, "cache", leaf)
if cmd.Hidden || cmd.Deprecated == "" || !cmd.Runnable() {
t.Fatalf("cache %s contract: hidden=%v deprecated=%q runnable=%v", leaf, cmd.Hidden, cmd.Deprecated, cmd.Runnable())
}
if cmd.IsAvailableCommand() {
t.Fatalf("deprecated cache %s must not be IsAvailableCommand", leaf)
}
}
var out bytes.Buffer
cmd := NewRootCommand()
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"cache", "refresh", "--format", "json"})
if err := cmd.Execute(); err != nil {
t.Fatalf("cache refresh compatibility stub: %v\n%s", err, out.String())
}
got := out.String()
for _, want := range []string{`"status":"deprecated"`, `"command":"dws cache refresh"`, "不再支持", "服务发现已下线"} {
if !strings.Contains(got, want) {
t.Fatalf("cache refresh output missing %q:\n%s", want, got)
}
}
for _, format := range []string{"", "json", "pretty", "table"} {
var buf bytes.Buffer
parent := &cobra.Command{Use: "dws"}
parent.PersistentFlags().String("format", format, "")
parent.SetOut(&buf)
sub := &cobra.Command{Use: "cache"}
parent.AddCommand(sub)
if err := printCacheCompatNotice(sub, "dws cache status"); err != nil {
t.Fatalf("format=%q: %v", format, err)
}
text := buf.String()
if !strings.Contains(text, "不再支持") && !strings.Contains(text, "服务发现已下线") {
t.Fatalf("format=%q missing notice:\n%s", format, text)
}
if format == "" || format == "json" || format == "pretty" {
if !strings.Contains(text, `"status":"deprecated"`) && !strings.Contains(text, `"status": "deprecated"`) {
t.Fatalf("format=%q missing deprecated JSON status:\n%s", format, text)
}
}
}
for _, format := range []string{"pretty", "table"} {
parent := &cobra.Command{Use: "dws"}
parent.PersistentFlags().String("format", format, "")
parent.SetOut(failWriter{})
sub := &cobra.Command{Use: "cache"}
parent.AddCommand(sub)
if err := printCacheCompatNotice(sub, "dws cache clean"); err == nil || !strings.Contains(err.Error(), "write failed") {
t.Fatalf("format=%q write failure = %v, want write failed", format, err)
}
}
parent := newCacheCommand()
var parentOut bytes.Buffer
rootWrap := &cobra.Command{Use: "dws"}
rootWrap.PersistentFlags().String("format", "json", "")
rootWrap.SetOut(&parentOut)
rootWrap.AddCommand(parent)
parent.SetOut(&parentOut)
if err := parent.RunE(parent, nil); err != nil {
t.Fatalf("cache parent RunE = %v, want nil success", err)
}
if !strings.Contains(parentOut.String(), `"command":"dws cache"`) {
t.Fatalf("cache parent notice missing command:\n%s", parentOut.String())
}
cache := newCacheCommand()
cache.SetOut(&bytes.Buffer{})
cache.SetArgs([]string{"status"})
if err := cache.Execute(); err != nil {
t.Fatal(err)
}
}
+173 -7
View File
@@ -32,6 +32,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/safety"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
upgradepkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
@@ -226,6 +227,115 @@ func TestCrossPlatformCoverageDocDownloadPureCoverage(t *testing.T) {
}
}
func TestCrossPlatformCoverageRecoveryPureCoverage(t *testing.T) {
if _, err := decodeRecoveryAttempts(nil, nil, "", ""); err != nil {
t.Fatal(err)
}
if _, err := decodeRecoveryAttempts(json.RawMessage("null"), nil, "", ""); err != nil {
t.Fatal(err)
}
if got, err := decodeRecoveryAttempts(json.RawMessage(`[{"command_summary":"one"}]`), nil, "", ""); err != nil || len(got) != 1 {
t.Fatalf("array attempts = %#v %v", got, err)
}
if _, err := decodeRecoveryAttempts(json.RawMessage("{"), nil, "", ""); err == nil {
t.Fatal("malformed attempts succeeded")
}
if got, err := decodeRecoveryAttempts(json.RawMessage("2"), []string{"a"}, "ok", ""); err != nil || len(got) != 2 {
t.Fatalf("legacy attempts = %#v %v", got, err)
}
if legacyRecoveryAttempts(0, nil, "", "") != nil || len(legacyRecoveryAttempts(1, nil, "", "")) != 1 {
t.Fatal("legacy attempts edge mismatch")
}
for _, args := range [][]string{
{"dws", "--debug", "doc", "get", "--node", "n"},
{"dws", "--format=json", "doc", "--", "ignored"},
{"dws", "--unknown", "value", "doc"},
} {
old := os.Args
os.Args = args
_ = currentCommandPath()
os.Args = old
}
for _, inv := range []executor.Invocation{
{LegacyPath: "legacy path"},
{CanonicalProduct: "doc", Tool: "get"},
{CanonicalProduct: "doc"},
{},
} {
old := os.Args
os.Args = []string{"dws"}
_ = runtimeCommandPath(inv)
os.Args = old
}
if cloneRecoveryArgs(nil) != nil {
t.Fatal("empty recovery args should clone to nil")
}
original := map[string]any{"x": 1}
clone := cloneRecoveryArgs(original)
clone["x"] = 2
if original["x"] != 1 {
t.Fatal("recovery args were not cloned")
}
if got, _ := (*recoveryRuntime)(nil).Search(context.Background(), "query", recovery.RecoveryContext{}); got.DocSearch.Status != "skipped" {
t.Fatalf("nil recovery search = %#v", got)
}
if got, _ := (&recoveryRuntime{}).Search(context.Background(), " ", recovery.RecoveryContext{}); got.DocSearch.Status != "skipped" {
t.Fatalf("blank recovery search = %#v", got)
}
if _, err := (*recoveryRuntime)(nil).CallToolDirect(context.Background(), "x", "y", nil); err == nil {
t.Fatal("nil recovery runtime call succeeded")
}
if _, err := (&recoveryRuntime{}).resolveEndpoint(context.Background(), "missing", "tool"); err == nil || !strings.Contains(err.Error(), `endpoint not resolved for product "missing" (tool "tool")`) {
t.Fatalf("missing recovery endpoint error = %v", err)
} else {
var apiErr *apperrors.Error
if !errors.As(err, &apiErr) || apiErr.Category != apperrors.CategoryAPI || apiErr.Operation != "discovery.resolve" || apiErr.Reason != "endpoint_not_resolved" {
t.Fatalf("missing recovery endpoint classification = %#v", err)
}
}
t.Setenv("DINGTALK_OK_MCP_URL", " https://catalog.test ")
runtime := &recoveryRuntime{}
if got, err := runtime.resolveEndpoint(context.Background(), "ok", "tool"); err != nil || got != "https://catalog.test" {
t.Fatalf("recovery endpoint override = %q %v", got, err)
}
if recoveryRuntimeToken(nil) != "" || recoveryRuntimeToken(&GlobalFlags{Token: " token "}) != "token" {
t.Fatal("recovery token mismatch")
}
if toRecoveryToolResponse(nil) != nil {
t.Fatal("nil recovery response should stay nil")
}
response := toRecoveryToolResponse(&transport.ToolCallResult{IsError: true, Blocks: []transport.ContentBlock{{Type: "text", Text: "body"}}})
if response == nil || !response.IsError || len(response.Content) != 1 {
t.Fatalf("recovery response = %#v", response)
}
items := []any{map[string]any{"title": "A", "url": "u", "desc": "d"}, "skip", map[string]any{}}
for _, payload := range []map[string]any{
nil,
{"items": items},
{"data": map[string]any{"items": items}},
{"result": map[string]any{"items": items}},
} {
_ = parseDocSearchItemsFromMap(payload)
}
if toDocSearchItems("bad") != nil {
t.Fatal("non-list doc items accepted")
}
result := &transport.ToolCallResult{Content: map[string]any{}, Blocks: []transport.ContentBlock{{Text: "{"}, {Text: `{"items":[{"title":"B"}]}`}}}
if got := parseDocSearchItems(result); len(got) != 1 {
t.Fatalf("block doc items = %#v", got)
}
if parseDocSearchItems(nil) != nil {
t.Fatal("nil doc result should be nil")
}
searchItems := []recovery.DocSearchItem{{Title: "query", URL: "u"}, {Title: "other"}, {Title: "third"}, {Title: "fourth"}}
if len(rerankDocSearchHits("query", recovery.RecoveryContext{ToolName: "tool", CommandPath: []string{"doc"}}, searchItems)) != 3 || rerankDocSearchHits("", recovery.RecoveryContext{}, nil) != nil {
t.Fatal("doc search reranking mismatch")
}
}
func TestCrossPlatformCoverageSmallAppRegistryAndRootCoverage(t *testing.T) {
RegisterPluginAuth("coverage-registry", &PluginAuth{Token: "token"})
t.Cleanup(func() {
@@ -401,6 +511,59 @@ func TestCrossPlatformCoverageDirectRuntimeCoverage(t *testing.T) {
_ = defaultPATMCPEndpoint()
}
func TestCrossPlatformCoverageRecoveryLoadExecutionCoverage(t *testing.T) {
if _, err := loadRecoveryExecution(filepath.Join(t.TempDir(), "missing")); err == nil {
t.Fatal("missing recovery execution succeeded")
}
path := filepath.Join(t.TempDir(), "execution.json")
if err := os.WriteFile(path, []byte("{"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := loadRecoveryExecution(path); err == nil {
t.Fatal("malformed recovery execution succeeded")
}
for name, body := range map[string]string{
"legacy": `{"action":" one ","attempt":2,"result":" ok ","error":" bad "}`,
"modern": `{"actions":["one"],"attempts":[{"command_summary":"one"}],"error_summary":"bad"}`,
} {
t.Run(name, func(t *testing.T) {
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
if got, err := loadRecoveryExecution(path); err != nil || len(got.Actions) != 1 || len(got.Attempts) == 0 {
t.Fatalf("loaded execution = %#v %v", got, err)
}
})
}
}
func TestCrossPlatformCoverageRecoveryRuntimeHTTP(t *testing.T) {
var result map[string]any = map[string]any{"content": []map[string]any{{"type": "text", "text": `{"items":[{"title":"query result","url":"u"}]}`}}}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req struct {
ID int `json:"id"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
_ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": req.ID, "result": result})
}))
defer server.Close()
SetDynamicServers([]mcptypes.ServerDescriptor{{Endpoint: server.URL, CLI: mcptypes.CLIOverlay{ID: "devdoc", Tools: []mcptypes.CLITool{{Name: "search_open_platform_docs_rag"}}}}})
t.Cleanup(func() { SetDynamicServers(nil) })
runtime := &recoveryRuntime{transport: transport.NewClient(server.Client()), flags: &GlobalFlags{Token: "token"}}
got, err := runtime.Search(context.Background(), "query", recovery.RecoveryContext{ToolName: "search"})
if err != nil || got.DocSearch.Status != "success" || len(got.KBHits) == 0 {
t.Fatalf("recovery search = %#v %v", got, err)
}
result = map[string]any{"isError": true, "content": []map[string]any{{"type": "text", "text": "failed"}}}
if _, err := runtime.CallToolDirect(context.Background(), "devdoc", "search_open_platform_docs_rag", nil); err == nil {
t.Fatal("recovery MCP error succeeded")
}
server.Close()
if _, err := runtime.CallToolDirect(context.Background(), "devdoc", "search_open_platform_docs_rag", nil); err == nil {
t.Fatal("recovery network error succeeded")
}
}
func TestCrossPlatformCoverageEventCommandPureCoverage(t *testing.T) {
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
@@ -597,7 +760,7 @@ func TestCrossPlatformCoverageVersionCacheCompletionCoverage(t *testing.T) {
child := &cobra.Command{Use: "child"}
root.AddCommand(child)
child.SetOut(io.Discard)
if err := printCacheCompatNotice(child, "dws cache status"); err != nil {
if err := printCacheCompatNotice(child, "status"); err != nil {
t.Fatalf("cache %s: %v", format, err)
}
root.RemoveCommand(child)
@@ -1584,6 +1747,9 @@ func TestCrossPlatformCoverageDoctorCommandCoverage(t *testing.T) {
}
for _, jsonOut := range []bool{false, true} {
if got := doctorCheckCache(io.Discard, jsonOut); got.Status != statusPass {
t.Fatal("cache check failed")
}
if got := doctorCheckPerf(io.Discard, jsonOut); got.Status != statusPass {
t.Fatalf("perf check = %#v", got)
}
@@ -2002,7 +2168,7 @@ func TestCrossPlatformCoverageSkillSetupRuntimeCoverage(t *testing.T) {
}
_ = os.Symlink(filepath.Join(mono, "SKILL.md"), filepath.Join(mono, "linked.md"))
multi := filepath.Join(t.TempDir(), "multi")
for _, name := range []string{"dingtalk-shared", "dingtalk-a", "dingtalk-b"} {
for _, name := range []string{"dws-shared", "dingtalk-a", "dingtalk-b"} {
dir := filepath.Join(multi, name)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
@@ -2027,10 +2193,10 @@ func TestCrossPlatformCoverageSkillSetupRuntimeCoverage(t *testing.T) {
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dws", "SKILL.md")); err != nil {
t.Fatal(err)
}
if output, _, err := run("--mode", "multi", "--source", multi, "--target", "agents", "--yes", "--skill", "a"); err != nil || !strings.Contains(output, "installed=2") {
t.Fatalf("multi setup = %q, %v", output, err)
if output, warnings, err := run("--mode", "multi", "--source", multi, "--target", "agents", "--yes", "--skill", "a"); err != nil || !strings.Contains(output, "installed=2") || warnings == "" {
t.Fatalf("multi setup = %q / %q, %v", output, warnings, err)
}
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dingtalk-shared", "SKILL.md")); err != nil {
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dws-shared", "SKILL.md")); err != nil {
t.Fatal(err)
}
if output, _, err := run("--mode", "multi", "--source", multi, "--target", "agents", "--yes", "--dry-run", "--exclude", "b"); err != nil || !strings.Contains(output, "DRY-RUN") {
@@ -2054,7 +2220,7 @@ func TestCrossPlatformCoverageSkillSetupRuntimeCoverage(t *testing.T) {
}
func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
all := []string{"dingtalk-a", "dingtalk-b", "dingtalk-shared"}
all := []string{"dingtalk-a", "dingtalk-b", "dws-shared"}
for _, tc := range []struct {
include []string
exclude []string
@@ -2072,7 +2238,7 @@ func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
t.Errorf("filter %#v/%#v = %v", tc.include, tc.exclude, err)
}
}
for _, selected := range [][]string{nil, {"dingtalk-shared"}, {"dingtalk-a"}} {
for _, selected := range [][]string{nil, {"dws-shared"}, {"dingtalk-a"}} {
_ = ensureMandatorySharedSkill(selected, all)
}
_ = ensureMandatorySharedSkill([]string{"dingtalk-a"}, []string{"dingtalk-a"})
+1
View File
@@ -302,6 +302,7 @@ func TestCrossPlatformCoverageRootUtilityAndTimingCoverage(t *testing.T) {
_ = newConfigCommand()
_ = newCacheCommand()
_ = newVersionCommand()
_ = newRecoveryCommand(&GlobalFlags{})
_ = newAPICommand(&GlobalFlags{})
_ = NewRootCommand(context.Background())
}
+22 -1
View File
@@ -61,7 +61,7 @@ func newDoctorCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "doctor",
Short: "环境健康检查",
Long: "一键检查登录态、网络连通性和版本更新,快速定位常见问题。",
Long: "一键检查登录态、网络连通性、缓存状态和版本更新,快速定位常见问题。",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: runDoctor,
@@ -92,6 +92,9 @@ func runDoctor(cmd *cobra.Command, _ []string) error {
networkResult := doctorCheckNetwork(cmd.Context(), w, jsonOut, networkTimeout)
checks = append(checks, networkResult)
cacheResult := doctorCheckCache(w, jsonOut)
checks = append(checks, cacheResult)
versionResult := doctorCheckVersion(w, jsonOut, networkTimeout)
checks = append(checks, versionResult)
@@ -294,6 +297,24 @@ func doctorCheckNetwork(ctx context.Context, w io.Writer, jsonOut bool, timeout
return r
}
// ── Cache check ─────────────────────────────────────────────────────────
func doctorCheckCache(w io.Writer, jsonOut bool) checkResult {
if !jsonOut {
fmt.Fprint(w, tui.Dim("检查缓存状态... "))
}
r := checkResult{
Name: "cache",
Status: statusPass,
Message: "静态端点模式, 无需缓存",
}
if !jsonOut {
printCheckResult(w, r)
}
return r
}
// ── Version check ───────────────────────────────────────────────────────
func doctorCheckVersion(w io.Writer, jsonOut bool, timeout time.Duration) checkResult {
+25
View File
@@ -109,6 +109,31 @@ func TestPrintCheckResultNoHint(t *testing.T) {
}
}
func TestDoctorCheckCacheEmpty(t *testing.T) {
t.Setenv("DWS_CACHE_DIR", t.TempDir())
var buf bytes.Buffer
r := doctorCheckCache(&buf, false)
if r.Status != statusPass {
t.Errorf("expected pass for static endpoint mode, got %s", r.Status)
}
}
func TestDoctorCheckCacheEmptyJSON(t *testing.T) {
t.Setenv("DWS_CACHE_DIR", t.TempDir())
var buf bytes.Buffer
r := doctorCheckCache(&buf, true)
if r.Status != statusPass {
t.Errorf("expected pass for static endpoint mode, got %s", r.Status)
}
if buf.Len() != 0 {
t.Error("expected no output in JSON mode")
}
}
func TestDoctorCheckAuthReportsKeychainUnavailable(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", filepath.Join(t.TempDir(), "config"))
@@ -10,7 +10,7 @@ import (
// TestMultiSkillSharedContractKeepsAccountSafetyRule pins the multi-account
// safety rule that release run 30437390088 found missing: the MultiSkill e2e
// contract asserts the exact phrase below inside the installed
// dingtalk-shared/SKILL.md, so removing it from the embedded skill source must
// dws-shared/SKILL.md, so removing it from the embedded skill source must
// fail at PR time instead of at release time.
func TestMultiSkillSharedContractKeepsAccountSafetyRule(t *testing.T) {
dir, cleanup, err := materializeEmbeddedSkillSource(skillSetupModeMulti)
@@ -19,12 +19,12 @@ func TestMultiSkillSharedContractKeepsAccountSafetyRule(t *testing.T) {
}
t.Cleanup(cleanup)
data, err := os.ReadFile(filepath.Join(dir, "dingtalk-shared", "SKILL.md"))
data, err := os.ReadFile(filepath.Join(dir, "dws-shared", "SKILL.md"))
if err != nil {
t.Fatalf("read embedded dingtalk-shared/SKILL.md: %v", err)
t.Fatalf("read embedded dws-shared/SKILL.md: %v", err)
}
const rule = "禁止选择第一项、最近登录或最近使用账号"
if !strings.Contains(string(data), rule) {
t.Fatalf("embedded dingtalk-shared/SKILL.md lost the mandatory account safety rule %q", rule)
t.Fatalf("embedded dws-shared/SKILL.md lost the mandatory account safety rule %q", rule)
}
}
+14 -14
View File
@@ -33,43 +33,43 @@ func contains(ss []string, want string) bool {
return false
}
// dingtalk-shared must ship even when --skill narrows the set to a single product.
// dws-shared must ship even when --skill narrows the set to a single product.
func TestP1SharedAlwaysIncludedWithSkillFilter(t *testing.T) {
src := writeMultiSkillSrc(t, "dingtalk-shared", "dingtalk-aitable", "dingtalk-calendar")
src := writeMultiSkillSrc(t, "dws-shared", "dingtalk-aitable", "dingtalk-calendar")
all, err := listMultiSkillNames(src)
if err != nil {
t.Fatal(err)
}
if !contains(all, "dingtalk-shared") {
t.Fatalf("listMultiSkillNames did not enumerate dingtalk-shared: %v", all)
if !contains(all, "dws-shared") {
t.Fatalf("listMultiSkillNames did not enumerate dws-shared: %v", all)
}
filtered, err := filterMultiSkillNames(all, []string{"aitable"}, nil)
if err != nil {
t.Fatal(err)
}
if contains(filtered, "dingtalk-shared") {
t.Fatalf("precondition: filter should drop dingtalk-shared for -s aitable: %v", filtered)
if contains(filtered, "dws-shared") {
t.Fatalf("precondition: filter should drop dws-shared for -s aitable: %v", filtered)
}
final := ensureMandatorySharedSkill(filtered, all)
if !contains(final, "dingtalk-shared") {
t.Fatalf("ensureMandatorySharedSkill must re-add dingtalk-shared: %v", final)
if !contains(final, "dws-shared") {
t.Fatalf("ensureMandatorySharedSkill must re-add dws-shared: %v", final)
}
// Actually install with the filtered+mandatory set and assert dingtalk-shared landed.
// Actually install with the filtered+mandatory set and assert dws-shared landed.
dest := t.TempDir()
var out, errOut bytes.Buffer
if _, _, err := installMultiSkillToHomes(src, final, []string{dest}, &out, &errOut); err != nil {
t.Fatalf("install: %v (%s)", err, errOut.String())
}
if _, err := os.Stat(filepath.Join(dest, "dingtalk-shared", "SKILL.md")); err != nil {
t.Fatalf("dingtalk-shared not installed with -s aitable: %v", err)
if _, err := os.Stat(filepath.Join(dest, "dws-shared", "SKILL.md")); err != nil {
t.Fatalf("dws-shared not installed with -s aitable: %v", err)
}
if _, err := os.Stat(filepath.Join(dest, "dingtalk-aitable", "SKILL.md")); err != nil {
t.Fatalf("dingtalk-aitable not installed: %v", err)
}
}
// When the source has no dingtalk-shared (older layout), nothing is forced.
// When the source has no dws-shared (older layout), nothing is forced.
func TestP1SharedNoopWhenAbsent(t *testing.T) {
src := writeMultiSkillSrc(t, "dingtalk-aitable")
all, err := listMultiSkillNames(src)
@@ -77,7 +77,7 @@ func TestP1SharedNoopWhenAbsent(t *testing.T) {
t.Fatal(err)
}
final := ensureMandatorySharedSkill([]string{"dingtalk-aitable"}, all)
if contains(final, "dingtalk-shared") {
t.Fatalf("must not invent dingtalk-shared when source lacks it: %v", final)
if contains(final, "dws-shared") {
t.Fatalf("must not invent dws-shared when source lacks it: %v", final)
}
}
+441 -63
View File
@@ -1,118 +1,496 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"sort"
"strings"
"time"
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/recovery"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/spf13/cobra"
)
const recoveryUnsupportedMessage = "dws recovery 不再支持:失败快照恢复计划/执行/闭环已下线,请改用 doctor / schema / 对应业务命令排查。"
var (
recoverySavePlan = (*recovery.Store).SavePlan
recoverySaveAnalysis = (*recovery.Store).SaveAnalysis
)
type recoveryCompatNotice struct {
Status string `json:"status"`
Command string `json:"command"`
Message string `json:"message"`
}
func newRecoveryCommand(flags *GlobalFlags) *cobra.Command {
var (
planUseLast bool
planEventID string
executeUseLast bool
executeEventID string
finalEventID string
finalOutcome string
executionFile string
)
runtime := newRecoveryRuntime(flags)
// newRecoveryCommand keeps a visible Deprecated compatibility surface for
// historical argv and Interface Integrity. Behavior is unchanged: every leaf
// returns an explicit unsupported notice. Skills must not teach this path.
func newRecoveryCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "recovery",
Short: "不再支持:错误恢复辅助命令(兼容入口)",
Long: "此命令组仅为历史 argv 兼容保留,不再读取失败快照或生成恢复计划。Skill / Agent 请勿引导此路径。",
Deprecated: "不再支持;" + recoveryUnsupportedMessage,
Short: "错误恢复辅助命令",
Long: "读取失败快照,生成恢复分析,并回写恢复结果。",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return printRecoveryUnsupported(cmd, "dws recovery")
return cmd.Help()
},
}
planCmd := &cobra.Command{
Use: "plan",
Short: "不再支持:基于失败快照生成恢复计划",
Deprecated: "不再支持;" + recoveryUnsupportedMessage,
Short: "基于失败快照生成恢复计划",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return printRecoveryUnsupported(cmd, "dws recovery plan")
store := recovery.NewStore(defaultConfigDir())
last, err := loadRecoverySnapshot(store, planUseLast, planEventID)
if err != nil {
return err
}
planner := recovery.NewPlanner(runtime)
plan := planner.PlanWithOptions(cmd.Context(), last.Context, recovery.PlanOptions{
EventID: last.EventID,
EnableDocSearch: true,
})
recovery.HydratePlanForEvent(last.EventID, last.Context, last.Replay, &plan)
if err := recoverySavePlan(store, last.EventID, plan); err != nil {
return fmt.Errorf("保存恢复计划失败: %w", err)
}
payload := map[string]any{
"event_id": last.EventID,
"context": last.Context,
"plan": plan,
}
return output.WriteCommandPayload(cmd, payload, output.FormatJSON)
},
}
planCmd.Flags().Bool("last", false, "旧版兼容参数;recovery 不再支持")
planCmd.Flags().String("event-id", "", "旧版兼容参数;recovery 不再支持")
planCmd.Flags().BoolVar(&planUseLast, "last", false, "读取最近一次失败快照")
planCmd.Flags().StringVar(&planEventID, "event-id", "", "按 event_id 读取失败快照")
executeCmd := &cobra.Command{
Use: "execute",
Short: "不再支持:生成面向 Agent 的恢复分析包",
Deprecated: "不再支持;" + recoveryUnsupportedMessage,
Short: "生成面向 Agent 的恢复分析包",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return printRecoveryUnsupported(cmd, "dws recovery execute")
store := recovery.NewStore(defaultConfigDir())
last, err := loadRecoverySnapshot(store, executeUseLast, executeEventID)
if err != nil {
return err
}
planner := recovery.NewPlanner(runtime)
executor := recovery.NewExecutor(planner, runtime)
bundle := executor.Execute(cmd.Context(), *last)
if err := recoverySaveAnalysis(store, last.EventID, bundle.Plan, bundle); err != nil {
return fmt.Errorf("保存恢复分析失败: %w", err)
}
return output.WriteCommandPayload(cmd, bundle, output.FormatJSON)
},
}
executeCmd.Flags().Bool("last", false, "旧版兼容参数;recovery 不再支持")
executeCmd.Flags().String("event-id", "", "旧版兼容参数;recovery 不再支持")
executeCmd.Flags().BoolVar(&executeUseLast, "last", false, "读取最近一次失败快照")
executeCmd.Flags().StringVar(&executeEventID, "event-id", "", "按 event_id 读取失败快照")
finalizeCmd := &cobra.Command{
Use: "finalize",
Short: "不再支持:回写恢复闭环结果",
Deprecated: "不再支持;" + recoveryUnsupportedMessage,
Short: "回写恢复闭环结果",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return printRecoveryUnsupported(cmd, "dws recovery finalize")
if strings.TrimSpace(finalEventID) == "" {
return fmt.Errorf("必须提供 --event-id")
}
if strings.TrimSpace(finalOutcome) == "" {
return fmt.Errorf("必须提供 --outcome")
}
switch finalOutcome {
case "recovered", "failed", "handoff":
default:
return fmt.Errorf("--outcome 仅支持 recovered|failed|handoff")
}
store := recovery.NewStore(defaultConfigDir())
var execution *recovery.RecoveryExecution
if strings.TrimSpace(executionFile) != "" {
loaded, err := loadRecoveryExecution(executionFile)
if err != nil {
return err
}
execution = &loaded
}
if err := store.Finalize(finalEventID, finalOutcome, execution); err != nil {
return fmt.Errorf("回写恢复结果失败: %w", err)
}
payload := map[string]any{
"event_id": finalEventID,
"outcome": finalOutcome,
"success": true,
}
if execution != nil {
payload["execution_recorded"] = true
}
return output.WriteCommandPayload(cmd, payload, output.FormatJSON)
},
}
finalizeCmd.Flags().String("event-id", "", "旧版兼容参数;recovery 不再支持")
finalizeCmd.Flags().String("outcome", "", "旧版兼容参数;recovery 不再支持")
finalizeCmd.Flags().String("execution-file", "", "旧版兼容参数;recovery 不再支持")
finalizeCmd.Flags().StringVar(&finalEventID, "event-id", "", "恢复事件 ID")
finalizeCmd.Flags().StringVar(&finalOutcome, "outcome", "", "恢复结果: recovered|failed|handoff")
finalizeCmd.Flags().StringVar(&executionFile, "execution-file", "", "Agent 执行详情 JSON 文件")
cmd.AddCommand(planCmd, executeCmd, finalizeCmd)
return cmd
}
func printRecoveryUnsupported(cmd *cobra.Command, command string) error {
notice := recoveryCompatNotice{
Status: "unsupported",
Command: command,
Message: recoveryUnsupportedMessage,
func loadRecoverySnapshot(store *recovery.Store, useLast bool, eventID string) (*recovery.LastError, error) {
if useLast && strings.TrimSpace(eventID) != "" {
return nil, fmt.Errorf("--last 和 --event-id 不能同时使用")
}
format, _ := cmd.Root().PersistentFlags().GetString("format")
switch strings.ToLower(strings.TrimSpace(format)) {
case "", "json":
if err := json.NewEncoder(cmd.OutOrStdout()).Encode(notice); err != nil {
return err
switch {
case useLast:
last, err := store.LoadLastError()
if err != nil {
return nil, fmt.Errorf("读取失败快照失败: %w", err)
}
return apperrors.NewValidation(recoveryUnsupportedMessage)
case "pretty":
data, _ := json.MarshalIndent(notice, "", " ")
if _, err := fmt.Fprintln(cmd.OutOrStdout(), string(data)); err != nil {
return err
return last, nil
case strings.TrimSpace(eventID) != "":
last, err := store.LoadErrorByEvent(strings.TrimSpace(eventID))
if err != nil {
return nil, fmt.Errorf("读取失败快照失败: %w", err)
}
return apperrors.NewValidation(recoveryUnsupportedMessage)
return last, nil
default:
if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n", notice.Command, notice.Message); err != nil {
return err
}
return apperrors.NewValidation(recoveryUnsupportedMessage)
return nil, fmt.Errorf("必须通过 --last 或 --event-id 指定失败快照")
}
}
func loadRecoveryExecution(path string) (recovery.RecoveryExecution, error) {
var execution recovery.RecoveryExecution
data, err := os.ReadFile(path)
if err != nil {
return execution, fmt.Errorf("读取恢复执行详情失败: %w", err)
}
var payload recoveryExecutionPayload
if err := json.Unmarshal(data, &payload); err != nil {
return execution, fmt.Errorf("解析恢复执行详情失败: %w", err)
}
execution.Actions = append([]string(nil), payload.Actions...)
if len(execution.Actions) == 0 && strings.TrimSpace(payload.Action) != "" {
execution.Actions = []string{strings.TrimSpace(payload.Action)}
}
execution.Result = strings.TrimSpace(payload.Result)
execution.ErrorSummary = strings.TrimSpace(payload.ErrorSummary)
if execution.ErrorSummary == "" {
execution.ErrorSummary = strings.TrimSpace(payload.Error)
}
attempts, err := decodeRecoveryAttempts(payload.Attempts, execution.Actions, execution.Result, execution.ErrorSummary)
if err != nil {
return execution, fmt.Errorf("解析恢复执行详情失败: %w", err)
}
if len(attempts) == 0 && payload.Attempt > 0 {
attempts = legacyRecoveryAttempts(payload.Attempt, execution.Actions, execution.Result, execution.ErrorSummary)
}
execution.Attempts = attempts
return execution, nil
}
type recoveryExecutionPayload struct {
Action string `json:"action,omitempty"`
Actions []string `json:"actions,omitempty"`
Attempt int `json:"attempt,omitempty"`
Attempts json.RawMessage `json:"attempts,omitempty"`
Result string `json:"result,omitempty"`
Error string `json:"error,omitempty"`
ErrorSummary string `json:"error_summary,omitempty"`
}
func decodeRecoveryAttempts(raw json.RawMessage, actions []string, result, errorSummary string) ([]recovery.RecoveryAttempt, error) {
trimmed := strings.TrimSpace(string(raw))
if trimmed == "" || trimmed == "null" {
return nil, nil
}
if strings.HasPrefix(trimmed, "[") {
var attempts []recovery.RecoveryAttempt
if err := json.Unmarshal(raw, &attempts); err != nil {
return nil, err
}
return attempts, nil
}
var count int
if err := json.Unmarshal(raw, &count); err != nil {
return nil, err
}
return legacyRecoveryAttempts(count, actions, result, errorSummary), nil
}
func legacyRecoveryAttempts(count int, actions []string, result, errorSummary string) []recovery.RecoveryAttempt {
if count <= 0 {
return nil
}
summary := strings.TrimSpace(strings.Join(actions, ", "))
if summary == "" {
summary = "legacy execution attempt"
}
attempts := make([]recovery.RecoveryAttempt, 0, count)
for i := 0; i < count; i++ {
attempts = append(attempts, recovery.RecoveryAttempt{
CommandSummary: summary,
Result: result,
ErrorSummary: errorSummary,
Source: "legacy_execution_file",
})
}
return attempts
}
type recoveryRuntime struct {
transport *transport.Client
flags *GlobalFlags
}
func newRecoveryRuntime(flags *GlobalFlags) *recoveryRuntime {
var httpClient *http.Client
if flags != nil && flags.Timeout > 0 {
httpClient = &http.Client{Timeout: time.Duration(flags.Timeout) * time.Second}
}
client := transport.NewClient(httpClient)
client.ExtraHeaders = resolveIdentityHeaders()
return &recoveryRuntime{
transport: client,
flags: flags,
}
}
func (r *recoveryRuntime) Search(ctx context.Context, query string, rc recovery.RecoveryContext) (recovery.KnowledgeRetrieval, error) {
const (
searchPage = 1
searchSize = 5
)
requestArgs := map[string]any{
"keyword": query,
"page": searchPage,
"size": searchSize,
}
retrieval := recovery.KnowledgeRetrieval{
DocSearch: recovery.DocSearch{
Provider: "open_platform_docs",
Query: query,
Page: searchPage,
Size: searchSize,
Status: "empty",
Request: &recovery.ToolCallRecord{
ServerID: "devdoc",
ToolName: "search_open_platform_docs_rag",
Arguments: cloneRecoveryArgs(requestArgs),
},
},
}
if r == nil || strings.TrimSpace(query) == "" {
retrieval.DocSearch.Status = "skipped"
return retrieval, nil
}
result, err := r.CallToolDirect(ctx, "devdoc", "search_open_platform_docs_rag", requestArgs)
if result != nil {
retrieval.DocSearch.Response = toRecoveryToolResponse(result)
}
if err != nil {
retrieval.DocSearch.Status = "error"
retrieval.DocSearch.Error = err.Error()
return retrieval, err
}
retrieval.DocSearch.Items = parseDocSearchItems(result)
if len(retrieval.DocSearch.Items) > 0 {
retrieval.DocSearch.Status = "success"
retrieval.KBHits = rerankDocSearchHits(query, rc, retrieval.DocSearch.Items)
}
return retrieval, nil
}
func (r *recoveryRuntime) CallToolDirect(ctx context.Context, serverID, toolName string, args map[string]any) (*transport.ToolCallResult, error) {
if r == nil || r.transport == nil {
return nil, fmt.Errorf("recovery runtime not initialized")
}
endpoint, err := r.resolveEndpoint(ctx, serverID, toolName)
if err != nil {
return nil, err
}
authToken, err := resolveRuntimeAuthToken(ctx, recoveryRuntimeToken(r.flags))
if err != nil {
return nil, tokenResolutionError(err)
}
tc := r.transport.WithAuth(authToken, resolveIdentityHeaders())
result, err := tc.CallTool(ctx, endpoint, toolName, args)
if err != nil {
return nil, err
}
if result.IsError {
return &result, apperrors.NewAPI(
extractMCPErrorMessage(result),
apperrors.WithOperation("tools/call"),
apperrors.WithReason("mcp_tool_error"),
apperrors.WithServerKey(serverID),
)
}
return &result, nil
}
func (r *recoveryRuntime) resolveEndpoint(_ context.Context, productID, toolName string) (string, error) {
if endpoint, ok := directRuntimeEndpoint(productID, toolName); ok {
return endpoint, nil
}
return "", endpointNotResolvedError(productID, toolName, "no dynamic endpoint registered for product or tool")
}
func recoveryRuntimeToken(flags *GlobalFlags) string {
if flags == nil {
return ""
}
return strings.TrimSpace(flags.Token)
}
func toRecoveryToolResponse(result *transport.ToolCallResult) *recovery.ToolResponse {
if result == nil {
return nil
}
response := &recovery.ToolResponse{IsError: result.IsError}
if len(result.Blocks) > 0 {
response.Content = make([]recovery.ToolResponseBlock, 0, len(result.Blocks))
for _, block := range result.Blocks {
response.Content = append(response.Content, recovery.ToolResponseBlock{
Type: block.Type,
Text: block.Text,
})
}
}
return response
}
func parseDocSearchItems(result *transport.ToolCallResult) []recovery.DocSearchItem {
if result == nil {
return nil
}
if items := parseDocSearchItemsFromMap(result.Content); len(items) > 0 {
return items
}
for _, block := range result.Blocks {
var payload map[string]any
if err := json.Unmarshal([]byte(block.Text), &payload); err == nil {
if items := parseDocSearchItemsFromMap(payload); len(items) > 0 {
return items
}
}
}
return nil
}
func parseDocSearchItemsFromMap(payload map[string]any) []recovery.DocSearchItem {
if len(payload) == 0 {
return nil
}
if items := toDocSearchItems(payload["items"]); len(items) > 0 {
return items
}
if data, ok := payload["data"].(map[string]any); ok {
if items := toDocSearchItems(data["items"]); len(items) > 0 {
return items
}
}
if result, ok := payload["result"].(map[string]any); ok {
if items := toDocSearchItems(result["items"]); len(items) > 0 {
return items
}
}
return nil
}
func toDocSearchItems(raw any) []recovery.DocSearchItem {
list, ok := raw.([]any)
if !ok {
return nil
}
items := make([]recovery.DocSearchItem, 0, len(list))
for _, entry := range list {
object, ok := entry.(map[string]any)
if !ok {
continue
}
item := recovery.DocSearchItem{}
if title, ok := object["title"].(string); ok {
item.Title = title
}
if url, ok := object["url"].(string); ok {
item.URL = url
}
if desc, ok := object["desc"].(string); ok {
item.Desc = desc
}
if item.Title != "" || item.URL != "" || item.Desc != "" {
items = append(items, item)
}
}
return items
}
func rerankDocSearchHits(query string, rc recovery.RecoveryContext, items []recovery.DocSearchItem) []recovery.KBHit {
if len(items) == 0 {
return nil
}
keywords := strings.Fields(strings.ToLower(strings.TrimSpace(query)))
type scoredHit struct {
hit recovery.KBHit
score float64
}
scored := make([]scoredHit, 0, len(items))
for _, item := range items {
text := strings.ToLower(strings.Join(append([]string{
item.Title,
item.URL,
item.Desc,
rc.ToolName,
}, rc.CommandPath...), " "))
score := 0.0
for _, keyword := range keywords {
if strings.Contains(text, keyword) {
score += 1
}
}
scored = append(scored, scoredHit{
hit: recovery.KBHit{
Source: "open_platform_docs",
Title: item.Title,
URL: item.URL,
Snippet: item.Desc,
Score: score,
},
score: score,
})
}
sort.SliceStable(scored, func(i, j int) bool {
return scored[i].score > scored[j].score
})
limit := len(scored)
if limit > 3 {
limit = 3
}
hits := make([]recovery.KBHit, 0, limit)
for _, item := range scored[:limit] {
hits = append(hits, item.hit)
}
return hits
}
@@ -1,110 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"bytes"
"errors"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageRecoveryDeprecatedUnsupportedShim(t *testing.T) {
root := NewRootCommand()
group := mustFindCommand(t, root, "recovery")
if group.Hidden || group.Deprecated == "" || !group.Runnable() {
t.Fatalf("recovery group contract: hidden=%v deprecated=%q runnable=%v", group.Hidden, group.Deprecated, group.Runnable())
}
for _, leaf := range []string{"plan", "execute", "finalize"} {
cmd := mustFindCommand(t, root, "recovery", leaf)
if cmd.Hidden || cmd.Deprecated == "" || !cmd.Runnable() {
t.Fatalf("recovery %s contract: hidden=%v deprecated=%q runnable=%v", leaf, cmd.Hidden, cmd.Deprecated, cmd.Runnable())
}
wantFlags := []string{"event-id"}
switch leaf {
case "plan", "execute":
wantFlags = append(wantFlags, "last")
case "finalize":
wantFlags = append(wantFlags, "outcome", "execution-file")
}
for _, flag := range wantFlags {
if cmd.Flags().Lookup(flag) == nil {
t.Fatalf("recovery %s missing --%s", leaf, flag)
}
}
for _, child := range newRecoveryCommand().Commands() {
if child.Name() != leaf {
continue
}
if err := child.RunE(child, nil); err == nil || !strings.Contains(err.Error(), "不再支持") {
t.Fatalf("recovery %s RunE = %v, want 不再支持", leaf, err)
}
}
}
for _, format := range []string{"", "json", "pretty", "table"} {
var out bytes.Buffer
cmd := &cobra.Command{Use: "dws"}
cmd.PersistentFlags().String("format", format, "")
cmd.SetOut(&out)
sub := &cobra.Command{Use: "recovery"}
cmd.AddCommand(sub)
err := printRecoveryUnsupported(sub, "dws recovery plan")
if err == nil {
t.Fatalf("format=%q returned nil error", format)
}
typed, ok := err.(*apperrors.Error)
if !ok || typed.Category != apperrors.CategoryValidation {
t.Fatalf("format=%q error = %T/%v, want validation Error", format, err, err)
}
got := out.String() + err.Error()
if !strings.Contains(got, "不再支持") {
t.Fatalf("format=%q missing 不再支持:\n%s", format, got)
}
if format == "" || format == "json" || format == "pretty" {
if !strings.Contains(got, `"status":"unsupported"`) && !strings.Contains(got, `"status": "unsupported"`) {
t.Fatalf("format=%q missing unsupported JSON status:\n%s", format, got)
}
}
}
for _, format := range []string{"json", "pretty", "table"} {
cmd := &cobra.Command{Use: "dws"}
cmd.PersistentFlags().String("format", format, "")
cmd.SetOut(failWriter{})
sub := &cobra.Command{Use: "recovery"}
cmd.AddCommand(sub)
if err := printRecoveryUnsupported(sub, "dws recovery plan"); err == nil || !strings.Contains(err.Error(), "write failed") {
t.Fatalf("format=%q write failure = %v, want write failed", format, err)
}
}
if err := newRecoveryCommand().RunE(newRecoveryCommand(), nil); err == nil || !strings.Contains(err.Error(), "不再支持") {
t.Fatalf("recovery parent RunE = %v, want 不再支持", err)
}
captureRuntimeFailure(executor.Invocation{}, nil, nil)
}
type failWriter struct{}
func (failWriter) Write([]byte) (int, error) {
return 0, errWriteFailed
}
var errWriteFailed = errors.New("write failed")
@@ -0,0 +1,151 @@
package app
import (
"context"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
)
func recoveryCoverageRun(cmdArgs ...string) (string, error) {
cmd := newRecoveryCommand(&GlobalFlags{})
out := &strings.Builder{}
cmd.SetOut(out)
cmd.SetErr(io.Discard)
cmd.SetArgs(cmdArgs)
err := cmd.Execute()
return out.String(), err
}
func TestCrossPlatformCoverageRecoveryCommandRemainingCoverage(t *testing.T) {
oldSavePlan, oldSaveAnalysis := recoverySavePlan, recoverySaveAnalysis
t.Cleanup(func() {
recoverySavePlan, recoverySaveAnalysis = oldSavePlan, oldSaveAnalysis
})
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
store := recovery.NewStore(configDir)
last, err := store.Capture(recovery.RecoveryContext{ServerID: "doc", ToolName: "get"})
if err != nil {
t.Fatal(err)
}
recoverySavePlan = func(*recovery.Store, string, recovery.RecoveryPlan) error { return errors.New("save plan") }
if _, err := recoveryCoverageRun("plan", "--last"); err == nil {
t.Fatal("injected plan save failure succeeded")
}
recoverySavePlan = oldSavePlan
recoverySaveAnalysis = func(*recovery.Store, string, recovery.RecoveryPlan, recovery.RecoveryBundle) error {
return errors.New("save analysis")
}
if _, err := recoveryCoverageRun("execute", "--last"); err == nil {
t.Fatal("injected analysis save failure succeeded")
}
recoverySaveAnalysis = oldSaveAnalysis
parent := newRecoveryCommand(nil)
parent.SetOut(io.Discard)
if err := parent.RunE(parent, nil); err != nil {
t.Fatal(err)
}
if out, err := recoveryCoverageRun("plan", "--last"); err != nil || !strings.Contains(out, last.EventID) {
t.Fatalf("recovery plan = %q, %v", out, err)
}
if out, err := recoveryCoverageRun("execute", "--event-id", last.EventID); err != nil || out == "" {
t.Fatalf("recovery execute = %q, %v", out, err)
}
for _, args := range [][]string{
{"finalize"},
{"finalize", "--event-id", last.EventID},
{"finalize", "--event-id", last.EventID, "--outcome", "unknown"},
{"finalize", "--event-id", last.EventID, "--outcome", "recovered", "--execution-file", "missing"},
} {
if _, err := recoveryCoverageRun(args...); err == nil {
t.Fatalf("recovery finalize %#v should fail", args)
}
}
executionPath := filepath.Join(t.TempDir(), "execution.json")
if err := os.WriteFile(executionPath, []byte(`{"action":"retry","attempt":1,"result":"ok"}`), 0o600); err != nil {
t.Fatal(err)
}
if out, err := recoveryCoverageRun("finalize", "--event-id", last.EventID, "--outcome", "handoff", "--execution-file", executionPath); err != nil || !strings.Contains(out, "execution_recorded") {
t.Fatalf("recovery finalize = %q, %v", out, err)
}
if _, err := recoveryCoverageRun("finalize", "--event-id", last.EventID, "--outcome", "failed"); err != nil {
t.Fatal(err)
}
if _, err := loadRecoverySnapshot(store, true, last.EventID); err == nil {
t.Fatal("conflicting snapshot selectors should fail")
}
if _, err := loadRecoverySnapshot(store, false, "missing"); err == nil {
t.Fatal("missing event snapshot should fail")
}
if _, err := loadRecoverySnapshot(store, false, ""); err == nil {
t.Fatal("empty snapshot selector should fail")
}
missingStore := recovery.NewStore(t.TempDir())
if _, err := loadRecoverySnapshot(missingStore, true, ""); err == nil {
t.Fatal("missing latest snapshot should fail")
}
eventsPath := filepath.Join(configDir, "recovery", "recovery_events.jsonl")
if err := os.Remove(eventsPath); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(eventsPath, 0o700); err != nil {
t.Fatal(err)
}
if _, err := recoveryCoverageRun("plan", "--last"); err == nil {
t.Fatal("recovery plan save should fail")
}
if _, err := recoveryCoverageRun("execute", "--last"); err == nil {
t.Fatal("recovery analysis save should fail")
}
if _, err := recoveryCoverageRun("finalize", "--event-id", last.EventID, "--outcome", "recovered"); err == nil {
t.Fatal("recovery finalization save should fail")
}
}
func TestCrossPlatformCoverageRecoveryExecutionAndRuntimeRemainingCoverage(t *testing.T) {
t.Setenv("DINGTALK_DEVDOC_MCP_URL", "http://127.0.0.1:1")
path := filepath.Join(t.TempDir(), "execution.json")
if err := os.WriteFile(path, []byte(`{"attempts":{}}`), 0o600); err != nil {
t.Fatal(err)
}
if _, err := loadRecoveryExecution(path); err == nil {
t.Fatal("invalid attempts should fail")
}
if _, err := decodeRecoveryAttempts([]byte(`[{}`), nil, "", ""); err == nil {
t.Fatal("invalid attempt array should fail")
}
SetDynamicServers(nil)
runtime := &recoveryRuntime{
transport: transport.NewClient(nil),
flags: &GlobalFlags{Token: "token"},
}
if _, err := runtime.CallToolDirect(context.Background(), "missing", "tool", nil); err == nil || !strings.Contains(err.Error(), `endpoint not resolved for product "missing" (tool "tool")`) {
t.Fatalf("direct resolution error = %v", err)
}
if got, err := runtime.Search(context.Background(), "query", recovery.RecoveryContext{}); err == nil || got.DocSearch.Status != "error" {
t.Fatalf("search error = %#v, %v", got, err)
}
if got := parseDocSearchItems(&transport.ToolCallResult{Content: map[string]any{}, Blocks: []transport.ContentBlock{{Text: "not-json"}}}); got != nil {
t.Fatalf("empty doc search items = %#v", got)
}
for _, payload := range []map[string]any{
{"data": map[string]any{}},
{"result": map[string]any{}},
} {
if got := parseDocSearchItemsFromMap(payload); got != nil {
t.Fatalf("empty nested doc search items = %#v", got)
}
}
}
+88 -4
View File
@@ -1,10 +1,94 @@
package app
import (
"os"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
)
// captureRuntimeFailure previously persisted a recovery snapshot for
// `dws recovery`. The recovery package is gone; keep a no-op seam so runner
// failure paths stay stable while the visible Deprecated shim remains.
func captureRuntimeFailure(_ executor.Invocation, _, _ error) {}
func captureRuntimeFailure(invocation executor.Invocation, rawErr, wrappedErr error) {
if rawErr == nil && wrappedErr == nil {
return
}
store := recovery.NewStore(defaultConfigDir())
if store == nil || !store.Enabled() {
return
}
input := recovery.CaptureInput{
CommandPath: runtimeCommandPath(invocation),
ServerID: strings.TrimSpace(invocation.CanonicalProduct),
ToolName: strings.TrimSpace(invocation.Tool),
Args: cloneRecoveryArgs(invocation.Params),
Argv: append([]string(nil), os.Args[1:]...),
RawErr: rawErr,
WrappedErr: wrappedErr,
}
_, _ = store.Capture(recovery.BuildContext(input), recovery.BuildReplay(input))
}
func runtimeCommandPath(invocation executor.Invocation) []string {
if path := currentCommandPath(); len(path) > 0 {
return path
}
if legacy := strings.Fields(strings.TrimSpace(invocation.LegacyPath)); len(legacy) > 0 {
return legacy
}
if product := strings.TrimSpace(invocation.CanonicalProduct); product != "" {
if tool := strings.TrimSpace(invocation.Tool); tool != "" {
return []string{product, tool}
}
return []string{product}
}
return nil
}
func currentCommandPath() []string {
boolFlags := map[string]struct{}{
"--verbose": {},
"-v": {},
"--debug": {},
"--mock": {},
"--dry-run": {},
"--yes": {},
"-y": {},
"--help": {},
"-h": {},
"--json": {},
}
path := make([]string, 0, len(os.Args))
skipNext := false
for _, arg := range os.Args[1:] {
if skipNext {
skipNext = false
continue
}
if arg == "--" {
break
}
if strings.HasPrefix(arg, "-") {
if strings.Contains(arg, "=") {
continue
}
if _, ok := boolFlags[arg]; ok {
continue
}
skipNext = true
continue
}
path = append(path, arg)
}
return path
}
func cloneRecoveryArgs(args map[string]any) map[string]any {
if len(args) == 0 {
return nil
}
out := make(map[string]any, len(args))
for key, value := range args {
out[key] = value
}
return out
}
+12 -3
View File
@@ -39,6 +39,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline/handlers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/usage"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
@@ -50,11 +51,15 @@ import (
type outputFileContextKey struct{}
const recoveryEventStderrPrefix = "RECOVERY_EVENT_ID="
var (
rootNormalizeProcessProfileArgs = normalizeProcessProfileArgs
rootExecuteCommand = (*cobra.Command).ExecuteC
rootNewRootCommandWithEngine = NewRootCommandWithEngine
rootRunPreParse = pipeline.RunPreParse
rootLatestRecoveryCapture = recovery.LatestCapture
rootResetRecoveryState = recovery.ResetRuntimeState
rootStopAllStdioClients = StopAllStdioClients
rootLoadPlugins = loadPlugins
rootMkdirAll = os.MkdirAll
@@ -102,6 +107,7 @@ func Execute() (exitCode int) {
ctx = WithTimingCollector(ctx, timing)
initStart := time.Now()
rootResetRecoveryState()
engine := newPipelineEngine()
root := rootNewRootCommandWithEngine(ctx, engine)
timing.Record("cmd_init", time.Since(initStart))
@@ -127,6 +133,9 @@ func Execute() (exitCode int) {
_, _ = fmt.Fprintln(os.Stderr)
}
_ = printExecutionError(executed, os.Stdout, os.Stderr, err)
if last := rootLatestRecoveryCapture(); last != nil && last.EventID != "" {
_, _ = fmt.Fprintf(os.Stderr, "%s%s\n", recoveryEventStderrPrefix, last.EventID)
}
return apperrors.ExitCode(err)
}
return 0
@@ -468,10 +477,10 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
newCatalogCommand(),
newConfigCommand(),
newDoctorCommand(),
newRecoveryCommand(),
newEventCommand(),
newAuditCommand(),
newCompletionCommand(root),
newRecoveryCommand(flags),
newUpgradeCommand(),
newVersionCommand(),
newPluginCommand(),
@@ -715,8 +724,8 @@ func hideNonDirectRuntimeCommands(root *cobra.Command) {
var builtinCommandNames = map[string]bool{
"auth": true, "api": true, "audit": true, "cache": true, "config": true,
"doctor": true, "event": true, "completion": true, "skill": true,
"plugin": true, "profile": true, "recovery": true, "version": true, "help": true,
"schema": true, "mcp": true, "upgrade": true,
"plugin": true, "profile": true, "version": true, "help": true,
"recovery": true, "schema": true, "mcp": true, "upgrade": true,
}
// commandNameSet returns a new set containing every name in base plus extras.
+8
View File
@@ -12,6 +12,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
@@ -23,6 +24,8 @@ func TestCrossPlatformCoverageRootExecuteAllBranchesCoverage(t *testing.T) {
oldExecute := rootExecuteCommand
oldNewRoot := rootNewRootCommandWithEngine
oldPreParse := rootRunPreParse
oldLatest := rootLatestRecoveryCapture
oldReset := rootResetRecoveryState
oldStop := rootStopAllStdioClients
oldArgs := os.Args
t.Cleanup(func() {
@@ -30,16 +33,20 @@ func TestCrossPlatformCoverageRootExecuteAllBranchesCoverage(t *testing.T) {
rootExecuteCommand = oldExecute
rootNewRootCommandWithEngine = oldNewRoot
rootRunPreParse = oldPreParse
rootLatestRecoveryCapture = oldLatest
rootResetRecoveryState = oldReset
rootStopAllStdioClients = oldStop
os.Args = oldArgs
})
os.Args = []string{"dws"}
rootNormalizeProcessProfileArgs = func() func() { return func() {} }
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
rootResetRecoveryState = func() {}
rootStopAllStdioClients = func() {}
rootNewRootCommandWithEngine = func(context.Context, *pipeline.Engine) *cobra.Command {
return &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
}
rootLatestRecoveryCapture = func() *recovery.LastError { return nil }
rootExecuteCommand = func(cmd *cobra.Command) (*cobra.Command, error) { return cmd, nil }
if code := Execute(); code != 0 {
t.Fatalf("successful Execute code = %d", code)
@@ -52,6 +59,7 @@ func TestCrossPlatformCoverageRootExecuteAllBranchesCoverage(t *testing.T) {
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
wantErr := errors.New("unknown command missing")
rootLatestRecoveryCapture = func() *recovery.LastError { return &recovery.LastError{EventID: "evt-test"} }
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) { return nil, wantErr }
if code := Execute(); code == 0 {
t.Fatal("failed Execute returned zero")
+13 -7
View File
@@ -419,14 +419,20 @@ func TestRootKeepsSVIPChatCompatibilityFlags(t *testing.T) {
}
}
func TestCacheCommandDeprecatedCompatStub(t *testing.T) {
root := NewRootCommand()
cmd, _, err := root.Find([]string{"cache", "refresh"})
if err != nil || cmd == nil || cmd == root {
t.Fatalf("dws cache refresh compatibility stub missing: %v", err)
func TestCacheRefreshCompatibilityStub(t *testing.T) {
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"cache", "refresh", "--format", "json"})
if err := cmd.Execute(); err != nil {
t.Fatalf("cache refresh compatibility stub: %v\n%s", err, out.String())
}
if cmd.Hidden || cmd.Deprecated == "" {
t.Fatalf("cache refresh must be visible Deprecated: hidden=%v deprecated=%q", cmd.Hidden, cmd.Deprecated)
got := out.String()
for _, want := range []string{`"status":"deprecated"`, `"command":"dws cache refresh"`, "服务发现已下线"} {
if !strings.Contains(got, want) {
t.Fatalf("cache refresh output missing %q:\n%s", want, got)
}
}
}
+20 -7
View File
@@ -75,8 +75,10 @@ func newSkillSetupCommand() *cobra.Command {
Long: `安装 dws 自身 skill 文档到 AI Agent 目录(如 ~/.claude/skills/、~/.cursor/skills/ 等)。
支持两种模式:
mono 单 skill(稳定 / 推荐)—— 总入口 SKILL.md + references/products/
multi 多 skill—— 按产品拆 N 个独立 skill
mono 单 skill(稳定 / 推荐)—— 总入口 SKILL.md + references/products/
multi 🧪 EXPERIMENTAL 多 skill(试验版 / Preview)—— 按产品拆 N 个独立 skill
尚未达到 stable 标准,接口、命名与跨 skill 引用可能变动;
生产前请评估,问题请提 issue 反馈
multi 模式支持按产品挑选:
-s/--skill 只装指定子 skill(可重复,短名 aitable 或全名 dingtalk-aitable 均可)
@@ -148,7 +150,7 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
if filterErr != nil {
return filterErr
}
// dingtalk-shared carries the global rules every product skill declares as a
// dws-shared carries the global rules every product skill declares as a
// PREREQUISITE; it must ship even when --skill / --exclude narrows the set.
multiSkillNames = ensureMandatorySharedSkill(filtered, allMultiSkillNames)
}
@@ -175,6 +177,8 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
fmt.Fprintln(out, "已取消。")
return nil
}
} else if mode == skillSetupModeMulti {
fmt.Fprintln(errOut, "🧪 multi 模式当前为 EXPERIMENTAL(试验版 / Preview)—— 接口与布局可能变动,稳定版请用 --mode mono")
}
var installed, skipped int
@@ -201,8 +205,8 @@ const multiSkillPrefix = "dingtalk-"
// multiSharedSkill is the shared, non-product skill that every per-product
// skill declares as a PREREQUISITE. It must always be installed in multi mode
// regardless of --skill / --exclude, otherwise the product skills reference a
// dingtalk-shared that was never installed.
const multiSharedSkill = "dingtalk-shared"
// dws-shared that was never installed.
const multiSharedSkill = "dws-shared"
// ensureMandatorySharedSkill guarantees the shared dependency skill is included
// whenever it exists in the source, even if --skill / --exclude narrowed it out.
@@ -361,10 +365,10 @@ func resolveSkillSetupMode(mode string, autoYes bool, out io.Writer) (string, er
huh.NewGroup(
huh.NewSelect[string]().
Title("选择 dws skill 安装模式").
Description("mono = 单 skill 入口(稳定 / 推荐)\nmulti = 按产品拆分的独立 skill").
Description("mono = 单 skill 入口(稳定 / 推荐)\nmulti = 按产品拆分(🧪 EXPERIMENTAL / 试验版,未达 stable,接口可能变动)").
Options(
huh.NewOption("mono — 单 skill(稳定 / 推荐)", skillSetupModeMono),
huh.NewOption("multi — 多 skill(按产品拆分)", skillSetupModeMulti),
huh.NewOption("multi — 多 skill(🧪 EXPERIMENTAL · 试验版)", skillSetupModeMulti),
).
Value(&choice),
),
@@ -522,6 +526,15 @@ func detectExistingAgentHomes(home, mode string) []string {
}
func confirmSkillSetup(out io.Writer, mode, src string, dests []string, multiSkillNames []string) (bool, error) {
if mode == skillSetupModeMulti {
fmt.Fprintln(out, "\n🧪 ─────────────────────────────────────────────────────────────")
fmt.Fprintln(out, " multi 模式当前为 EXPERIMENTAL(试验版 / Preview)")
fmt.Fprintf(out, " · 当前选择的 %d 个独立 skill 均跑过 verifier,可用但未达 stable\n", len(multiSkillNames))
fmt.Fprintln(out, " · 跨 skill 引用、bundle 命名、目录布局后续可能调整")
fmt.Fprintln(out, " · 不建议在生产 / 共享环境直接落地;问题请提 issue 反馈")
fmt.Fprintln(out, " 稳定版请用 --mode mono")
fmt.Fprintln(out, "🧪 ─────────────────────────────────────────────────────────────")
}
fmt.Fprintf(out, "\n📦 将安装 skill:\n mode: %s\n source: %s\n", mode, src)
if mode == skillSetupModeMulti {
fmt.Fprintf(out, " 将装 %d 个独立 skill(按子目录平铺到 <agent-home>/<skill-name>/):\n", len(multiSkillNames))
+4 -4
View File
@@ -58,7 +58,7 @@ func TestMaterializeEmbeddedSkillSourceMono(t *testing.T) {
}
// TestMaterializeEmbeddedSkillSourceMulti verifies that the peer multi bundle
// contains both the shared routing skill and misc (including folded PAT docs). Structured
// contains both the shared routing skill and the PAT product skill. Structured
// Schema hints are build inputs and must not become a third installable mode.
func TestMaterializeEmbeddedSkillSourceMulti(t *testing.T) {
dir, cleanup, err := materializeEmbeddedSkillSource(skillSetupModeMulti)
@@ -71,9 +71,9 @@ func TestMaterializeEmbeddedSkillSourceMulti(t *testing.T) {
t.Fatalf("extracted dir %s is not a valid multi skill source root", dir)
}
for _, rel := range []string{
filepath.Join("dingtalk-shared", "SKILL.md"),
filepath.Join("dingtalk-misc", "SKILL.md"),
filepath.Join("dingtalk-misc", "references", "pat.md"),
filepath.Join("dws-shared", "SKILL.md"),
filepath.Join("dingtalk-pat", "SKILL.md"),
filepath.Join("dingtalk-pat", "references", "pat.md"),
} {
if _, err := os.Stat(filepath.Join(dir, rel)); err != nil {
t.Errorf("expected embedded multi skill to contain %s: %v", rel, err)
@@ -73,7 +73,7 @@ func TestCrossPlatformCoverageSkillSetupHighLevelRemainingCoverage(t *testing.T)
if err := cmd.RunE(cmd, nil); err == nil {
t.Fatal("empty multi source should fail")
}
skillSetupListMulti = func(string) ([]string, error) { return []string{"dingtalk-shared", "dingtalk-doc"}, nil }
skillSetupListMulti = func(string) ([]string, error) { return []string{"dws-shared", "dingtalk-doc"}, nil }
skillSetupFilterMulti = func([]string, []string, []string) ([]string, error) { return nil, fail }
cmd = skillSetupCoverageCommand(t, skillSetupModeMulti, true)
if err := cmd.RunE(cmd, nil); err == nil {
+2 -2
View File
@@ -71,7 +71,7 @@ func NewSchemaCommand() *cobra.Command {
Short: "渐进查看命令 Schema (产品 / 分组 / 工具参数)",
Long: `查看当前可运行命令的 Schema 元数据。
不带参数时列出产品和工具数量;传产品或分组路径逐层展开;传具体工具路径输出扁平参数 Schema(对齐 GWS:parameters 内联 required,键为 CLI flag)。普通 Agent 查询应使用 --compact:它按稳定字段白名单输出选参、约束和安全语义。省略 --compact 的 full leaf 保留参数映射、接口绑定和 provenance,仅用于定向审计;--all 输出全部工具的完整 leaf Schema,用于审计/CI。helper、MCP 与本地 Cobra 命令均须通过 ContractFinal.Identity 声明进入收集的身份集,并从同一声明装配的 ToolSpec 投影;查询不执行服务发现或临时合成第二份 Schema。`,
不带参数时列出产品和工具数量;传产品或分组路径逐层展开;传具体工具路径输出扁平参数 Schema(对齐 GWS:parameters 内联 required,键为 CLI flag)。--all 输出全部工具的完整 leaf Schema(包括参数和约束,用于审计/CI)。--compact 去除 provenance / debug 字段,仅保留 Agent 选参所需信息(适合 Agent 上下文)。helper、MCP 与本地 Cobra 命令均须通过 ContractFinal.Identity 声明进入收集的身份集,并从同一声明装配的 ToolSpec 投影;查询不执行服务发现或临时合成第二份 Schema。`,
Args: cobra.MaximumNArgs(1),
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
@@ -113,7 +113,7 @@ func NewSchemaCommand() *cobra.Command {
},
}
cmd.Flags().Bool("all", false, "输出全部工具的完整 leaf Schema(包括参数和约束,用于审计/CI)")
cmd.Flags().Bool("compact", false, "按稳定字段白名单输出 Agent 选参、约束和安全语义")
cmd.Flags().Bool("compact", false, "去除 provenance/debug 字段,仅保留 Agent 选参所需信息")
cmd.Flags().String("cli-path", "", "按 CLI 命令路径查询")
return cmd
}
+34 -1
View File
@@ -24,6 +24,39 @@ import (
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalMCPMetadataLookup(t *testing.T) {
cmd := &cobra.Command{Use: "reply"}
t.Cleanup(func() { contractfinal.ClearRuntimeContractFinalForTest(cmd) })
cmd.Flags().String("text", "", "text")
runtimeannotate.AnnotateRuntimeFlag(cmd, "text", "text", "string", false)
contractfinal.RegisterRuntimeContractFinal(cmd, contract.ContractFinalPayload{
Identity: &contract.ToolIdentitySpec{
ProductID: "chat", Name: "reply_personal_message", CanonicalPath: "chat.reply_personal_message",
CLIPath: "chat reply", PrimaryCLIPath: "chat reply",
},
Interface: &contract.InterfaceSpec{
Mode: contract.InterfaceModeMCP,
Availability: contract.InterfaceAvailable,
Ref: &contract.InterfaceRefSpec{ProductID: "chat", RPCName: "send_personal_message"},
},
})
entry := runtimeSchemaEntry{
ProductID: "chat", ToolName: "reply_personal_message", Command: cmd,
CLIPath: "chat reply", PrimaryCLIPath: "chat reply",
}
metadata := runtimeSchemaMetadataSources{
MCP: embeddedMCPMetadata{Tools: map[string]embeddedMCPToolMetadata{
"chat.send_personal_message": {Parameters: map[string]embeddedMCPParamMeta{
"text": {Type: "string"},
}},
}},
}
if _, err := runtimeToolSpecFromContractFinal(entry, mustFinal(t, cmd), metadata); err != nil {
t.Fatalf("runtimeToolSpecFromContractFinal with MCP metadata = %v", err)
}
}
func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalPassThrough(t *testing.T) {
cmd := &cobra.Command{Use: "create", Short: "s", Long: "l"}
t.Cleanup(func() { contractfinal.ClearRuntimeContractFinalForTest(cmd) })
@@ -285,7 +318,7 @@ func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalSafetyAnnotationFa
func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalParameterResolutionError(t *testing.T) {
oldParameters := resolveRuntimeParameters
t.Cleanup(func() { resolveRuntimeParameters = oldParameters })
resolveRuntimeParameters = func(*cobra.Command, string, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
resolveRuntimeParameters = func(*cobra.Command, string, map[string]embeddedMCPParamMeta, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
return nil, errors.New("parameters failed")
}
entry := runtimeSchemaEntry{
+184 -85
View File
@@ -89,9 +89,9 @@ func RegisterRuntimeSchemaConstraints(canonicalPath string, constraints RuntimeS
}
// emptyPinnedMCPMetadata returns the retired pin shape with no tools.
// schema_mcp_metadata.json is deleted; Schema parameter assembly never loads
// or ranks MCP pin candidates. Optional Interface-registry validators may
// still pass this empty shape when they only need ContractFinal self-checks.
// schema_mcp_metadata.json is deleted; production assembly does not embed or
// load a pinned MCP snapshot. Test fixtures may still inject non-empty maps
// through schemaRegistryForTestWithMetadata.
func emptyPinnedMCPMetadata() embeddedMCPMetadata {
return embeddedMCPMetadata{Tools: map[string]embeddedMCPToolMetadata{}}
}
@@ -217,6 +217,62 @@ func collectRuntimeSchemaEntriesFromBound(bound BoundCommandRegistry) ([]runtime
return entries, nil
}
func pinnedMCPMetadataForEntryFrom(entry runtimeSchemaEntry, agentMetadata agentMetadata, mcpMetadata embeddedMCPMetadata) (embeddedMCPToolMetadata, bool) {
// Optional test/diagnostic lookup only. Production mcpMetadata is empty;
// Contract/ParamDecl own interface facts. When a non-empty fixture is
// injected, ContractFinal Interface.Ref remaps CLI canonical names onto
// fixture keys (e.g. reply_personal_message → chat.send_personal_message).
if len(mcpMetadata.Tools) == 0 {
return embeddedMCPToolMetadata{}, false
}
if entry.Command != nil {
if final, ok := RuntimeContractFinal(entry.Command); ok && final.Interface != nil && final.Interface.Ref != nil {
if metadata, found := mcpMetadataForInterfaceRef(mcpMetadata, final.Interface.Ref.ProductID, final.Interface.Ref.RPCName); found {
return metadata, true
}
}
}
paths := []string{
entry.PrimaryCLIPath,
entry.CLIPath,
entry.ProductID + "." + entry.ToolName,
}
paths = append(paths, entry.Aliases...)
if toolMetadata, ok := lookupAgentToolMetadataFrom(agentMetadata, paths...); ok && toolMetadata.InterfaceRef != nil {
if metadata, found := mcpMetadataForInterfaceRef(mcpMetadata, toolMetadata.InterfaceRef.ProductID, toolMetadata.InterfaceRef.RPCName); found {
return metadata, true
}
}
for _, key := range []string{
entry.SourceProductID + "." + entry.ToolName,
entry.ProductID + "." + entry.ToolName,
} {
key = strings.Trim(key, ".")
if key == "" {
continue
}
if meta, ok := mcpMetadata.Tools[key]; ok {
return meta, true
}
}
return embeddedMCPToolMetadata{}, false
}
func mcpMetadataForInterfaceRef(mcpMetadata embeddedMCPMetadata, productID, rpcName string) (embeddedMCPToolMetadata, bool) {
productID = strings.TrimSpace(productID)
rpcName = strings.TrimSpace(rpcName)
key := strings.Trim(productID+"."+rpcName, ".")
if key == "" {
return embeddedMCPToolMetadata{}, false
}
metadata, exists := mcpMetadata.Tools[key]
if !exists {
return embeddedMCPToolMetadata{}, false
}
metadata.InterfaceRef = &embeddedMCPInterfaceRef{ProductID: productID, RPCName: rpcName}
return metadata, true
}
func runtimeSchemaAnnotations(cmd *cobra.Command) (productID, toolName, source string) {
if cmd == nil || cmd.Annotations == nil {
return "", "", ""
@@ -266,6 +322,7 @@ const (
runtimeSchemaRankDefault = 0
runtimeSchemaRankDerived = 50
runtimeSchemaRankInference = 100
runtimeSchemaRankMCP = 400
runtimeSchemaRankCobraHelp = 450
runtimeSchemaRankCobraDefault = 600
runtimeSchemaRankCobraContract = 610
@@ -275,7 +332,7 @@ const (
runtimeSchemaRankVersionedBinding = 650
// ParamDecl.Property (dws.schema.property) outranks residual versioned
// binding candidates (active bindings JSON is empty after Phase 2).
// Mapping exclusions stay highest so an explicit "no RPC property" review
// Mapping exclusions stay highest so an explicit "no MCP property" review
// cannot be overridden by a leaf ParamDecl that still carries a Property.
runtimeSchemaRankParamDeclProperty = 655
runtimeSchemaRankMappingExclusion = 660
@@ -283,6 +340,7 @@ const (
runtimeSchemaPrecedenceDefault = "default"
runtimeSchemaPrecedenceDerived = "derived_resolution"
runtimeSchemaPrecedenceInference = "inference"
runtimeSchemaPrecedenceMCP = "mcp_metadata"
runtimeSchemaPrecedenceCobraHelp = "cobra_help"
runtimeSchemaPrecedenceCobra = "cobra_contract"
runtimeSchemaPrecedenceNativeAnnotation = "native_annotation"
@@ -475,6 +533,8 @@ func runtimeSchemaSourcePriority(source string) (int, string) {
return runtimeSchemaRankCobraDefault, runtimeSchemaPrecedenceCobra
}
return runtimeSchemaRankCobraContract, runtimeSchemaPrecedenceCobra
case "mcp_metadata", "pinned_mcp_metadata":
return runtimeSchemaRankMCP, runtimeSchemaPrecedenceMCP
case "cobra_help":
return runtimeSchemaRankCobraHelp, runtimeSchemaPrecedenceCobraHelp
case "flag_name_inference", "usage_required_inference", "usage_format_inference":
@@ -515,9 +575,9 @@ func runtimeSchemaParameterMappingKey(canonicalPath, flagName string) string {
// runtimeSchemaParameterMappingCandidates resolves the two reviewed,
// versioned property-mapping inputs. An exclusion is an explicit statement
// that the CLI parameter is not a direct RPC/interface property: it therefore
// supplies a present empty candidate (rather than allowing name inference to
// survive) and keeps the review reason in provenance.
// that the CLI parameter is not a direct MCP property: it therefore supplies
// a present empty candidate (rather than allowing name inference to survive)
// and keeps the review reason in provenance.
func runtimeSchemaParameterMappingCandidates(snapshot schemaParameterBindingSnapshot, canonicalPath, flagName string) (runtimeSchemaFieldCandidate, runtimeSchemaFieldCandidate, error) {
binding := strings.TrimSpace(snapshot.Bindings[strings.TrimSpace(canonicalPath)][strings.TrimSpace(flagName)])
bindingCandidate := runtimeSchemaStringCandidate(binding, "versioned_parameter_binding")
@@ -545,24 +605,34 @@ func runtimeSchemaParameterMappingCandidates(snapshot schemaParameterBindingSnap
type runtimeParameterFieldContext struct {
flag *pflag.Flag
metadata RuntimeSchemaParameterMetadata
pinnedParam embeddedMCPParamMeta
hasPinned bool
paramType string
constraints RuntimeSchemaConstraints
property string
}
func (c runtimeParameterFieldContext) interfaceTypeCandidates() []runtimeSchemaFieldCandidate {
return []runtimeSchemaFieldCandidate{
candidates := []runtimeSchemaFieldCandidate{
runtimeSchemaStringCandidate(firstFlagAnnotation(c.flag, runtimeSchemaFlagTypeAnnotation), "native_annotation"),
runtimeSchemaStringCandidateAtRank(c.paramType, "cobra_flag_type", runtimeSchemaRankInference, "fallback"),
}
if c.hasPinned {
candidates = append(candidates, runtimeSchemaStringCandidate(c.pinnedParam.Type, "mcp_metadata"))
}
return append(candidates,
runtimeSchemaStringCandidateAtRank(c.paramType, "cobra_flag_type", runtimeSchemaRankInference, "fallback"),
)
}
func (c runtimeParameterFieldContext) descriptionCandidates() []runtimeSchemaFieldCandidate {
return []runtimeSchemaFieldCandidate{
candidates := []runtimeSchemaFieldCandidate{
runtimeSchemaStringCandidate(firstFlagAnnotation(c.flag, runtimeSchemaFlagDescriptionAnnotation), "native_annotation"),
runtimeSchemaStringCandidate(c.flag.Usage, "cobra_usage"),
runtimeSchemaCandidate("", true, "default"),
}
if c.hasPinned {
candidates = append(candidates, runtimeSchemaStringCandidate(c.pinnedParam.Description, "mcp_metadata"))
}
return append(candidates, runtimeSchemaCandidate("", true, "default"))
}
func (c runtimeParameterFieldContext) requiredCandidates() []runtimeSchemaFieldCandidate {
@@ -579,7 +649,7 @@ func (c runtimeParameterFieldContext) requiredCandidates() []runtimeSchemaFieldC
break
}
}
return []runtimeSchemaFieldCandidate{
candidates := []runtimeSchemaFieldCandidate{
constraintRequired,
runtimeSchemaCandidate(true, typedRequired, "typed_parameter_metadata"),
runtimeSchemaAnnotatedBoolCandidate(c.flag, runtimeSchemaFlagMetadataRequiredAnnotation, "typed_parameter_metadata"),
@@ -587,36 +657,50 @@ func (c runtimeParameterFieldContext) requiredCandidates() []runtimeSchemaFieldC
runtimeSchemaCandidate(true, runtimeFlagCobraHardRequired(c.flag), "cobra_hard_required"),
runtimeSchemaCandidate(false, cobraDefaultOptional, "cobra_nonzero_default"),
runtimeSchemaCandidate(usageRequired, usageRequired, "usage_required_inference"),
runtimeSchemaCandidate(false, true, "default"),
}
if c.hasPinned && c.pinnedParam.Required != nil {
candidates = append(candidates, runtimeSchemaCandidate(*c.pinnedParam.Required, true, "mcp_metadata"))
}
return append(candidates, runtimeSchemaCandidate(false, true, "default"))
}
func (c runtimeParameterFieldContext) requiredWhenCandidates() []runtimeSchemaFieldCandidate {
return []runtimeSchemaFieldCandidate{
candidates := []runtimeSchemaFieldCandidate{
runtimeSchemaStringCandidate(c.metadata.RequiredWhen[c.flag.Name], "typed_parameter_metadata"),
runtimeSchemaStringCandidate(firstFlagAnnotation(c.flag, runtimeSchemaFlagMetadataRequiredWhenAnnotation), "typed_parameter_metadata"),
runtimeSchemaStringCandidate(firstFlagAnnotation(c.flag, runtimeSchemaFlagRequiredWhenAnnotation), "native_annotation"),
runtimeSchemaCandidate("", true, "default"),
}
if c.hasPinned {
candidates = append(candidates, runtimeSchemaStringCandidate(c.pinnedParam.RequiredWhen, "mcp_metadata"))
}
return append(candidates, runtimeSchemaCandidate("", true, "default"))
}
func (c runtimeParameterFieldContext) formatCandidates() []runtimeSchemaFieldCandidate {
return []runtimeSchemaFieldCandidate{
candidates := []runtimeSchemaFieldCandidate{
runtimeSchemaStringCandidate(c.metadata.Formats[c.flag.Name], "typed_parameter_metadata"),
runtimeSchemaStringCandidate(firstFlagAnnotation(c.flag, runtimeSchemaFlagMetadataFormatAnnotation), "typed_parameter_metadata"),
runtimeSchemaStringCandidate(firstFlagAnnotation(c.flag, "x-cli-format"), "native_annotation"),
}
if c.hasPinned {
candidates = append(candidates, runtimeSchemaStringCandidate(c.pinnedParam.Format, "mcp_metadata"))
}
return append(candidates,
runtimeSchemaStringCandidate(inferredRuntimeFlagFormat(c.flag), "usage_format_inference"),
runtimeSchemaCandidate("", true, "default"),
}
)
}
func (c runtimeParameterFieldContext) enumCandidates() []runtimeSchemaFieldCandidate {
return []runtimeSchemaFieldCandidate{
candidates := []runtimeSchemaFieldCandidate{
runtimeSchemaEnumCandidate(c.metadata.Enums[c.flag.Name], "typed_parameter_metadata"),
runtimeSchemaEnumCandidate(runtimeFlagEnumAnnotation(c.flag, runtimeSchemaFlagMetadataEnumAnnotation), "typed_parameter_metadata"),
runtimeSchemaEnumCandidate(runtimeFlagEnum(c.flag), "native_annotation"),
runtimeSchemaCandidate([]string{}, true, "default"),
}
if c.hasPinned {
candidates = append(candidates, runtimeSchemaEnumCandidate(c.pinnedParam.Enum, "mcp_metadata"))
}
return append(candidates, runtimeSchemaCandidate([]string{}, true, "default"))
}
func (c runtimeParameterFieldContext) exampleCandidates() []runtimeSchemaFieldCandidate {
@@ -633,8 +717,7 @@ func (c runtimeParameterFieldContext) exampleCandidates() []runtimeSchemaFieldCa
// source may intentionally raise or lower type/mapping/description semantics.
// required is different: Cobra MarkFlagRequired is a hard floor that no
// lower-priority source may demote (see resolveRequiredProjection).
// MCP pin / mcp_metadata is not a candidate source.
func runtimeCommandParameterSpecs(cmd *cobra.Command, canonicalPath string, constraints RuntimeSchemaConstraints) ([]ParameterSpec, error) {
func runtimeCommandParameterSpecs(cmd *cobra.Command, canonicalPath string, pinnedParams map[string]embeddedMCPParamMeta, constraints RuntimeSchemaConstraints) ([]ParameterSpec, error) {
if cmd == nil {
return nil, nil
}
@@ -675,10 +758,18 @@ func runtimeCommandParameterSpecs(cmd *cobra.Command, canonicalPath string, cons
return
}
property, _ := propertyWinner.Value.(string)
// pinnedParams remains for test fixtures that inject MCP-shaped maps;
// production assembly always passes an empty map (pin retired).
pinnedParam, hasPinnedParam := embeddedMCPParamMeta{}, false
if len(pinnedParams) > 0 && strings.TrimSpace(property) != "" {
pinnedParam, hasPinnedParam = lookupPinnedMCPParam(pinnedParams, property, flag.Name)
}
paramType := runtimeFlagCLIType(flag)
fieldCtx := runtimeParameterFieldContext{
flag: flag,
metadata: metadata,
pinnedParam: pinnedParam,
hasPinned: hasPinnedParam,
paramType: paramType,
constraints: constraints,
property: property,
@@ -703,6 +794,10 @@ func runtimeCommandParameterSpecs(cmd *cobra.Command, canonicalPath string, cons
return
}
description, _ := descriptionWinner.Value.(string)
interfaceDescription := ""
if hasPinnedParam {
interfaceDescription = strings.TrimSpace(pinnedParam.Description)
}
// Required uses field-level safe merge: higher sources may raise required, but
// Cobra MarkFlagRequired cannot be projected away as optional.
@@ -743,6 +838,9 @@ func runtimeCommandParameterSpecs(cmd *cobra.Command, canonicalPath string, cons
runtimeSchemaCandidate(true, true, "cobra_hard_required"),
)
}
if interfaceDescription != "" && interfaceDescription != description {
parameter.InterfaceDescription = interfaceDescription
}
if interfaceType != "" && interfaceType != paramType {
parameter.InterfaceType = interfaceType
fieldProvenance["interface_type"] = runtimeSchemaFieldProvenance(interfaceTypeWinner)
@@ -757,6 +855,12 @@ func runtimeCommandParameterSpecs(cmd *cobra.Command, canonicalPath string, cons
if def := runtimeFlagDefault(flag); def != "" {
parameter.Default = runtimeSchemaJSONString(def)
}
if hasPinnedParam {
interfaceDefault := strings.TrimSpace(pinnedParam.Default)
if interfaceDefault != "" && interfaceDefault != runtimeFlagDefault(flag) {
parameter.InterfaceDefault = runtimeSchemaJSONString(interfaceDefault)
}
}
formatWinner, ok := resolveField("format", fieldCtx.formatCandidates())
if !ok {
return
@@ -887,6 +991,19 @@ func runtimeSchemaConstraintsEmpty(constraints RuntimeSchemaConstraints) bool {
return runtimeannotate.ConstraintsEmpty(constraints)
}
func lookupPinnedMCPParam(params map[string]embeddedMCPParamMeta, property, flagName string) (embeddedMCPParamMeta, bool) {
if len(params) == 0 {
return embeddedMCPParamMeta{}, false
}
if meta, ok := params[property]; ok {
return meta, true
}
if meta, ok := params[flagName]; ok {
return meta, true
}
return embeddedMCPParamMeta{}, false
}
func isGenericPayloadFlag(flag *pflag.Flag) bool {
if flag == nil {
return false
@@ -1045,87 +1162,69 @@ func strconvQuote(value string) string {
// ─── --compact mode ──────────────────────────────────────────────────────────
// schemaCompactPayloadKeys is the reviewed Agent-view allowlist. Keep this a
// positive list: a new full/audit field must not silently expand routine Agent
// context just because it was added to ToolSpec.ToPayload.
var schemaCompactPayloadKeys = map[string]bool{
// Navigation envelopes.
"kind": true, "level": true, "count": true, "tool_count": true,
"products": true, "product": true, "tools": true,
"id": true, "schema_path": true, "runtime": true,
// Leaf identity and execution semantics.
"canonical_path": true, "cli_path": true,
"agent_summary": true, "description": true,
"effect": true, "risk": true, "confirmation": true, "idempotency": true,
"interface_mode": true, "availability": true, "interface_reason": true,
"parameters": true, "constraints": true, "positionals": true, "dry_run": true,
"examples": true, "use_when": true, "avoid_when": true,
// schemaCompactStripKeys are top-level tool/product keys removed in --compact mode.
var schemaCompactStripKeys = map[string]bool{
// provenance / debug
"agent_metadata_source": true,
"agent_source_refs": true,
"agent_summary_source": true,
"effect_source": true,
"metadata_source": true,
"source": true,
"agent_metadata": true,
"field_provenance": true,
"reviewed": true,
// redundant with canonical_path / cli_path
"name": true,
"path": true,
"cli_name": true,
"primary_cli_path": true,
"is_alias": true,
"has_parameters": true,
"parameter_count": true,
"product_id": true,
"display": true,
"title": true,
"group": true,
"source_product_id": true,
"aliases": true,
"catalog_hash": true,
"surface_hash": true,
"workflow_refs": true,
"prerequisites": true,
"tips": true,
"interface_ref": true,
}
// schemaCompactParamKeys is the reviewed parameter allowlist for Agent command
// construction. RPC mapping and provenance fields intentionally remain in the
// full/audit view.
var schemaCompactParamKeys = map[string]bool{
"type": true, "description": true, "required": true,
"cli_required": true, "required_when": true,
"default": true, "interface_default": true, "example": true,
"format": true, "enum": true,
// schemaCompactParamStripKeys are per-parameter keys removed in --compact mode.
var schemaCompactParamStripKeys = map[string]bool{
"interface_description": true,
"interface_type": true,
"property": true,
"field_provenance": true,
}
// stripSchemaPayloadCompact projects a full Schema payload onto the reviewed
// Agent-view allowlist. Structural product/tool children are projected
// recursively; constraint, positional and dry-run values are already typed
// contract data and are retained verbatim.
// stripSchemaPayloadCompact walks a schema payload map and removes provenance,
// debug and redundant keys so that only agent-essential fields remain.
// It operates recursively on nested maps, slices, and parameter objects.
func stripSchemaPayloadCompact(payload map[string]any) map[string]any {
if payload == nil {
return nil
}
result := make(map[string]any, len(payload))
for k, v := range payload {
if !schemaCompactPayloadKeys[k] {
if schemaCompactStripKeys[k] {
continue
}
switch k {
case "parameters":
if k == "parameters" {
result[k] = stripSchemaParametersCompact(v)
case "product":
if product, ok := v.(map[string]any); ok {
result[k] = stripSchemaPayloadCompact(product)
} else {
result[k] = v
}
case "products", "tools":
result[k] = stripSchemaPayloadCollectionCompact(v)
default:
result[k] = v
continue
}
result[k] = stripSchemaValueCompact(v)
}
return result
}
func stripSchemaPayloadCollectionCompact(value any) any {
switch values := value.(type) {
case []map[string]any:
result := make([]map[string]any, len(values))
for i, item := range values {
result[i] = stripSchemaPayloadCompact(item)
}
return result
case []any:
result := make([]any, len(values))
for i, item := range values {
if payload, ok := item.(map[string]any); ok {
result[i] = stripSchemaPayloadCompact(payload)
} else {
result[i] = item
}
}
return result
default:
return value
}
}
func stripSchemaParametersCompact(value any) any {
parameters, ok := value.(map[string]any)
if !ok {
@@ -1179,7 +1278,7 @@ func stripSchemaValueCompact(v any) any {
func stripSchemaParamCompact(param map[string]any) map[string]any {
result := make(map[string]any, len(param))
for k, v := range param {
if !schemaCompactParamKeys[k] {
if schemaCompactParamStripKeys[k] {
continue
}
result[k] = v
@@ -11,6 +11,7 @@ import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/spf13/cobra"
@@ -62,6 +63,39 @@ func TestCrossPlatformCoverageCollectRuntimeSchemaEntriesErrorsAndOrdering(t *te
}
func TestCrossPlatformCoverageRuntimeSchemaMetadataLookupEdges(t *testing.T) {
if _, ok := pinnedMCPMetadataForEntryFrom(runtimeSchemaEntry{}, agentMetadata{}, embeddedMCPMetadata{Tools: map[string]embeddedMCPToolMetadata{}}); ok {
t.Fatal("empty lookup unexpectedly matched")
}
leaf := &cobra.Command{Use: "reply"}
contractfinal.RegisterRuntimeContractFinal(leaf, contract.ContractFinalPayload{
Identity: &contract.ToolIdentitySpec{
ProductID: "sample", Name: "run", CanonicalPath: "sample.run",
CLIPath: "sample run", PrimaryCLIPath: "sample run",
},
Interface: &contract.InterfaceSpec{
Mode: contract.InterfaceModeMCP,
Availability: contract.InterfaceAvailable,
Ref: &contract.InterfaceRefSpec{ProductID: "chat", RPCName: "send_personal_message"},
},
})
t.Cleanup(func() { ClearRuntimeContractFinalForTest(leaf) })
mcp := embeddedMCPMetadata{Tools: map[string]embeddedMCPToolMetadata{
"chat.send_personal_message": {
Parameters: map[string]embeddedMCPParamMeta{
"clawType": {Type: "string"},
},
},
}}
got, ok := pinnedMCPMetadataForEntryFrom(runtimeSchemaEntry{Command: leaf, ProductID: "chat", ToolName: "reply_personal_message"}, agentMetadata{}, mcp)
if !ok || got.Parameters["clawType"].Type != "string" {
t.Fatalf("ContractFinal Interface.Ref MCP remap = %#v ok=%v", got, ok)
}
if got.InterfaceRef == nil || got.InterfaceRef.RPCName != "send_personal_message" {
t.Fatalf("InterfaceRef = %#v", got.InterfaceRef)
}
for _, test := range []struct {
value any
want int
@@ -121,13 +155,13 @@ func TestCrossPlatformCoverageRuntimeCommandParameterErrorEdges(t *testing.T) {
cmd.Flags().String("value", "", "value")
flag := cmd.Flags().Lookup("value")
if specs, err := runtimeCommandParameterSpecs(nil, "sample.run", RuntimeSchemaConstraints{}); err != nil || specs != nil {
if specs, err := runtimeCommandParameterSpecs(nil, "sample.run", nil, RuntimeSchemaConstraints{}); err != nil || specs != nil {
t.Fatalf("nil command specs = %#v, err = %v", specs, err)
}
testseam.Swap(t, &schemaParameterBindingData, func() (schemaParameterBindingSnapshot, error) {
return schemaParameterBindingSnapshot{}, errors.New("load failed")
})
if _, err := runtimeCommandParameterSpecs(cmd, "sample.run", RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), "load failed") {
if _, err := runtimeCommandParameterSpecs(cmd, "sample.run", nil, RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), "load failed") {
t.Fatalf("binding load error = %v", err)
}
testseam.Swap(t, &schemaParameterBindingData, func() (schemaParameterBindingSnapshot, error) {
@@ -145,7 +179,7 @@ func TestCrossPlatformCoverageRuntimeCommandParameterErrorEdges(t *testing.T) {
MappingExclusions: map[string]string{"sample.run --value": " "},
}, nil
})
if _, err := runtimeCommandParameterSpecs(cmd, "sample.run", RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), "mapping exclusion") {
if _, err := runtimeCommandParameterSpecs(cmd, "sample.run", nil, RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), "mapping exclusion") {
t.Fatalf("mapping exclusion error = %v", err)
}
testseam.Swap(t, &schemaParameterBindingData, func() (schemaParameterBindingSnapshot, error) {
@@ -160,22 +194,22 @@ func TestCrossPlatformCoverageRuntimeCommandParameterErrorEdges(t *testing.T) {
}
return resolveRuntimeSchemaCandidate(field, candidates...)
})
if _, err := runtimeCommandParameterSpecs(cmd, "sample.run", RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), target) {
if _, err := runtimeCommandParameterSpecs(cmd, "sample.run", nil, RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), target) {
t.Fatalf("%s resolution error = %v", target, err)
}
}
resolveRuntimeSchemaField = realResolver
if specs, err := runtimeCommandParameterSpecs(&cobra.Command{Use: "empty"}, "sample.empty", RuntimeSchemaConstraints{}); err != nil || specs != nil {
if specs, err := runtimeCommandParameterSpecs(&cobra.Command{Use: "empty"}, "sample.empty", nil, RuntimeSchemaConstraints{}); err != nil || specs != nil {
t.Fatalf("empty specs = %#v, err = %v", specs, err)
}
if payload, err := runtimeCommandParameters(nil, "", RuntimeSchemaConstraints{}); err != nil || payload != nil {
if payload, err := runtimeCommandParameters(nil, "", nil, RuntimeSchemaConstraints{}); err != nil || payload != nil {
t.Fatalf("empty payload = %#v, err = %v", payload, err)
}
testseam.Swap(t, &runtimeCommandParameterSpecsForPayload, func(*cobra.Command, string, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
testseam.Swap(t, &runtimeCommandParameterSpecsForPayload, func(*cobra.Command, string, map[string]embeddedMCPParamMeta, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
return []ParameterSpec{{Name: "bad", Example: json.RawMessage("{")}}, nil
})
if _, err := runtimeCommandParameters(cmd, "sample.run", RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), "serialize Schema parameter") {
if _, err := runtimeCommandParameters(cmd, "sample.run", nil, RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), "serialize Schema parameter") {
t.Fatalf("payload serialization error = %v", err)
}
@@ -184,21 +218,32 @@ func TestCrossPlatformCoverageRuntimeCommandParameterErrorEdges(t *testing.T) {
t.Fatalf("required annotation = %v/%v", required, present)
}
// Binding snapshot still supplies reviewed property mappings without MCP pin.
// Fixture MCP-shaped maps still participate when explicitly injected.
testseam.Swap(t, &schemaParameterBindingData, func() (schemaParameterBindingSnapshot, error) {
return schemaParameterBindingSnapshot{
Bindings: map[string]map[string]string{"sample.run": {"value": "clawType"}},
}, nil
})
specs, err := runtimeCommandParameterSpecs(cmd, "sample.run", RuntimeSchemaConstraints{})
requiredTrue := true
specs, err := runtimeCommandParameterSpecs(cmd, "sample.run", map[string]embeddedMCPParamMeta{
"clawType": {
Type: "string",
Description: "fixture description",
Required: &requiredTrue,
Default: "fixture-default",
},
}, RuntimeSchemaConstraints{})
if err != nil {
t.Fatalf("parameter specs error = %v", err)
t.Fatalf("fixture pinned parameter specs error = %v", err)
}
if len(specs) != 1 || specs[0].Property != "clawType" {
t.Fatalf("parameter specs = %#v", specs)
if len(specs) != 1 || specs[0].Property != "clawType" || specs[0].InterfaceDescription != "fixture description" {
t.Fatalf("fixture pinned parameter specs = %#v", specs)
}
if prov := specs[0].FieldProvenance["property"]; prov.Source == "" {
t.Fatalf("property provenance missing: %#v", specs[0].FieldProvenance)
if len(specs[0].InterfaceDefault) == 0 {
t.Fatalf("fixture interface_default missing: %#v", specs[0])
}
if prov := specs[0].FieldProvenance["required"]; prov.Source == "" {
t.Fatalf("fixture required provenance missing: %#v", specs[0].FieldProvenance)
}
}
@@ -228,6 +273,9 @@ func TestCrossPlatformCoverageRuntimeSchemaPureHelperEdges(t *testing.T) {
if !reflect.DeepEqual(groups, [][]string{{"one"}}) {
t.Fatalf("normalized groups = %#v", groups)
}
if meta, ok := lookupPinnedMCPParam(map[string]embeddedMCPParamMeta{"flag": {Type: "string"}}, "property", "flag"); !ok || meta.Type != "string" {
t.Fatalf("flag fallback metadata = %#v/%v", meta, ok)
}
if isGenericPayloadFlag(nil) {
t.Fatal("nil flag cannot be a generic payload")
}
@@ -274,75 +322,4 @@ func TestCrossPlatformCoverageSchemaCompactProjectionEdges(t *testing.T) {
if _, exists := value["property"]; exists {
t.Fatalf("compact parameter value = %#v", value)
}
// Non-parameter nested maps fall through to payload compacting.
nested := stripSchemaValueCompact(map[string]any{"description": "keep", "provenance": "drop"}).(map[string]any)
if nested["description"] != "keep" {
t.Fatalf("nested non-param map = %#v", nested)
}
if _, exists := nested["provenance"]; exists {
t.Fatalf("nested non-param provenance should drop: %#v", nested)
}
// Type-only maps still count as parameter objects.
typedOnly := stripSchemaValueCompact(map[string]any{"type": "string", "property": "remote"}).(map[string]any)
if _, exists := typedOnly["property"]; exists {
t.Fatalf("type-only param value = %#v", typedOnly)
}
mapSlice := stripSchemaValueCompact([]map[string]any{{"description": "leaf", "provenance": "drop"}}).([]map[string]any)
if len(mapSlice) != 1 || mapSlice[0]["description"] != "leaf" {
t.Fatalf("value compact []map = %#v", mapSlice)
}
if _, exists := mapSlice[0]["provenance"]; exists {
t.Fatalf("value compact []map provenance should drop: %#v", mapSlice)
}
anySlice := stripSchemaValueCompact([]any{map[string]any{"description": "leaf", "provenance": "drop"}, "raw"}).([]any)
if len(anySlice) != 2 || anySlice[1] != "raw" {
t.Fatalf("value compact []any = %#v", anySlice)
}
payload := map[string]any{
"description": "calendar",
"provenance": map[string]any{"source": "drop"},
"parameters": parameters,
"product": map[string]any{"description": "calendar", "provenance": "drop"},
"products": []map[string]any{
{"description": "calendar", "provenance": "drop"},
},
"tools": []any{
map[string]any{"description": "leaf", "provenance": "drop"},
"skip-me",
},
"constraints": map[string]any{"require_one_of": []any{}},
}
stripped := stripSchemaPayloadCompact(payload)
if stripped["description"] != "calendar" {
t.Fatalf("compact description = %#v", stripped["description"])
}
if _, exists := stripped["provenance"]; exists {
t.Fatalf("compact should drop provenance: %#v", stripped)
}
if product, ok := stripped["product"].(map[string]any); !ok || product["description"] != "calendar" {
t.Fatalf("compact product = %#v", stripped["product"])
}
if _, exists := stripped["product"].(map[string]any)["provenance"]; exists {
t.Fatalf("nested product provenance should drop: %#v", stripped["product"])
}
if products, ok := stripped["products"].([]map[string]any); !ok || len(products) != 1 || products[0]["description"] != "calendar" {
t.Fatalf("compact products = %#v", stripped["products"])
}
if tools, ok := stripped["tools"].([]any); !ok || len(tools) != 2 {
t.Fatalf("compact tools = %#v", stripped["tools"])
}
if tool, ok := stripped["tools"].([]any)[0].(map[string]any); !ok || tool["description"] != "leaf" {
t.Fatalf("compact tools[0] = %#v", stripped["tools"].([]any)[0])
}
if stripped["tools"].([]any)[1] != "skip-me" {
t.Fatalf("compact tools[1] = %#v", stripped["tools"].([]any)[1])
}
// Non-map product values are retained verbatim.
if got := stripSchemaPayloadCompact(map[string]any{"product": "raw"}); got["product"] != "raw" {
t.Fatalf("non-map product = %#v", got["product"])
}
if got := stripSchemaPayloadCollectionCompact("raw"); got != "raw" {
t.Fatalf("non-collection compact = %#v", got)
}
}
+19
View File
@@ -189,6 +189,25 @@ func cloneFieldCandidates(source []contract.FieldCandidateProvenance) []contract
return out
}
func lookupAgentToolMetadataFrom(source agentMetadata, paths ...string) (agentToolMetadata, bool) {
seen := map[string]bool{}
for _, path := range paths {
for _, candidate := range []string{
strings.TrimSpace(path),
strings.Join(splitSchemaPathTokens(path), " "),
} {
if candidate == "" || seen[candidate] {
continue
}
seen[candidate] = true
if metadata, ok := source.Tools[candidate]; ok {
return metadata, true
}
}
}
return agentToolMetadata{}, false
}
// agentMetadataSummaryFromProducts publishes Catalog-level Agent coverage from
// the assembled Schema surface (ContractFinal / ProductDecl). This keeps
// runtime delivery and CI dumps hash-aligned without requiring build-time
+122 -9
View File
@@ -20,6 +20,7 @@ import (
"testing/fstest"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
"github.com/spf13/cobra"
)
@@ -93,8 +94,9 @@ func TestRuntimeSchemaIncludesAgentMetadata(t *testing.T) {
// longer participates in assembly.
root := buildRuntimeSchemaTestRoot()
declareRuntimeSchemaTestRootDoc(t, root, nil)
mcpFixture := embeddedMCPMetadata{Tools: map[string]embeddedMCPToolMetadata{}}
leaf, err := runtimeSchemaPayloadForTest(root, []string{"doc.create_document"})
leaf, err := runtimeSchemaPayloadForTestWithMetadata(root, []string{"doc.create_document"}, emptyAgentMetadata(), mcpFixture)
if err != nil {
t.Fatalf("runtimeSchemaPayloadForTest(leaf): %v", err)
}
@@ -108,7 +110,7 @@ func TestRuntimeSchemaIncludesAgentMetadata(t *testing.T) {
t.Fatalf("leaf examples = %#v", leaf["examples"])
}
catalog, err := runtimeSchemaPayloadForTest(root, nil)
catalog, err := runtimeSchemaPayloadForTestWithMetadata(root, nil, emptyAgentMetadata(), mcpFixture)
if err != nil {
t.Fatalf("runtimeSchemaPayloadForTest(catalog): %v", err)
}
@@ -134,7 +136,7 @@ func TestRuntimeSchemaIncludesAgentMetadata(t *testing.T) {
t.Fatalf("product summary must not include examples: %#v", tools[0])
}
registry, err := schemaRegistryForTest(root)
registry, err := schemaRegistryForTestWithMetadata(root, emptyAgentMetadata(), mcpFixture)
if err != nil {
t.Fatalf("schemaRegistryForTest(): %v", err)
}
@@ -160,7 +162,7 @@ func TestRuntimeSchemaAllPayloadContainsFullLeafParameters(t *testing.T) {
// exercises the production assembly path.
root := buildRuntimeSchemaTestRoot()
declareRuntimeSchemaTestRootDoc(t, root, nil)
registry, err := schemaRegistryForTest(root)
registry, err := schemaRegistryForTestWithMetadata(root, emptyAgentMetadata(), embeddedMCPMetadata{})
if err != nil {
t.Fatal(err)
}
@@ -201,8 +203,9 @@ func schemaTestInt(value any) int {
}
func TestRuntimeSchemaUsesVersionedInterfaceRef(t *testing.T) {
// interface_ref declares on the leaf ContractFinal; MCP pin is not a
// parameter candidate source.
// interface_ref declares on the leaf ContractFinal; the injected MCP
// fixture participates through the gated fixture lookup (remapped via the
// declared Interface.Ref).
root := buildRuntimeSchemaTestRoot()
declareRuntimeSchemaTestRootDoc(t, root, func(payload *contract.ContractFinalPayload) {
payload.Interface = &contract.InterfaceSpec{
@@ -212,8 +215,17 @@ func TestRuntimeSchemaUsesVersionedInterfaceRef(t *testing.T) {
Ref: &contract.InterfaceRefSpec{ProductID: "documents", RPCName: "create_doc_v2"},
}
})
mcpFixture := embeddedMCPMetadata{
Tools: map[string]embeddedMCPToolMetadata{
"documents.create_doc_v2": {
Parameters: map[string]embeddedMCPParamMeta{
"title": {Description: "MCP document title"},
},
},
},
}
payload, err := runtimeSchemaPayloadForTest(root, []string{"doc.create_document"})
payload, err := runtimeSchemaPayloadForTestWithMetadata(root, []string{"doc.create_document"}, emptyAgentMetadata(), mcpFixture)
if err != nil {
t.Fatal(err)
}
@@ -221,11 +233,112 @@ func TestRuntimeSchemaUsesVersionedInterfaceRef(t *testing.T) {
if ref["product_id"] != "documents" || ref["rpc_name"] != "create_doc_v2" {
t.Fatalf("interface_ref = %#v", payload["interface_ref"])
}
if payload["interface_mode"] != contract.InterfaceModeMCP {
t.Fatalf("interface_mode = %#v", payload["interface_mode"])
parameters, _ := payload["parameters"].(map[string]any)
title, _ := parameters["title"].(map[string]any)
if title["interface_description"] != "MCP document title" {
t.Fatalf("title metadata = %#v", title)
}
}
func TestMCPRequiredParticipatesInSourcePrecedence(t *testing.T) {
required := true
mcpFixture := embeddedMCPMetadata{
Tools: map[string]embeddedMCPToolMetadata{
"sample.list_items": {
Parameters: map[string]embeddedMCPParamMeta{
"limit": {Required: &required},
},
},
},
}
root := &cobra.Command{Use: "dws"}
list := &cobra.Command{Use: "list", Run: func(*cobra.Command, []string) {}}
list.Flags().Int("limit", 0, "optional page size")
AttachRuntimeSchema(list, "sample", "list_items", "test")
sample := &cobra.Command{Use: "sample"}
sample.AddCommand(list)
root.AddCommand(sample)
declareSampleListItemsLeaf(t, list)
payload, err := runtimeSchemaPayloadForTestWithMetadata(root, []string{"sample.list_items"}, emptyAgentMetadata(), mcpFixture)
if err != nil {
t.Fatal(err)
}
parameters, _ := payload["parameters"].(map[string]any)
limit, _ := parameters["limit"].(map[string]any)
if limit["required"] != true {
t.Fatalf("MCP required candidate did not win over the default: %#v", limit)
}
}
func TestMCPDefaultDoesNotOverrideCLIDefault(t *testing.T) {
mcpFixture := embeddedMCPMetadata{
Tools: map[string]embeddedMCPToolMetadata{
"sample.list_items": {
Parameters: map[string]embeddedMCPParamMeta{
"limit": {Default: "50"},
},
},
},
}
root := &cobra.Command{Use: "dws"}
list := &cobra.Command{Use: "list", Run: func(*cobra.Command, []string) {}}
list.Flags().Int("limit", 10, "optional page size")
AttachRuntimeSchema(list, "sample", "list_items", "test")
sample := &cobra.Command{Use: "sample"}
sample.AddCommand(list)
root.AddCommand(sample)
declareSampleListItemsLeaf(t, list)
payload, err := runtimeSchemaPayloadForTestWithMetadata(root, []string{"sample.list_items"}, emptyAgentMetadata(), mcpFixture)
if err != nil {
t.Fatal(err)
}
parameters, _ := payload["parameters"].(map[string]any)
limit, _ := parameters["limit"].(map[string]any)
if limit["default"] != "10" || limit["interface_default"] != "50" {
t.Fatalf("CLI and interface defaults were not separated: %#v", limit)
}
}
// declareSampleListItemsLeaf registers the ContractFinal / ProductDecl
// declarations for the synthetic sample.list_items leaf so MCP fixture tests
// assemble through the production path.
func declareSampleListItemsLeaf(t *testing.T, list *cobra.Command) {
t.Helper()
contractfinal.RegisterRuntimeContractFinal(list, contract.ContractFinalPayload{
Identity: &contract.ToolIdentitySpec{
ProductID: "sample", Name: "list_items", CanonicalPath: "sample.list_items",
CLIPath: "sample list", PrimaryCLIPath: "sample list",
},
Title: "List items",
Description: "List sample items",
Safety: &contract.SafetySpec{
Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent",
},
Interface: &contract.InterfaceSpec{
Mode: "local", Availability: "available", Reason: "test local leaf",
},
Selection: &contract.SelectionSpec{
AgentSummary: "List sample items",
UseWhen: []string{"list sample items"},
AvoidWhen: []string{"not listing"},
},
})
t.Cleanup(func() { contractfinal.ClearRuntimeContractFinalForTest(list) })
contract.RegisterProductDecl(contract.ProductDecl{
ID: "sample",
Selection: contract.ProductSelectionDecl{
AgentSummary: "Sample product",
UseWhen: []string{"sample routing"},
AvoidWhen: []string{"not sample"},
},
})
t.Cleanup(func() { contract.ClearProductDeclForTest("sample") })
}
func findSchemaProduct(products []map[string]any, id string) map[string]any {
for _, product := range products {
if product["id"] == id {
+3 -7
View File
@@ -508,10 +508,6 @@ func TestStripSchemaPayloadCompactLeaf(t *testing.T) {
if err != nil {
t.Fatal(err)
}
leaf["future_audit_field"] = "must not leak into Agent view"
for _, raw := range schemaMap(leaf["parameters"]) {
raw["future_mapping_field"] = "must not leak into Agent view"
}
stripped := stripSchemaPayloadCompact(leaf)
// Must keep agent-essential fields.
@@ -522,7 +518,7 @@ func TestStripSchemaPayloadCompactLeaf(t *testing.T) {
}
// Must strip provenance / redundant fields.
for _, key := range []string{"agent_metadata_source", "agent_source_refs", "agent_summary_source", "effect_source", "metadata_source", "primary_cli_path", "parameter_count", "has_parameters", "interface_ref", "source", "title", "display", "future_audit_field"} {
for _, key := range []string{"agent_metadata_source", "agent_source_refs", "agent_summary_source", "effect_source", "metadata_source", "primary_cli_path", "parameter_count", "has_parameters", "interface_ref", "source", "title", "display"} {
if _, ok := stripped[key]; ok {
t.Fatalf("compact leaf still contains stripped key %q", key)
}
@@ -532,7 +528,7 @@ func TestStripSchemaPayloadCompactLeaf(t *testing.T) {
if params, ok := stripped["parameters"].(map[string]any); ok {
for name, p := range params {
if pm, ok := p.(map[string]any); ok {
for _, stripped := range []string{"interface_description", "interface_type", "property", "future_mapping_field"} {
for _, stripped := range []string{"interface_description", "interface_type", "property"} {
if _, present := pm[stripped]; present {
t.Fatalf("compact param %q still contains %q", name, stripped)
}
@@ -1308,7 +1304,7 @@ func TestDeliveryCatalogContactParamDeclsMatchMergeBaseContract(t *testing.T) {
}
if want.interfaceType != "" {
prov := schemaMap(param["field_provenance"])["interface_type"]
if src, _ := prov["source"].(string); src != "native_annotation" {
if src, _ := prov["source"].(string); src != "native_annotation" && src != "mcp_metadata" {
t.Fatalf("%s --%s interface_type source = %#v", tc.path, flagName, prov)
}
}
+4 -1
View File
@@ -20,7 +20,7 @@ package cli
var reviewedRuntimeSchemaExclusionGroups = []runtimeSchemaExclusionGroup{
{
ID: "cli-management",
Reason: "Local CLI lifecycle, authentication, configuration, and plugin-management commands are user-operated controls rather than stable Agent tools.",
Reason: "Local CLI lifecycle, authentication, configuration, recovery, and plugin-management commands are user-operated controls rather than stable Agent tools.",
Reviewed: true,
Commands: []string{
"api",
@@ -53,6 +53,9 @@ var reviewedRuntimeSchemaExclusionGroups = []runtimeSchemaExclusionGroup{
"profile list",
"profile switch",
"profile use",
"recovery execute",
"recovery finalize",
"recovery plan",
"schema",
"skill get",
"skill install",
+2 -2
View File
@@ -137,8 +137,8 @@ func schemaToolSpecFromPayload(payload map[string]any) (ToolSpec, error) {
// runtimeCommandParameters is the compatibility wire adapter used only by
// tests; resolution happens in runtimeCommandParameterSpecs.
func runtimeCommandParameters(cmd *cobra.Command, canonicalPath string, constraints RuntimeSchemaConstraints) (map[string]any, error) {
specs, err := runtimeCommandParameterSpecsForPayload(cmd, canonicalPath, constraints)
func runtimeCommandParameters(cmd *cobra.Command, canonicalPath string, pinnedParams map[string]embeddedMCPParamMeta, constraints RuntimeSchemaConstraints) (map[string]any, error) {
specs, err := runtimeCommandParameterSpecsForPayload(cmd, canonicalPath, pinnedParams, constraints)
if err != nil {
return nil, err
}
@@ -969,16 +969,16 @@ func TestOverallCoverageGapDeliveryCompletenessAndDryRun(t *testing.T) {
func TestOverallCoverageGapRuntimeParamsAndAgentMetadata(t *testing.T) {
prevSpecs := runtimeCommandParameterSpecsForPayload
t.Cleanup(func() { runtimeCommandParameterSpecsForPayload = prevSpecs })
runtimeCommandParameterSpecsForPayload = func(*cobra.Command, string, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
runtimeCommandParameterSpecsForPayload = func(*cobra.Command, string, map[string]embeddedMCPParamMeta, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
return nil, fmt.Errorf("specs boom")
}
if _, err := runtimeCommandParameters(&cobra.Command{Use: "run"}, "sample.run", RuntimeSchemaConstraints{}); err == nil {
if _, err := runtimeCommandParameters(&cobra.Command{Use: "run"}, "sample.run", nil, RuntimeSchemaConstraints{}); err == nil {
t.Fatal("parameter specs error must surface")
}
runtimeCommandParameterSpecsForPayload = func(*cobra.Command, string, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
runtimeCommandParameterSpecsForPayload = func(*cobra.Command, string, map[string]embeddedMCPParamMeta, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
return []ParameterSpec{{Name: "ok", Type: "string"}}, nil
}
payload, err := runtimeCommandParameters(&cobra.Command{Use: "run"}, "sample.run", RuntimeSchemaConstraints{})
payload, err := runtimeCommandParameters(&cobra.Command{Use: "run"}, "sample.run", nil, RuntimeSchemaConstraints{})
if err != nil || payload["ok"] == nil {
t.Fatalf("parameter payload = %#v err=%v", payload, err)
}
@@ -1070,6 +1070,24 @@ func TestOverallCoverageGapRuntimeParamsAndAgentMetadata(t *testing.T) {
}
func TestCrossPlatformCoverageOverallRegressionRecovery(t *testing.T) {
if _, ok := lookupPinnedMCPParam(nil, "property", "flag"); ok {
t.Fatal("nil pinned params must miss")
}
if _, ok := lookupPinnedMCPParam(map[string]embeddedMCPParamMeta{}, "property", "flag"); ok {
t.Fatal("empty pinned params must miss")
}
if _, ok := lookupPinnedMCPParam(map[string]embeddedMCPParamMeta{"other": {Type: "string"}}, "property", "flag"); ok {
t.Fatal("unmatched pinned params must miss")
}
if _, ok := pinnedMCPMetadataForEntryFrom(runtimeSchemaEntry{}, agentMetadata{}, embeddedMCPMetadata{}); ok {
t.Fatal("empty MCP metadata must not match")
}
if _, ok := pinnedMCPMetadataForEntryFrom(runtimeSchemaEntry{}, agentMetadata{}, embeddedMCPMetadata{
Tools: map[string]embeddedMCPToolMetadata{"other.key": {}},
}); ok {
t.Fatal("missing MCP metadata keys must not match")
}
left := runtimeSchemaStringCandidateAtPriority("same", true, "z-source", 5, "p")
right := runtimeSchemaStringCandidateAtPriority("same", true, "a-source", 5, "p")
winner, err := resolveRuntimeSchemaCandidate("source-order", left, right)
@@ -410,7 +410,7 @@ func TestRuntimeCommandParameterSpecsPreserveReviewedEmptyPropertyProvenance(t *
cmd := &cobra.Command{Use: "query"}
cmd.Flags().Bool("all", false, "fetch every page")
parameters, err := runtimeCommandParameterSpecs(cmd, "aitable.query_records", RuntimeSchemaConstraints{})
parameters, err := runtimeCommandParameterSpecs(cmd, "aitable.query_records", nil, RuntimeSchemaConstraints{})
if err != nil {
t.Fatal(err)
}
@@ -448,7 +448,6 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
"drive.download_file --output": "local output path",
"drive.download_file --parallel": "local multipart download control; never sent to download_file",
"drive.download_file --part-size": "local multipart download control; never sent to download_file",
"drive.download_file --version": "Polymorphic dispatch: --version switches the MCP tool call from download_file to download_file_version; not a download_file interface property",
"drive.download_file_version --no-resume": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --no-resume is a CLI-local multipart download control and does not publish a direct interface property.",
"drive.download_file_version --node": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"drive.download_file_version --output": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --output is a CLI wrapper input and does not publish a direct interface property.",
@@ -656,6 +655,7 @@ var reviewedSchemaParameterBindingRemovals = map[string]schemaParameterBindingRe
"contact.get_dept_info_by_dept_id --id": {Reason: "The public flag was renamed from --id to the unambiguous --dept spelling; successor binding retired to ParamDecl.Property on the owning leaf (Track 1 Phase 2).", Reviewed: true},
"contact.get_dept_members_by_deptId --ids": {Reason: "The public flag was renamed from --ids to the unambiguous --depts spelling; successor binding retired to ParamDecl.Property on the owning leaf (Track 1 Phase 2).", Reviewed: true},
"contact.get_sub_depts_by_dept_id --id": {Reason: "The public flag was renamed from --id to the unambiguous --dept spelling; successor binding retired to ParamDecl.Property on the owning leaf (Track 1 Phase 2).", Reviewed: true},
"drive.download_file --version": {Reason: "Polymorphic dispatch: --version switches the MCP tool call from download_file to download_file_version; the version property belongs to download_file_version metadata, not download_file.", Reviewed: true},
"minutes.query_user_tag_list --limit": {Reason: "The current helper and pinned interface have no pagination input.", Reviewed: true},
"oa.list_pending_approvals --size": {Reason: "The public pagination flag was normalized from --size to --limit; successor binding retired to ParamDecl.Property on the owning leaf (Track 1 Phase 2).", Reviewed: true},
"oa.list_user_visible_process --size": {Reason: "The public pagination flag was normalized from --size to --limit; successor binding retired to ParamDecl.Property on the owning leaf (Track 1 Phase 2).", Reviewed: true},
@@ -70,6 +70,18 @@ func schemaRegistryForTest(root *cobra.Command) (SchemaRegistry, error) {
return AssembleSchemaRegistryFromBound(bound)
}
func schemaRegistryForTestWithMetadata(root *cobra.Command, agent agentMetadata, mcp embeddedMCPMetadata) (SchemaRegistry, error) {
bound, err := boundTestCommandRegistry(root)
if err != nil {
return SchemaRegistry{}, err
}
// Production-shaped assembly: leaves must carry ContractFinal and products
// a ProductDecl (see declareRuntimeSchemaTestRootDoc). Injected MCP/agent
// fixtures participate only through the gated fixture lookup in
// runtimeToolSpecFromContractFinal; production passes an empty pin.
return assembleSchemaRegistryFromBound(bound, runtimeSchemaMetadataSources{Agent: agent, MCP: mcp})
}
// declareRuntimeSchemaTestRootDoc registers the ContractFinal / ProductDecl
// declarations for the synthetic doc.create_document tree built by
// buildRuntimeSchemaTestRoot, so production-shaped assembly can resolve it.
@@ -144,6 +156,18 @@ func runtimeSchemaPayloadForTest(root *cobra.Command, args []string) (map[string
return schemaPayloadFromLoadedCatalog(loaded, args)
}
func runtimeSchemaPayloadForTestWithMetadata(root *cobra.Command, args []string, agent agentMetadata, mcp embeddedMCPMetadata) (map[string]any, error) {
registry, err := schemaRegistryForTestWithMetadata(root, agent, mcp)
if err != nil {
return nil, err
}
loaded, err := loadedSchemaCatalogForTestRegistry(registry)
if err != nil {
return nil, err
}
return schemaPayloadFromLoadedCatalog(loaded, args)
}
func runtimeSchemaAllPayloadForTest(root *cobra.Command) (map[string]any, error) {
registry, err := schemaRegistryForTest(root)
if err != nil {
+16 -11
View File
@@ -15,10 +15,8 @@ import (
)
type runtimeSchemaMetadataSources struct {
// Agent remains only for historical test seams that still construct this
// struct; production assembly does not overlay Agent or MCP pin onto
// parameters or tool text.
Agent agentMetadata
MCP embeddedMCPMetadata
}
var (
@@ -64,9 +62,10 @@ func (resolved ResolvedSchemaBuild) CommandCount() int {
}
func pinnedRuntimeSchemaMetadataSources() runtimeSchemaMetadataSources {
// Production pin and Agent inject are both retired; assembly is Contract /
// ParamDecl / Cobra only.
return runtimeSchemaMetadataSources{}
return runtimeSchemaMetadataSources{
Agent: runtimeAgentMetadata(),
MCP: emptyPinnedMCPMetadata(),
}
}
// ResolveSchemaBuild is the only assembly path from executable Cobra commands
@@ -124,7 +123,7 @@ func AssembleSchemaRegistryFromBound(bound BoundCommandRegistry) (SchemaRegistry
// assembleSchemaRegistryFromBound resolves every entry through the
// ContractFinal / ProductDecl production path. Missing declarations fail
// closed; retired skill/MCP-pin/agent-inject overlays are never reopened.
// closed; retired skill/MCP/agent-inject overlays are never reopened.
func assembleSchemaRegistryFromBound(bound BoundCommandRegistry, metadata runtimeSchemaMetadataSources) (SchemaRegistry, error) {
entries, err := assembleCollectEntries(bound)
if err != nil {
@@ -198,12 +197,18 @@ func assembleProductSelection(entry runtimeSchemaEntry) (contract.SelectionSpec,
// runtimeToolSpecFromContractFinal pass-throughs Contract-authored Schema fields.
// Declared values are the final data source; hints/registry text does not merge.
// MCP pin is retired: interface_type / interface_* facts come from ParamDecl /
// native annotations only.
// Production MCP pin is empty, so assembly skips MCP-metadata lookups entirely;
// interface_type / interface_* facts come from ParamDecl / native annotations.
// Tests may still inject a non-empty MCP fixture map, which participates through
// pinnedMCPMetadataForEntryFrom.
func runtimeToolSpecFromContractFinal(entry runtimeSchemaEntry, final contract.ContractFinalPayload, metadata runtimeSchemaMetadataSources) (ToolSpec, error) {
_ = metadata // reserved for historical assemble seams; no overlay sources remain
canonicalPath := entry.ProductID + "." + entry.ToolName
constraints := runtimeCommandConstraints(entry.Command)
var pinnedParams map[string]embeddedMCPParamMeta
if len(metadata.MCP.Tools) > 0 {
pinnedMeta, _ := pinnedMCPMetadataForEntryFrom(entry, metadata.Agent, metadata.MCP)
pinnedParams = pinnedMeta.Parameters
}
// Apply parameter declarations from the contract.ContractFinalPayload before the
// resolver reads them. The decls were put there by AttachContract at
// DeclareLeafMetadata time; now that all flags exist on the fully-built
@@ -211,7 +216,7 @@ func runtimeToolSpecFromContractFinal(entry runtimeSchemaEntry, final contract.C
if err := ApplyParamDecls(entry.Command, final.Parameters); err != nil {
return ToolSpec{}, fmt.Errorf("apply Contract Schema ParamDecls for %s: %w", canonicalPath, err)
}
parameters, err := resolveRuntimeParameters(entry.Command, canonicalPath, constraints)
parameters, err := resolveRuntimeParameters(entry.Command, canonicalPath, pinnedParams, constraints)
if err != nil {
return ToolSpec{}, fmt.Errorf("resolve Contract Schema parameters for %s: %w", canonicalPath, err)
}
@@ -278,10 +278,23 @@ func TestCrossPlatformCoverageRenderSafetyAnnotationSuccess(t *testing.T) {
}
func TestCrossPlatformCoverageMCPMetadataInterfaceRefEdges(t *testing.T) {
// MCP pin lookup helpers are retired; keep this named coverage slot as a
// no-op marker so CrossPlatformCoverage* selection stays stable.
if got := emptyPinnedMCPMetadata(); got.Tools == nil || len(got.Tools) != 0 {
t.Fatalf("empty pinned metadata = %#v", got)
if _, ok := mcpMetadataForInterfaceRef(embeddedMCPMetadata{}, " ", " "); ok {
t.Fatal("blank interface ref must miss")
}
if _, ok := mcpMetadataForInterfaceRef(embeddedMCPMetadata{Tools: map[string]embeddedMCPToolMetadata{}}, "chat", "missing"); ok {
t.Fatal("missing MCP tool must miss")
}
agent := agentMetadata{Tools: map[string]agentToolMetadata{
"chat reply": {InterfaceRef: &embeddedMCPInterfaceRef{ProductID: "chat", RPCName: "send_personal_message"}},
}}
mcp := embeddedMCPMetadata{Tools: map[string]embeddedMCPToolMetadata{
"chat.send_personal_message": {Parameters: map[string]embeddedMCPParamMeta{"clawType": {Type: "string"}}},
}}
got, ok := pinnedMCPMetadataForEntryFrom(runtimeSchemaEntry{
PrimaryCLIPath: "chat reply", ProductID: "chat", ToolName: "reply_personal_message",
}, agent, mcp)
if !ok || got.Parameters["clawType"].Type != "string" {
t.Fatalf("agent InterfaceRef remap = %#v ok=%v", got, ok)
}
}
+2 -179
View File
@@ -230,7 +230,7 @@ func newCalendarCommand() *cobra.Command {
Long: `管理钉钉日历:日程、参会人、会议室、闲忙、附件、日历本、访问权限。调用前必须先使用 --help 查看参数结构。
命令结构:
dws calendar event [list|get|create|update|delete|suggest|respond|instances] 日程管理
dws calendar event [list|get|create|update|delete|suggest|respond] 日程管理
dws calendar attendee [list|add|delete] 参会人管理
dws calendar room [search|add|delete|list-groups] 会议室管理
dws calendar busy search 闲忙查询 (可查人、查会议室)
@@ -2082,184 +2082,7 @@ func newCalendarCommand() *cobra.Command {
eventSuggestCmd.Flags().String("members", "", "")
_ = eventSuggestCmd.Flags().MarkHidden("members")
eventSuggestCmd.Flags().String("duration", "", "日程持续时间 (分钟,默认30)")
eventInstancesCmd := &cobra.Command{
Use: "instances",
Short: "查询循环日程的实例列表",
Long: `查询指定重复性日程(SeriesMaster)在指定时间范围内的所有实例。
**注意**:此接口只能查询重复性日程的实例;若传入的是普通非循环日程,将查不到任何实例信息。
必须传入 --id 指定重复性日程的 eventId(即 SeriesMaster 的 eventId,可通过 event list 获取)。
不传 --start/--end 时,默认查询今天(00:00:00 ~ 23:59:59)的实例。`,
Example: ` dws calendar event instances --id EVENT_ID
dws calendar event instances --id EVENT_ID --start "2026-03-10T00:00:00+08:00" --end "2026-03-31T23:59:59+08:00"
dws calendar event instances --id EVENT_ID --limit 50
dws calendar event instances --id EVENT_ID --cursor "<nextCursor>"`,
RunE: func(cmd *cobra.Command, args []string) error {
eventID, err := mustFlagOrFallback(cmd, "id", "event", "event-id", "eventId")
if err != nil {
return err
}
toolArgs := map[string]any{"eventId": eventID}
var startTime, endTime int64
var now time.Time
if v := flagOrFallback(cmd, "start", "time-min", "min-time", "start-time", "startTime", "start_time", "start-date", "startDate"); v != "" {
startTime, err = parseISOTimeToMillis("start", v)
if err != nil {
return err
}
toolArgs["startTime"] = startTime
} else {
now = time.Now()
startTime = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).UnixMilli()
toolArgs["startTime"] = startTime
}
if v := flagOrFallback(cmd, "end", "time-max", "max-time", "end-time", "endTime", "end_time", "end-date", "endDate"); v != "" {
endTime, err = parseISOTimeToMillis("end", v)
if err != nil {
return err
}
toolArgs["endTime"] = endTime
} else {
if now.IsZero() {
now = time.Now()
}
endTime = time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location()).UnixMilli()
toolArgs["endTime"] = endTime
}
if err := validateTimeRange(startTime, endTime); err != nil {
return err
}
if v := flagOrFallback(cmd, "calendar-id", "calendarId", "calendar"); v != "" {
toolArgs["calendarId"] = v
}
if v := flagOrFallback(cmd, "cursor", "next-cursor", "nextCursor", "page-token", "pageToken", "next-token"); v != "" {
toolArgs["cursor"] = v
}
if lim, _ := cmd.Flags().GetInt("limit"); lim > 0 {
toolArgs["limit"] = lim
} else if lim, _ := cmd.Flags().GetInt("max-results"); lim > 0 {
toolArgs["limit"] = lim
} else if lim, _ := cmd.Flags().GetInt("maxResults"); lim > 0 {
toolArgs["limit"] = lim
} else if lim, _ := cmd.Flags().GetInt("page-size"); lim > 0 {
toolArgs["limit"] = lim
} else if lim, _ := cmd.Flags().GetInt("size"); lim > 0 {
toolArgs["limit"] = lim
} else if lim, _ := cmd.Flags().GetInt("count"); lim > 0 {
toolArgs["limit"] = lim
}
return callSortedCalendarEvents(cmd, "list_event_instances", toolArgs)
},
}
DeclareLeafMetadata(eventInstancesCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "calendar",
Name: "list_event_instances",
CanonicalPath: "calendar.list_event_instances",
CLIPath: "calendar event instances",
PrimaryCLIPath: "calendar event instances",
},
Description: "查询循环日程的实例列表",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "calendar", RPCName: "list_event_instances"},
},
Selection: contract.SelectionSpec{
AgentSummary: "查询循环日程在时间范围内展开的实例",
UseWhen: []string{"已知循环日程 eventId(SeriesMaster),需要列出某时间窗内的实例时"},
AvoidWhen: []string{
"普通非循环日程请用 dws calendar event get / list",
"未知 eventId 时先 dws calendar event list",
},
Examples: []string{
"dws calendar event instances --id <EVENT_ID>",
"dws calendar event instances --id <EVENT_ID> --start \"2026-03-10T00:00:00+08:00\" --end \"2026-03-31T23:59:59+08:00\"",
},
},
Parameters: []contract.ParamDecl{
{Name: "id", Property: "eventId", Required: boolPtr(true)},
{Name: "start", Property: "startTime"},
{Name: "end", Property: "endTime"},
{Name: "calendar-id", Property: "calendarId"},
{Name: "cursor", Property: "cursor"},
{Name: "limit", Property: "limit", InterfaceType: "integer"},
},
},
})
// InstancesEvent flags (aligned with event list aliases)
eventInstancesCmd.Flags().String("id", "", "日程 ID (必填)")
eventInstancesCmd.Flags().String("event", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("event")
eventInstancesCmd.Flags().String("event-id", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("event-id")
eventInstancesCmd.Flags().String("eventId", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("eventId")
eventInstancesCmd.Flags().String("start", "", "开始时间 ISO-8601 (例如 2026-03-10T00:00:00+08:00)")
eventInstancesCmd.Flags().String("time-min", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("time-min")
eventInstancesCmd.Flags().String("min-time", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("min-time")
eventInstancesCmd.Flags().String("start-time", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("start-time")
eventInstancesCmd.Flags().String("startTime", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("startTime")
eventInstancesCmd.Flags().String("start_time", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("start_time")
eventInstancesCmd.Flags().String("start-date", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("start-date")
eventInstancesCmd.Flags().String("startDate", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("startDate")
eventInstancesCmd.Flags().String("end", "", "结束时间 ISO-8601 (例如 2026-03-31T23:59:59+08:00)")
eventInstancesCmd.Flags().String("time-max", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("time-max")
eventInstancesCmd.Flags().String("max-time", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("max-time")
eventInstancesCmd.Flags().String("end-time", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("end-time")
eventInstancesCmd.Flags().String("endTime", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("endTime")
eventInstancesCmd.Flags().String("end_time", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("end_time")
eventInstancesCmd.Flags().String("end-date", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("end-date")
eventInstancesCmd.Flags().String("endDate", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("endDate")
eventInstancesCmd.Flags().String("calendar-id", "", "日历 ID (可选,默认 primary 主日历;指定其他日历本时填写,可通过 book list 获取)")
eventInstancesCmd.Flags().String("calendarId", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("calendarId")
eventInstancesCmd.Flags().String("calendar", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("calendar")
eventInstancesCmd.Flags().String("cursor", "", "分页游标 (首次查询无需传入,仅翻页时传入上一次返回的 nextCursor)")
eventInstancesCmd.Flags().String("next-cursor", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("next-cursor")
eventInstancesCmd.Flags().String("nextCursor", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("nextCursor")
eventInstancesCmd.Flags().String("page-token", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("page-token")
eventInstancesCmd.Flags().String("pageToken", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("pageToken")
eventInstancesCmd.Flags().String("next-token", "", "")
_ = eventInstancesCmd.Flags().MarkHidden("next-token")
eventInstancesCmd.Flags().Int("limit", 0, "每页返回条数 (默认 100,最大 100)")
eventInstancesCmd.Flags().Int("max-results", 0, "")
_ = eventInstancesCmd.Flags().MarkHidden("max-results")
eventInstancesCmd.Flags().Int("maxResults", 0, "")
_ = eventInstancesCmd.Flags().MarkHidden("maxResults")
eventInstancesCmd.Flags().Int("page-size", 0, "")
_ = eventInstancesCmd.Flags().MarkHidden("page-size")
eventInstancesCmd.Flags().Int("size", 0, "")
_ = eventInstancesCmd.Flags().MarkHidden("size")
eventInstancesCmd.Flags().Int("count", 0, "")
_ = eventInstancesCmd.Flags().MarkHidden("count")
eventCmd.AddCommand(eventListCmd, eventGetCmd, eventCreateCmd, eventUpdateCmd, eventDeleteCmd, eventSuggestCmd, eventRespondCmd, eventInstancesCmd)
eventCmd.AddCommand(eventListCmd, eventGetCmd, eventCreateCmd, eventUpdateCmd, eventDeleteCmd, eventSuggestCmd, eventRespondCmd)
// participant
participantCmd.PersistentFlags().String("event", "", "日程 ID (必填)")
+1
View File
@@ -144,6 +144,7 @@ var (
"clear": {},
"refresh": {},
"recover": {},
"recovery": {},
"login": {},
"logout": {},
"register": {},
+22
View File
@@ -48,6 +48,28 @@ var crossProductAliases = []crossProductAliasGroup{
{semantic: "内容文件路径", names: []string{"content-file", "content-path"}},
}
// crossProductAliasPeers 返回与 name 同语义组的其余 flag 名(不含 name 自身)。
// 互斥/冲突校验用它枚举 RegisterCrossProductAliases 自动注册的隐藏别名,
// 避免手写枚举漏掉自动别名(如 --limit 语义组的 --page-size)。
// name 不属于任何语义组时返回 nil。
func crossProductAliasPeers(name string) []string {
for _, group := range crossProductAliases {
for _, candidate := range group.names {
if candidate != name {
continue
}
peers := make([]string, 0, len(group.names)-1)
for _, peer := range group.names {
if peer != name {
peers = append(peers, peer)
}
}
return peers
}
}
return nil
}
// RegisterCrossProductAliases automatically registers hidden alias flags for
// cross-product compatibility. Call this once after all primary flags are registered.
//
@@ -0,0 +1,42 @@
package helpers
import (
"slices"
"testing"
)
func TestCrossPlatformCoverageCrossProductAliasPeers(t *testing.T) {
// 组内任一成员都能查到其余成员,且结果不含自身。
for _, group := range crossProductAliases {
for _, name := range group.names {
peers := crossProductAliasPeers(name)
if len(peers) != len(group.names)-1 {
t.Fatalf("peers(%q) = %#v, want %d entries", name, peers, len(group.names)-1)
}
if slices.Contains(peers, name) {
t.Fatalf("peers(%q) contains itself: %#v", name, peers)
}
for _, peer := range peers {
if !slices.Contains(group.names, peer) {
t.Fatalf("peers(%q) leaked %q from another group", name, peer)
}
}
}
}
// drive list 的分页互斥(driveListLimitConflict / driveListCursorConflict)依赖这两组:
// 改动注册表若挪走这些别名,会在此失败而不是静默丢失互斥覆盖。
if peers := crossProductAliasPeers("limit"); !slices.Contains(peers, "page-size") {
t.Fatalf("limit peers = %#v, want page-size", peers)
}
for _, want := range []string{"page-token", "next-token"} {
if peers := crossProductAliasPeers("cursor"); !slices.Contains(peers, want) {
t.Fatalf("cursor peers = %#v, want %s", peers, want)
}
}
// 不属于任何语义组的 flag 返回 nil。
if peers := crossProductAliasPeers("latest"); peers != nil {
t.Fatalf("peers(latest) = %#v, want nil", peers)
}
}
@@ -15,7 +15,6 @@ import (
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
@@ -81,101 +80,6 @@ func requireTypedConfirmationError(t *testing.T, err error) {
}
}
type guardedStepCaller struct {
calls []guardedMutationCall
steps []string
index int
dryRun bool
}
func (c *guardedStepCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
c.calls = append(c.calls, guardedMutationCall{productID: productID, toolName: toolName, args: args})
text := `{}`
if c.index < len(c.steps) {
text = c.steps[c.index]
}
c.index++
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
}
func (*guardedStepCaller) Format() string { return "json" }
func (c *guardedStepCaller) DryRun() bool { return c.dryRun }
func (*guardedStepCaller) Fields() string { return "" }
func (*guardedStepCaller) JQ() string { return "" }
func executeGuardedMailMutationCommand(t *testing.T, caller edition.ToolCaller, args ...string) error {
t.Helper()
testseam.Protect(t, &os.Args)
os.Args = append([]string{"dws", "mail"}, args...)
previousDeps := deps
t.Cleanup(func() { deps = previousDeps })
InitDeps(caller)
deps.Out.w = io.Discard
root := newMailCommand()
root.SilenceErrors = true
root.SilenceUsage = true
if root.InOrStdin() == os.Stdin {
root.SetIn(strings.NewReader(""))
}
root.SetArgs(args)
return root.Execute()
}
func TestMailMessageShareToChatRequiresConfirmationBeforeToolCall(t *testing.T) {
baseArgs := []string{
"message", "share-to-chat",
"--email", "user@company.com",
"--id", "msg-1",
"--users", "uid1",
}
caller := &guardedMutationCaller{}
err := executeGuardedMailMutationCommand(t, caller, baseArgs...)
requireTypedConfirmationError(t, err)
if len(caller.calls) != 0 {
t.Fatalf("tool calls = %#v, want none before confirmation", caller.calls)
}
stepCaller := &guardedStepCaller{steps: []string{
`{"result":{"sign":"sig","riskMessage":"careful"}}`,
`{"ok":true}`,
}}
err = executeGuardedMailMutationCommand(t, stepCaller, append(append([]string(nil), baseArgs...), "--yes")...)
if err != nil {
t.Fatalf("confirmed share with sign returned error: %v", err)
}
if len(stepCaller.calls) != 2 {
t.Fatalf("tool calls = %d, want 2 for sign retry", len(stepCaller.calls))
}
if stepCaller.calls[0].toolName != "share_message_to_chat" || stepCaller.calls[1].toolName != "share_message_to_chat" {
t.Fatalf("tool calls = %#v, want share_message_to_chat twice", stepCaller.calls)
}
if sign, _ := stepCaller.calls[1].args["sign"].(string); sign != "sig" {
t.Fatalf("second call sign = %q, want sig", sign)
}
directCaller := &guardedStepCaller{steps: []string{`{"ok":true}`}}
err = executeGuardedMailMutationCommand(t, directCaller, append(append([]string(nil), baseArgs...), "--yes")...)
if err != nil {
t.Fatalf("confirmed direct success returned error: %v", err)
}
if len(directCaller.calls) != 1 {
t.Fatalf("tool calls = %d, want 1 for direct success", len(directCaller.calls))
}
if directCaller.calls[0].toolName != "share_message_to_chat" {
t.Fatalf("tool call = %#v, want share_message_to_chat", directCaller.calls[0])
}
dryRunCaller := &guardedMutationCaller{dryRun: true}
err = executeGuardedMailMutationCommand(t, dryRunCaller, baseArgs...)
if err != nil {
t.Fatalf("dry-run without --yes returned error: %v", err)
}
if len(dryRunCaller.calls) != 0 {
t.Fatalf("dry-run tool calls = %#v, want none", dryRunCaller.calls)
}
}
func TestChatDismissGroupRequiresConfirmationBeforeToolCall(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newChatCommand,
-282
View File
@@ -1,282 +0,0 @@
// Copyright 2022 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// This file is adapted from Go standard library's internal/diff package.
// The original source can be found at /usr/local/go/src/internal/diff/diff.go.
//
// Modifications:
// - Package changed from "diff" to "products"
// - Diff() renamed to UnifiedDiff() and accepts a contextLines parameter
// - const C replaced with the contextLines parameter
package helpers
import (
"bytes"
"fmt"
"sort"
"strings"
)
// A pair is a pair of values tracked for both the x and y side of a diff.
// It is typically a pair of line indexes.
type diffPair struct{ x, y int }
func nonNeg(v int) int {
if v < 0 {
return 0
}
return v
}
// UnifiedDiff returns an anchored diff of the two texts old and new
// in the "unified diff" format. If old and new are identical,
// UnifiedDiff returns a nil slice (no output).
//
// Unix diff implementations typically look for a diff with
// the smallest number of lines inserted and removed,
// which can in the worst case take time quadratic in the
// number of lines in the texts. As a result, many implementations
// either can be made to run for a long time or cut off the search
// after a predetermined amount of work.
//
// In contrast, this implementation looks for a diff with the
// smallest number of "unique" lines inserted and removed,
// where unique means a line that appears just once in both old and new.
// We call this an "anchored diff" because the unique lines anchor
// the chosen matching regions. An anchored diff is usually clearer
// than a standard diff, because the algorithm does not try to
// reuse unrelated blank lines or closing braces.
// The algorithm also guarantees to run in O(n log n) time
// instead of the standard O(n²) time.
//
// Some systems call this approach a "patience diff," named for
// the "patience sorting" algorithm, itself named for a solitaire card game.
// We avoid that name for two reasons. First, the name has been used
// for a few different variants of the algorithm, so it is imprecise.
// Second, the name is frequently interpreted as meaning that you have
// to wait longer (to be patient) for the diff, meaning that it is a slower algorithm,
// when in fact the algorithm is faster than the standard one.
func UnifiedDiff(oldName string, old []byte, newName string, new []byte, contextLines int) []byte {
if bytes.Equal(old, new) {
return nil
}
x := diffLines(old)
y := diffLines(new)
// Print diff header.
var out bytes.Buffer
fmt.Fprintf(&out, "diff %s %s\n", oldName, newName)
fmt.Fprintf(&out, "--- %s\n", oldName)
fmt.Fprintf(&out, "+++ %s\n", newName)
// Loop over matches to consider,
// expanding each match to include surrounding lines,
// and then printing diff chunks.
// To avoid setup/teardown cases outside the loop,
// tgs returns a leading {0,0} and trailing {len(x), len(y)} pair
// in the sequence of matches.
var (
done diffPair // printed up to x[:done.x] and y[:done.y]
chunk diffPair // start lines of current chunk
count diffPair // number of lines from each side in current chunk
ctext []string // lines for current chunk
)
for _, m := range diffTgs(x, y) {
if m.x < done.x {
// Already handled scanning forward from earlier match.
continue
}
// Expand matching lines as far as possible,
// establishing that x[start.x:end.x] == y[start.y:end.y].
// Note that on the first (or last) iteration we may (or definitely do)
// have an empty match: start.x==end.x and start.y==end.y.
start := m
for start.x > done.x && start.y > done.y && x[start.x-1] == y[start.y-1] {
start.x--
start.y--
}
end := m
for end.x < len(x) && end.y < len(y) && x[end.x] == y[end.y] {
end.x++
end.y++
}
// Emit the mismatched lines before start into this chunk.
// (No effect on first sentinel iteration, when start = {0,0}.)
for _, s := range x[done.x:start.x] {
ctext = append(ctext, "-"+s)
count.x++
}
for _, s := range y[done.y:start.y] {
ctext = append(ctext, "+"+s)
count.y++
}
// If we're not at EOF and have too few common lines,
// the chunk includes all the common lines and continues.
C := contextLines
if C < 0 {
// 防御性兼容:负值会破坏下方区间判定,按无上下文处理;0 是合法值,直接生效
C = 0
}
if (end.x < len(x) || end.y < len(y)) &&
(end.x-start.x < C || (len(ctext) > 0 && end.x-start.x < 2*C)) {
for _, s := range x[start.x:end.x] {
ctext = append(ctext, " "+s)
count.x++
count.y++
}
done = end
continue
}
// End chunk with common lines for context.
if len(ctext) > 0 {
n := end.x - start.x
if n > C {
n = C
}
for _, s := range x[start.x : start.x+n] {
ctext = append(ctext, " "+s)
count.x++
count.y++
}
done = diffPair{start.x + n, start.y + n}
// Format and emit chunk.
// Convert line numbers to 1-indexed.
// Special case: empty file shows up as 0,0 not 1,0.
if count.x > 0 {
chunk.x++
}
if count.y > 0 {
chunk.y++
}
fmt.Fprintf(&out, "@@ -%d,%d +%d,%d @@\n", chunk.x, count.x, chunk.y, count.y)
for _, s := range ctext {
out.WriteString(s)
}
count.x = 0
count.y = 0
ctext = ctext[:0]
}
// If we reached EOF, we're done.
if end.x >= len(x) && end.y >= len(y) {
break
}
// Otherwise start a new chunk.
// C is clamped to >= 0 above; nonNeg saturates end-C when the next
// hunk would start before line 0 (defensive; exercised via nonNeg tests).
chunk = diffPair{nonNeg(end.x - C), nonNeg(end.y - C)}
for _, s := range x[chunk.x:end.x] {
ctext = append(ctext, " "+s)
count.x++
count.y++
}
done = end
}
return out.Bytes()
}
// diffLines returns the lines in the file x, including newlines.
// If the file does not end in a newline, one is supplied
// along with a warning about the missing newline.
func diffLines(x []byte) []string {
l := strings.SplitAfter(string(x), "\n")
if l[len(l)-1] == "" {
l = l[:len(l)-1]
} else {
// Treat last line as having a message about the missing newline attached,
// using the same text as BSD/GNU diff (including the leading backslash).
l[len(l)-1] += "\n\\ No newline at end of file\n"
}
return l
}
// diffTgs returns the pairs of indexes of the longest common subsequence
// of unique lines in x and y, where a unique line is one that appears
// once in x and once in y.
//
// The longest common subsequence algorithm is as described in
// Thomas G. Szymanski, "A Special Case of the Maximal Common
// Subsequence Problem," Princeton TR #170 (January 1975),
// available at https://research.swtch.com/tgs170.pdf.
func diffTgs(x, y []string) []diffPair {
// Count the number of times each string appears in a and b.
// We only care about 0, 1, many, counted as 0, -1, -2
// for the x side and 0, -4, -8 for the y side.
// Using negative numbers now lets us distinguish positive line numbers later.
m := make(map[string]int)
for _, s := range x {
if c := m[s]; c > -2 {
m[s] = c - 1
}
}
for _, s := range y {
if c := m[s]; c > -8 {
m[s] = c - 4
}
}
// Now unique strings can be identified by m[s] = -1+-4.
//
// Gather the indexes of those strings in x and y, building:
// xi[i] = increasing indexes of unique strings in x.
// yi[i] = increasing indexes of unique strings in y.
// inv[i] = index j such that x[xi[i]] = y[yi[j]].
var xi, yi, inv []int
for i, s := range y {
if m[s] == -1+-4 {
m[s] = len(yi)
yi = append(yi, i)
}
}
for i, s := range x {
if j, ok := m[s]; ok && j >= 0 {
xi = append(xi, i)
inv = append(inv, j)
}
}
// Apply Algorithm A from Szymanski's paper.
// In those terms, A = J = inv and B = [0, n).
// We add sentinel pairs {0,0}, and {len(x),len(y)}
// to the returned sequence, to help the processing loop.
J := inv
n := len(xi)
T := make([]int, n)
L := make([]int, n)
for i := range T {
T[i] = n + 1
}
for i := 0; i < n; i++ {
k := sort.Search(n, func(k int) bool {
return T[k] >= J[i]
})
T[k] = J[i]
L[i] = k + 1
}
k := 0
for _, v := range L {
if k < v {
k = v
}
}
seq := make([]diffPair, 2+k)
seq[1+k] = diffPair{len(x), len(y)} // sentinel at end
lastj := n
for i := n - 1; i >= 0; i-- {
if L[i] == k && J[i] < lastj {
seq[k] = diffPair{xi[i], yi[J[i]]}
k--
}
}
seq[0] = diffPair{0, 0} // sentinel at start
return seq
}
+4 -11
View File
@@ -3657,13 +3657,11 @@ CLI 内部自动完成全部流程:
PROCESSING 处理中
SUCCESS 导出成功,返回 downloadUrl
FAILED 导出失败`,
Example: ` dws doc export get --job-id <JOB_ID>
dws doc export get --task-id <TASK_ID>`,
Example: ` dws doc export get --job-id <JOB_ID>`,
RunE: func(cmd *cobra.Command, _ []string) error {
// Keep --job-id as the visible primary; --task-id is an add-only synonym.
jobID, err := mustFlagOrFallback(cmd, "job-id", "task-id")
if err != nil {
return err
jobID := mustGetFlag(cmd, "job-id")
if jobID == "" {
return fmt.Errorf("flag --job-id is required")
}
if deps.Caller.DryRun() {
@@ -3730,14 +3728,9 @@ CLI 内部自动完成全部流程:
AvoidWhen: []string{"常规导出请直接 dws doc export(一体化提交+轮询+下载),不要先查 job"},
Examples: []string{"dws doc export get --job-id <JOB_ID> --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "job-id", Property: "jobId"},
},
},
})
exportGetCmd.Flags().String("job-id", "", "导出任务 ID (必填)")
exportGetCmd.Flags().String("task-id", "", "--job-id 的别名")
_ = exportGetCmd.Flags().MarkHidden("task-id")
// --node 的隐藏别名(与 doc 下其他命令保持一致)
exportCmd.Flags().String("url", "", "--node 的别名")
+31 -53
View File
@@ -317,25 +317,16 @@ func newDriveCommand() *cobra.Command {
dws drive list --folder <dentryUuid> --order-by name --order asc
dws drive list --workspace <workspaceId>
dws drive list --workspace <workspaceId> --folder <folderId>
dws drive list --latest 5
dws drive list --folder <dentryUuid> --latest 3 --pattern "*.docx"`,
dws drive list --pattern "*日报*" --latest 3
dws drive list --workspace <workspaceId> --latest 5`,
RunE: func(cmd *cobra.Command, args []string) error {
pattern, _ := cmd.Flags().GetString("pattern")
depth, _ := cmd.Flags().GetInt("depth")
latest, _ := cmd.Flags().GetInt("latest")
if cmd.Flags().Changed("latest") {
if err := validateDriveListLatest(cmd, latest); err != nil {
return err
}
if cmd.Flags().Changed("versions") {
return &CLIError{Code: CodeInvalidParam, Message: "--latest 不能与 --versions 同时使用"}
}
}
// --versions 模式:列出文件历史版本(仅普通文件)
// 先于 --depth 校验执行:versions 模式合法使用 --limit,
// 先于 --depth/--latest 校验执行:versions 模式合法使用 --limit,
// 不应被「--limit 与 --depth 不兼容」的误导性报错拦截。
if cmd.Flags().Changed("versions") {
if cmd.Flags().Changed("depth") && depth > 1 {
@@ -344,6 +335,12 @@ func newDriveCommand() *cobra.Command {
Message: "--versions 与 --depth 不能同时使用",
}
}
if cmd.Flags().Changed("latest") {
return &CLIError{
Code: CodeInvalidParam,
Message: "--versions 与 --latest 不能同时使用:版本列表按版本序返回,无跨文件 Top-N 语义",
}
}
if pattern != "" {
return &CLIError{
Code: CodeInvalidParam,
@@ -370,11 +367,20 @@ func newDriveCommand() *cobra.Command {
}
}
if cmd.Flags().Changed("latest") {
if err := validateDriveListLatest(cmd, latest); err != nil {
return err
}
}
// 如果指定了 --workspace,路由到文档空间(doc MCP server)
workspaceID := flagOrFallback(cmd, "workspace", "workspace-id")
if workspaceID != "" {
// depth>1 时 --pattern 放开(先递归后过滤);--order-by/--space-id/--thumbnail
// 知识库无对应参数,静默忽略。
// latest>0 且 depth==1:知识库无服务端排序须拉全量,复用 BFS maxDepth=1
//(限流补偿/页数防线现成),后置处理器在发射函数内取 Top-N 并剥装饰字段;
// pattern 同样在 BFS 发射函数内过滤,不再拒绝。
if depth > 1 || latest > 0 {
quiet, _ := cmd.Flags().GetBool("quiet")
baseArgs := map[string]any{"workspaceId": workspaceID}
@@ -432,8 +438,9 @@ func newDriveCommand() *cobra.Command {
return runDriveListDepth(cmd, newDrivePanDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet, latest)
}
// 默认路由:钉盘文件列表
if latest > 0 {
// 钉盘单层 latest:服务端支持 orderBy=modifyTime&order=desc,凑够 N 即停,
// 不复用 BFS(否则最高频路径从 O(1) 退化为 O(目录大小)),走独立扫描循环。
quiet, _ := cmd.Flags().GetBool("quiet")
baseArgs := map[string]any{}
if v, _ := cmd.Flags().GetString("space-id"); v != "" {
@@ -450,6 +457,8 @@ func newDriveCommand() *cobra.Command {
}
return runDriveListLatest(cmd, baseArgs, rootFolder, latest, pattern, quiet)
}
// 默认路由:钉盘文件列表
maxResults, _ := cmd.Flags().GetInt("limit")
if !cmd.Flags().Changed("limit") {
if v, _ := cmd.Flags().GetInt("max"); v > 0 {
@@ -512,15 +521,18 @@ func newDriveCommand() *cobra.Command {
"用户要浏览「我的文件」/钉盘/网盘某目录下有哪些文件或文件夹时",
"已知父文件夹 dentryUuid,要列出其子项以便继续 download/copy/move 时",
"传 --workspace 时要列出文档空间/知识库根或子目录(与 wiki node list 场景重叠时,用户说钉盘/我的文件优先本命令)",
"范围已知(当前目录/--folder/--workspace 知识库)、要按文件修改时间取最新 N 个文件时用 --latest N(可叠加 --pattern 限名、--depth 递归)",
},
AvoidWhen: []string{
"只记得关键词、不知道所在目录时改用 dws drive search",
"明确要在某个知识库内按目录浏览且已有 workspaceId 时可用 dws wiki node list",
"要找最近打开/编辑过的文档改用 dws drive recent",
"要看「我最近看过/改过的」跨空间个人访问记录改用 dws drive recent;范围已知、按文件修改时间取最新用本命令 --latest",
},
// 门禁限定最多 2 条示例(schema_agent_examples.go),--latest 只能替换掉
// 原第 2 条的 --limit:--limit 仍由第 1 条示范,--folder 由第 2 条继续示范。
Examples: []string{
"dws drive list --limit 20 --format json",
"dws drive list --latest 5 --format json",
"dws drive list --folder <dentryUuid> --latest 5 --format json",
},
},
},
@@ -730,8 +742,6 @@ func newDriveCommand() *cobra.Command {
{Name: "part-size", Description: "分片下载的分片大小(如 8MB/16MB/1GB)"},
{Name: "parallel", Description: "分片下载并发数(1-8)"},
{Name: "no-resume", Description: "关闭断点续传"},
// Wukong compat alias: routes to download-version; not a download_file property.
{Name: "version", Description: "下载指定历史版本号(兼容别名,等价 download-version)"},
},
},
})
@@ -1082,8 +1092,8 @@ func newDriveCommand() *cobra.Command {
driveListCmd.Flags().Bool("versions", false, "列出文件历史版本而非文件列表 (需配合 --node)")
driveListCmd.Flags().String("node", "", "文件 ID (dentryUuid) 或 URL (--versions 模式下必填)")
driveListCmd.Flags().String("pattern", "", "按名称通配过滤结果,如 \"*日报*\" (客户端过滤) (可选)")
driveListCmd.Flags().Int("depth", 1, "递归列出子目录层级,默认 1(仅当前层),最大 5;与 --cursor/--limit 互斥;与 --workspace 组合时走知识库递归 (可选)")
driveListCmd.Flags().Int("latest", 0, "按修改时间取最新 N 个文件(1~50);与 --pattern 组合时表示名称匹配的文件中最新 N 个;可与 --workspace/--depth 组合;与 --order-by/--order/--limit/--cursor 互斥 (可选)")
driveListCmd.Flags().Int("depth", 1, "递归列出子目录层级,默认 1(仅当前层),最大 5;与 --cursor/--limit(含隐藏别名)互斥;与 --workspace 组合时走知识库递归 (可选)")
driveListCmd.Flags().Int("latest", 0, "按修改时间取最新 N 个文件(1~50);与 --pattern 组合时表示\"名称匹配的文件中最新 N 个\";可与 --workspace/--depth 组合(扫描触 2000 条上限或途中目录读取失败时报错,不产出不完整的 Top-N);与 --order-by/--order/--limit/--cursor(含隐藏别名)互斥 (可选)")
driveListCmd.Flags().Bool("quiet", false, "关闭递归进度输出(stderr),不影响 stdout JSON (--depth>1 或 --latest 多页扫描时有效) (可选)")
driveInfoCmd.Flags().String("node", "", "节点 ID (dentryUuid) (必填)")
@@ -1092,7 +1102,6 @@ func newDriveCommand() *cobra.Command {
driveDownloadCmd.Flags().String("node", "", "文件 ID (dentryUuid) (必填)")
driveDownloadCmd.Flags().String("space-id", "", "文件所属空间 ID (可选)")
driveDownloadCmd.Flags().String("output", "", "本地保存路径 (文件路径或目录,必填)")
driveDownloadCmd.Flags().Int("version", 0, "下载指定历史版本号(兼容别名,等价 download-version)")
driveDownloadCmd.Flags().String("part-size", "16MB", "分片下载的分片大小,如 8MB/16MB/1GB,范围 1MB-1GB (可选)")
driveDownloadCmd.Flags().Int("parallel", 4, "分片下载并发数,范围 1-8 (可选)")
driveDownloadCmd.Flags().Bool("no-resume", false, "关闭断点续传 (可选)")
@@ -1107,14 +1116,6 @@ func newDriveCommand() *cobra.Command {
driveDownloadVersionCmd.Flags().String(alias, "", "")
_ = driveDownloadVersionCmd.Flags().MarkHidden(alias)
}
// Wukong compat: `drive download --version N` routes to download-version.
origDriveDownloadRunE := driveDownloadCmd.RunE
driveDownloadCmd.RunE = func(cmd *cobra.Command, args []string) error {
if cmd.Flags().Changed("version") {
return driveDownloadVersionCmd.RunE(cmd, args)
}
return origDriveDownloadRunE(cmd, args)
}
driveMkdirCmd.Flags().String("name", "", "文件夹名称,最长 50 字符 (必填)")
driveMkdirCmd.Flags().String("space-id", "", "目标空间 ID,不传则使用「我的文件」 (可选)")
@@ -2060,8 +2061,6 @@ func newDriveCommand() *cobra.Command {
limit := 0
if cmd.Flags().Changed("limit") {
limit, _ = cmd.Flags().GetInt("limit")
} else if cmd.Flags().Changed("max-results") {
limit, _ = cmd.Flags().GetInt("max-results")
}
if limit > 0 {
toolArgs["maxResults"] = limit
@@ -2113,8 +2112,6 @@ func newDriveCommand() *cobra.Command {
})
drivePermListCmd.Flags().String("node", "", "目标节点 ID 或 URL (必填)")
drivePermListCmd.Flags().Int("limit", 30, "返回成员数上限,默认 30,最大 200")
drivePermListCmd.Flags().Int("max-results", 0, "")
_ = drivePermListCmd.Flags().MarkHidden("max-results")
drivePermListCmd.Flags().String("filter-role", "", "按角色过滤: OWNER / MANAGER / EDITOR / DOWNLOADER / READER")
drivePermListCmd.Flags().String("workspace", "", "知识库 ID (选填)")
@@ -2778,7 +2775,7 @@ func newDriveCommand() *cobra.Command {
// ── cross-product hidden aliases ──
for _, cmd := range []*cobra.Command{
driveListCmd, driveListSpacesCmd, driveInfoCmd, driveDownloadCmd, driveDownloadVersionCmd,
driveListCmd, driveListSpacesCmd, driveInfoCmd, driveDownloadCmd,
driveMkdirCmd, driveUploadInfoCmd, driveCommitCmd, driveUploadCmd, driveDeleteCmd,
driveSearchCmd, driveCopyCmd, driveMoveCmd, driveRenameCmd, driveStatsCmd, driveShortcutCmd,
driveFolderCreateCmd,
@@ -2867,6 +2864,7 @@ func newDriveCommand() *cobra.Command {
AvoidWhen: []string{
"按关键词全局搜文件改用 dws drive search",
"浏览某目录内容改用 dws drive list",
"范围已知(某文件夹/知识库)、要按文件修改时间取最新 N 个改用 dws drive list --latest N(本命令按个人访问记录、跨空间,与文件修改时间不是一回事)",
},
Examples: []string{
"dws drive recent --format json",
@@ -3080,11 +3078,6 @@ func newDriveCommand() *cobra.Command {
},
})
driveCoverCmd.Flags().String("node", "", "节点 ID (dentryUuid) 或文档 URL (必填)")
for _, alias := range []string{"url", "id"} {
driveCoverCmd.Flags().String(alias, "", "--node 的别名")
_ = driveCoverCmd.Flags().MarkHidden(alias)
}
RegisterCrossProductAliases(driveCoverCmd)
// ── drive revert (回滚文件到指定历史版本) ──
driveRevertCmd := &cobra.Command{
@@ -3137,21 +3130,6 @@ func newDriveCommand() *cobra.Command {
})
driveRevertCmd.Flags().String("node", "", "文件 ID (dentryUuid) 或 URL (必填)")
driveRevertCmd.Flags().Int("version", 0, "要回滚到的历史版本号 (必填,正整数)")
for _, alias := range []string{"url", "id"} {
driveRevertCmd.Flags().String(alias, "", "--node 的别名")
_ = driveRevertCmd.Flags().MarkHidden(alias)
}
RegisterCrossProductAliases(driveRevertCmd)
for _, child := range driveStarCmd.Commands() {
for _, alias := range []string{"url", "id"} {
if child.Flags().Lookup(alias) == nil {
child.Flags().String(alias, "", "--node 的别名")
_ = child.Flags().MarkHidden(alias)
}
}
RegisterCrossProductAliases(child)
}
driveCmd.AddCommand(
driveListCmd,
+249 -11
View File
@@ -32,6 +32,11 @@ const (
// API 调用数随目录宽度指数增长,超过报错不 clamp。
driveDepthMax = 5
driveDepthMaxItems = 2000
// --latest 上限与 --limit 服务端每页硬上限 50 对齐;「定位最新」场景 N 天然小,
// 百/千级属「导出清单」场景,应由 --order-by 翻页或 --depth 承接。
driveLatestMax = 50
// 钉盘单层 latest 扫描上限(50×20 页):服务端有序,已扫描即最新 1000 条,提前终止不影响 Top-N 正确性。
driveLatestScanMax = 1000
// Sentinel 限流以 HTTP 200 + body errorCode 返回,transport 层不会自动重试,补偿只能在 BFS 层做。
driveDepthRateLimitedCode = "invalidRequest.rateLimited"
driveDepthCancelledMsg = "cancelled by user, partial result emitted"
@@ -82,15 +87,52 @@ func validateDriveListDepth(cmd *cobra.Command, depth int) error {
return nil
}
// --workspace 不互斥:depth>1 时作为路由开关切到知识库 BFS。
if v := flagOrFallback(cmd, "cursor", "next-token"); v != "" {
return driveDepthExclusiveError("cursor", "depth>1 时多文件夹合成一份清单,无连续游标")
if name := driveListCursorConflict(cmd); name != "" {
return driveDepthExclusiveError(name, "depth>1 时多文件夹合成一份清单,无连续游标")
}
if cmd.Flags().Changed("limit") || cmd.Flags().Changed("max") {
return driveDepthExclusiveError("limit", "递归模式数据量由全局上限 2000 与 --depth 层数控制")
if name := driveListLimitConflict(cmd); name != "" {
return driveDepthExclusiveError(name, "递归模式数据量由全局上限 2000 与 --depth 层数控制")
}
return nil
}
// driveListLimitConflict / driveListCursorConflict 返回用户实际传入的那个分页 flag 名
// (含 RegisterCrossProductAliases 自动注册的隐藏别名,如 --page-size / --page-token),
// 无冲突返回 ""。报错点名用户真正传的名字而非语义组主名,否则传 --page-size 却被
// 告知「不能与 --limit 同时使用」会让人无从下手。
// max 手写保留:它由 drive 单独注册,不在 crossProductAliases 表内。
func driveListLimitConflict(cmd *cobra.Command) string {
return driveListFirstProvidedFlag(cmd, append([]string{"limit", "max"}, crossProductAliasPeers("limit")...))
}
func driveListCursorConflict(cmd *cobra.Command) string {
return driveListFirstProvidedFlag(cmd, append([]string{"cursor"}, crossProductAliasPeers("cursor")...))
}
func driveListFirstProvidedFlag(cmd *cobra.Command, names []string) string {
for _, name := range names {
if driveListFlagProvided(cmd, name) {
return name
}
}
return ""
}
// driveListFlagProvided 判定用户是否真的传了该 flag:string 型按值判定(空串视为未传,
// 兼容 Agent 的 --cursor "$CURSOR" 首页空值模板),其余类型按 Changed 判定
// (--limit 是 int,显式传入默认值 20 也算冲突)。这两条规则与改造前
// flagOrFallback / Changed 的既有行为逐字等价,只是覆盖面扩到了全部别名。
func driveListFlagProvided(cmd *cobra.Command, name string) bool {
flag := cmd.Flags().Lookup(name)
if flag == nil {
return false
}
if flag.Value.Type() == "string" {
return flag.Value.String() != ""
}
return flag.Changed
}
func driveDepthExclusiveError(flag, reason string) error {
return &CLIError{
Code: CodeInvalidParam,
@@ -98,6 +140,36 @@ func driveDepthExclusiveError(flag, reason string) error {
}
}
// validateDriveListLatest --latest 边界与互斥校验;互斥报错附等效写法引导,
// 便于用户/Agent 改用 --order-by 原语翻「更早的」。
func validateDriveListLatest(cmd *cobra.Command, latest int) error {
if latest < 1 || latest > driveLatestMax {
return &CLIError{
Code: CodeInvalidParam,
Message: fmt.Sprintf("--latest 必须为 1~%d 的整数,当前: %d", driveLatestMax, latest),
}
}
for _, f := range []string{"order-by", "order"} {
if cmd.Flags().Changed(f) {
return driveLatestExclusiveError(f, latest)
}
}
if name := driveListLimitConflict(cmd); name != "" {
return driveLatestExclusiveError(name, latest)
}
if name := driveListCursorConflict(cmd); name != "" {
return driveLatestExclusiveError(name, latest)
}
return nil
}
func driveLatestExclusiveError(flag string, latest int) error {
return &CLIError{
Code: CodeInvalidParam,
Message: fmt.Sprintf("--latest 不能与 --%s 同时使用:Top-N 排序语义由 latest 独占;如需自定义排序请改用 --order-by modifyTime --order desc --limit %d", flag, latest),
}
}
// 路由绑定差异全部收敛在此,BFS 主循环保持路由无关。
type driveDepthRoute struct {
serverID string // ""=钉盘自动路由 / "doc"=知识库
@@ -249,11 +321,12 @@ bfs:
item["depth"] = folder.depth + 1
item["parentId"] = folder.id // 根级为空串
item["rel_path"] = rel // 不保证唯一,组树以 parentId 为准
// 时间戳归一:钉盘 modifyTime / 知识库 updateTime 统一为 sortTime(毫秒 int64)
if ms, ok := driveItemModifiedMillis(item); ok {
if latest > 0 {
// 时间戳归一:钉盘 modifyTime / 知识库 updateTime 统一为内部 sortTime
// (毫秒 int64,无时间字段填 0 排末尾),仅供 --latest 后置处理器跨路由
// 排序;emit 前一律删除,不进输出契约。
ms, _ := driveModifiedMillis(item)
item["sortTime"] = ms
} else {
item["sortTime"] = int64(0)
}
collected = append(collected, item)
if len(collected) >= driveDepthMaxItems {
@@ -295,6 +368,11 @@ bfs:
if folderErr != nil {
if driveDepthUnrecoverable(folderErr) {
if latest > 0 {
// 不完整集合上的 Top-N 会被误读为全局最新:latest 下不吐 partial,
// 直接回根因错误(auth 过期 / 网络不可达比通用 token 更可操作)。
return folderErr
}
// partial 照吐 stdout,错误详情走 stderr,非零退出
errs = append(errs, newDriveDepthError(folder, folderErr))
if emitErr := emitDriveDepthResult(collected, errs, truncated, pattern, latest, maxDepth); emitErr != nil {
@@ -330,15 +408,46 @@ bfs:
}
}
if truncated && latest > 0 {
// BFS 序与修改时间无关:截断与递归途中目录失败都让未扫区域可能含更新文件,
// 此时的 Top-N 不是全局最新,两者同属一条防线——拒绝以成功状态产出。
// SIGINT 部分结果路径不在此拦截(退出码 130 已显式标记不完整)。
if latest > 0 && (truncated || len(errs) > 0) {
return driveLatestIncompleteError(latest, truncated, errs)
}
return emitDriveDepthResult(collected, errs, truncated, pattern, latest, maxDepth)
}
// driveLatestIncompleteError 排序基不完整时的拒绝产出错误。截断与目录失败共用
// CodeContentTruncated(→ ExitAPI),但 token 分开,便于消费方区分「范围太大」与「读不到」。
// 拒绝产出后 errors[] 不再进 stdout,失败详情必须落在错误消息里,否则用户完全瞎。
// Suggestion 的每个子句把可照抄的命令写在句尾,且必须与该子句正文一致——让用户
// 「去掉 --latest」的子句若示例仍带 --latest,照抄就复现同一个错误。
func driveLatestIncompleteError(latest int, truncated bool, errs []driveDepthError) error {
// len(errs)==0 只可能来自截断分支:调用点已保证二者至少一真。
if truncated || len(errs) == 0 {
return &CLIError{
Code: CodeContentTruncated,
Message: fmt.Sprintf("LATEST_SCAN_TRUNCATED: 扫描在全局上限 %d 条处截断,未扫描区域可能含更新文件,拒绝输出不完整的 Top-%d", driveDepthMaxItems, latest),
Suggestion: fmt.Sprintf("缩小扫描范围后重试:--folder 指定子目录,或降低 --depth 层数,如 dws drive list --folder <子目录ID> --latest %d", latest),
}
}
return emitDriveDepthResult(collected, errs, truncated, pattern, latest, maxDepth)
first := errs[0]
folder := first.FolderName
if folder == "" {
folder = first.FolderID
}
if folder == "" {
folder = "<root>"
}
return &CLIError{
Code: CodeContentTruncated,
Message: fmt.Sprintf("LATEST_SCAN_INCOMPLETE: %d 个目录未读全(首个失败 folder=%s depth=%d reason=%s: %s),未扫描区域可能含更新文件,拒绝输出不完整的 Top-%d",
len(errs), folder, first.Depth, first.Reason, first.Message, latest),
// partial + errors[] 的承诺限定在 --depth>1:单层(含知识库 maxDepth=1)去掉 --latest
// 会路由回普通单层 list,本就没有 errors[] 契约,无条件承诺即是另一种不准确。
Suggestion: fmt.Sprintf("确认目录权限后重试;或用 --folder 缩小到可读子目录后重取 Top-%d:dws drive list --folder <可读子目录ID> --latest %d;需要看失败明细请去掉 --latest 按原范围重跑(--depth>1 时同时输出已扫到的 partial 与 errors[] 明细):dws drive list --folder <目录ID> --depth <原层数>", latest, latest),
}
}
func emitDriveDepthCancelled(items []map[string]any, errs []driveDepthError, pattern string, latest, reqDepth int) error {
@@ -365,6 +474,7 @@ func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, trunca
items = filtered
}
if latest > 0 {
// latest 排列按 sortTime desc(第 1 条最新);rel_path 树序仅在不带 latest 时保持。
items = applyDriveListLatest(items, latest)
} else {
// 排列为 rel_path 树序:BFS 只决定截断时哪些条目入选,树序决定呈现顺序。
@@ -383,7 +493,13 @@ func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, trunca
maxDepth = d
}
}
// sortTime 是内部排序字段(applyDriveListLatest 已用完),任何输出路径都不得泄露;
// depth / parentId / rel_path 按 depth>1 的既有契约保留。
for _, item := range items {
delete(item, "sortTime")
}
if latest > 0 && reqDepth == 1 {
// 知识库单层 latest:输出与普通单层 list 对齐,剥 BFS 装饰字段(maxDepth 已先统计)。
stripDriveDepthDecorations(items)
}
if items == nil {
@@ -513,6 +629,128 @@ func driveDepthItemID(item map[string]any) string {
return ""
}
// applyDriveListLatest --latest 后置处理器(知识库单层 / 钉盘与知识库 depth>1 三处共享):
// 仅文件参与 Top-N(文件夹只用于下钻与组树)→ sortTime desc 排序(tie-break:
// rel_path → fileId/nodeId,保证输出完全确定、CI 断言稳定)→ 截断 N。
func applyDriveListLatest(items []map[string]any, latest int) []map[string]any {
files := make([]map[string]any, 0, len(items))
for _, item := range items {
if isDriveDepthFolder(item) || isDocDepthFolder(item) {
continue
}
files = append(files, item)
}
sort.SliceStable(files, func(i, j int) bool {
ti, _ := files[i]["sortTime"].(int64)
tj, _ := files[j]["sortTime"].(int64)
if ti != tj {
return ti > tj
}
ri, _ := files[i]["rel_path"].(string)
rj, _ := files[j]["rel_path"].(string)
if ri != rj {
return ri < rj
}
return driveDepthItemID(files[i]) < driveDepthItemID(files[j])
})
if len(files) > latest {
files = files[:latest]
}
return files
}
// 单层 latest 输出与普通 list 对齐:剥掉 BFS 装饰字段。
func stripDriveDepthDecorations(items []map[string]any) {
for _, item := range items {
delete(item, "depth")
delete(item, "parentId")
delete(item, "rel_path")
delete(item, "sortTime")
}
}
// runDriveListLatest 钉盘单层 --latest 扫描:服务端按 modifyTime desc 排序,客户端
// 逐页剔除文件夹 + pattern 过滤,凑够 N / 扫满 driveLatestScanMax / 翻到底即停。
// 知识库路由无服务端排序须拉全量,走 BFS maxDepth=1 + 后置处理器,不走本函数。
func runDriveListLatest(cmd *cobra.Command, baseArgs map[string]any, rootFolder string, latest int, pattern string, quiet bool) error {
buildArgs := func(pageToken string) map[string]any {
args := map[string]any{
"maxResults": float64(driveDepthPageSize),
"orderBy": "modifyTime",
"order": "desc",
}
for k, v := range baseArgs {
args[k] = v
}
if rootFolder != "" {
args["parentId"] = rootFolder
}
if pageToken != "" {
args["nextToken"] = pageToken
}
return args
}
if deps.Caller.DryRun() {
return deps.Out.PrintJSON(map[string]any{
"tool": "list_files",
"args": buildArgs(""),
"latest": latest,
"note": fmt.Sprintf("dry-run:latest 为客户端能力,凑够 N 条即停,最多扫描 %d 条", driveLatestScanMax),
})
}
ctx := cmd.Context()
if ctx == nil {
ctx = context.Background()
}
collected := make([]map[string]any, 0, latest)
scanned := 0
pageToken := ""
// 兜底服务端「空页 + 非空游标」异常,页数上限与扫描上限同源。
maxPages := driveLatestScanMax/driveDepthPageSize + 1
for pages := 0; pages < maxPages; pages++ {
text, err := callMCPToolReturnText(ctx, "list_files", buildArgs(pageToken))
if err != nil {
return fmt.Errorf("latest 扫描第 %d 页失败: %w", pages+1, err)
}
// hasMore 异常形态(hasMore=true 但 token 空)静默停:服务端有序,截断只影响
// 「凑不满 N」路径,已有 stderr 提示兜底,不复制 BFS 的 pagination anomaly 报错。
items, next, _ := parseDriveDepthPage(text)
for _, item := range items {
scanned++
if isDriveDepthFolder(item) {
continue
}
name, _ := item["name"].(string)
if name == "" {
name, _ = item["fileName"].(string)
}
if pattern != "" && !matchDriveNamePattern(name, pattern) {
continue
}
collected = append(collected, item)
if len(collected) >= latest {
break
}
}
if len(collected) >= latest || next == "" || scanned >= driveLatestScanMax {
break
}
pageToken = next
if !quiet {
fmt.Fprintf(os.Stderr, "[drive-list] latest 扫描中: 已扫 %d 条,命中 %d/%d\n", scanned, len(collected), latest)
}
}
if len(collected) < latest {
// 凑不满 N 不是失败(退出码 0);提示附可直接执行的后续命令,便于 Agent 自主继续。
hint := fmt.Sprintf("dws drive list --folder <子目录ID> --latest %d", latest)
if pattern != "" {
hint = fmt.Sprintf("dws drive list --folder <子目录ID> --pattern %q --latest %d", pattern, latest)
}
fmt.Fprintf(os.Stderr, "[drive-list] 已扫描 %d 条,找到 %d/%d 条;建议缩小范围:%s\n", scanned, len(collected), latest, hint)
}
return deps.Out.PrintJSON(map[string]any{"items": collected})
}
func driveDepthUnrecoverable(err error) bool {
var cliErr *CLIError
if errors.As(err, &cliErr) {
+19 -6
View File
@@ -306,7 +306,7 @@ func TestCrossPlatformCoverageEmitDriveDepthResult(t *testing.T) {
{"name": "a-file.xlsx", "rel_path": "a", "depth": 2, "fileId": "f1"},
{"name": "skip-me.csv", "rel_path": "c", "depth": 1, "fileId": "f3"},
}
if err := emitDriveDepthResult(items, nil, false, "*.xlsx", 0, 0); err != nil {
if err := emitDriveDepthResult(items, nil, false, "*.xlsx", 0, 3); err != nil {
t.Fatal(err)
}
result := decodeDepthResult(t, out)
@@ -325,7 +325,7 @@ func TestCrossPlatformCoverageEmitDriveDepthResult(t *testing.T) {
}
out.Reset()
if err := emitDriveDepthResult(nil, nil, false, "", 0, 0); err != nil {
if err := emitDriveDepthResult(nil, nil, false, "", 0, 3); err != nil {
t.Fatal(err)
}
result = decodeDepthResult(t, out)
@@ -338,7 +338,7 @@ func TestCrossPlatformCoverageEmitDriveDepthResult(t *testing.T) {
{"name": "dup", "rel_path": "p/dup", "fileId": "a1"},
}
out.Reset()
if err := emitDriveDepthResult(samePath, nil, false, "", 0, 0); err != nil {
if err := emitDriveDepthResult(samePath, nil, false, "", 0, 3); err != nil {
t.Fatal(err)
}
got = decodeDepthResult(t, out)["items"].([]any)
@@ -347,7 +347,7 @@ func TestCrossPlatformCoverageEmitDriveDepthResult(t *testing.T) {
}
deps.Out.w = failingWriter{}
if err := emitDriveDepthResult(nil, nil, false, "", 0, 0); err == nil {
if err := emitDriveDepthResult(nil, nil, false, "", 0, 3); err == nil {
t.Fatal("failing writer returned nil")
}
}
@@ -378,11 +378,24 @@ func useDriveDepthArgs(t *testing.T) {
t.Cleanup(func() { os.Args = old })
}
func runDepthBFS(t *testing.T, caller *scriptedToolCaller, route driveDepthRoute, root string, maxDepth int, pattern string) (map[string]any, error) {
// runDepthBFSRaw 返回原始 stdout 缓冲,供「必须无输出」的用例断言;
// 需要解析 JSON 的用例改用下面两个包装。
func runDepthBFSRaw(t *testing.T, caller *scriptedToolCaller, route driveDepthRoute, root string, maxDepth int, pattern string, latest int) (*bytes.Buffer, error) {
t.Helper()
out := installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
err := runDriveListDepth(cmd, route, map[string]any{}, root, maxDepth, pattern, true, 0)
err := runDriveListDepth(cmd, route, map[string]any{}, root, maxDepth, pattern, true, latest)
return out, err
}
func runDepthBFS(t *testing.T, caller *scriptedToolCaller, route driveDepthRoute, root string, maxDepth int, pattern string) (map[string]any, error) {
t.Helper()
return runDepthBFSLatest(t, caller, route, root, maxDepth, pattern, 0)
}
func runDepthBFSLatest(t *testing.T, caller *scriptedToolCaller, route driveDepthRoute, root string, maxDepth int, pattern string, latest int) (map[string]any, error) {
t.Helper()
out, err := runDepthBFSRaw(t, caller, route, root, maxDepth, pattern, latest)
return decodeDepthResult(t, out), err
}
-199
View File
@@ -1,199 +0,0 @@
package helpers
import (
"context"
"encoding/json"
"fmt"
"os"
"sort"
"strconv"
"strings"
"time"
"github.com/spf13/cobra"
)
const (
// --latest 上限与 --limit 服务端每页硬上限 50 对齐。
driveLatestMax = 50
// 钉盘单层 latest 扫描上限(50×20 页)。
driveLatestScanMax = 1000
)
// validateDriveListLatest --latest 边界与互斥校验。
func validateDriveListLatest(cmd *cobra.Command, latest int) error {
if latest < 1 || latest > driveLatestMax {
return &CLIError{
Code: CodeInvalidParam,
Message: fmt.Sprintf("--latest 必须为 1~%d 的整数,当前: %d", driveLatestMax, latest),
}
}
for _, f := range []string{"order-by", "order"} {
if cmd.Flags().Changed(f) {
return driveLatestExclusiveError(f, latest)
}
}
if cmd.Flags().Changed("limit") || cmd.Flags().Changed("max") {
return driveLatestExclusiveError("limit", latest)
}
if v := flagOrFallback(cmd, "cursor", "next-token"); v != "" {
return driveLatestExclusiveError("cursor", latest)
}
return nil
}
func driveLatestExclusiveError(flag string, latest int) error {
return &CLIError{
Code: CodeInvalidParam,
Message: fmt.Sprintf("--latest 不能与 --%s 同时使用:Top-N 排序语义由 latest 独占;如需自定义排序请改用 --order-by modifyTime --order desc --limit %d", flag, latest),
}
}
func applyDriveListLatest(items []map[string]any, latest int) []map[string]any {
files := make([]map[string]any, 0, len(items))
for _, item := range items {
if isDriveDepthFolder(item) || isDocDepthFolder(item) {
continue
}
files = append(files, item)
}
sort.SliceStable(files, func(i, j int) bool {
ti, _ := files[i]["sortTime"].(int64)
tj, _ := files[j]["sortTime"].(int64)
if ti != tj {
return ti > tj
}
ri, _ := files[i]["rel_path"].(string)
rj, _ := files[j]["rel_path"].(string)
if ri != rj {
return ri < rj
}
return driveDepthItemID(files[i]) < driveDepthItemID(files[j])
})
if len(files) > latest {
files = files[:latest]
}
return files
}
func stripDriveDepthDecorations(items []map[string]any) {
for _, item := range items {
delete(item, "depth")
delete(item, "parentId")
delete(item, "rel_path")
delete(item, "sortTime")
}
}
func driveItemModifiedMillis(item map[string]any) (int64, bool) {
for _, k := range []string{"modifiedTime", "modifyTime", "modified_time", "gmtModified", "lastModifiedTime", "updateTime"} {
if v, ok := item[k]; ok {
if ms, ok := toMillis(v); ok {
return ms, true
}
}
}
return 0, false
}
func toMillis(v any) (int64, bool) {
switch t := v.(type) {
case float64:
if t <= 0 {
return 0, false
}
return int64(t), true
case json.Number:
if n, err := t.Int64(); err == nil && n > 0 {
return n, true
}
case string:
s := strings.TrimSpace(t)
if s == "" {
return 0, false
}
if n, err := strconv.ParseInt(s, 10, 64); err == nil && n > 0 {
return n, true
}
if tm, err := time.Parse(time.RFC3339, s); err == nil {
return tm.UnixMilli(), true
}
}
return 0, false
}
// runDriveListLatest 钉盘单层 --latest 扫描。
func runDriveListLatest(cmd *cobra.Command, baseArgs map[string]any, rootFolder string, latest int, pattern string, quiet bool) error {
buildArgs := func(pageToken string) map[string]any {
args := map[string]any{
"maxResults": float64(driveDepthPageSize),
"orderBy": "modifyTime",
"order": "desc",
}
for k, v := range baseArgs {
args[k] = v
}
if rootFolder != "" {
args["parentId"] = rootFolder
}
if pageToken != "" {
args["nextToken"] = pageToken
}
return args
}
if deps.Caller.DryRun() {
return deps.Out.PrintJSON(map[string]any{
"tool": "list_files",
"args": buildArgs(""),
"latest": latest,
"note": "dry-run:latest 为客户端能力,凑够 N 条即停,最多扫描 1000 条",
})
}
ctx := cmd.Context()
if ctx == nil {
ctx = context.Background()
}
collected := make([]map[string]any, 0, latest)
scanned := 0
pageToken := ""
maxPages := driveLatestScanMax/driveDepthPageSize + 1
for pages := 0; pages < maxPages; pages++ {
text, err := callMCPToolReturnText(ctx, "list_files", buildArgs(pageToken))
if err != nil {
return fmt.Errorf("latest 扫描第 %d 页失败: %w", pages+1, err)
}
items, next, _ := parseDriveDepthPage(text)
for _, item := range items {
scanned++
if isDriveDepthFolder(item) {
continue
}
name, _ := item["name"].(string)
if name == "" {
name, _ = item["fileName"].(string)
}
if pattern != "" && !matchDriveNamePattern(name, pattern) {
continue
}
collected = append(collected, item)
if len(collected) >= latest {
break
}
}
if len(collected) >= latest || next == "" || scanned >= driveLatestScanMax {
break
}
pageToken = next
if !quiet {
fmt.Fprintf(os.Stderr, "[drive-list] latest 扫描中: 已扫 %d 条,命中 %d/%d\n", scanned, len(collected), latest)
}
}
if len(collected) < latest {
hint := fmt.Sprintf("dws drive list --folder <子目录ID> --latest %d", latest)
if pattern != "" {
hint = fmt.Sprintf("dws drive list --folder <子目录ID> --pattern %q --latest %d", pattern, latest)
}
fmt.Fprintf(os.Stderr, "[drive-list] 已扫描 %d 条,找到 %d/%d 条;建议缩小范围:%s\n", scanned, len(collected), latest, hint)
}
return deps.Out.PrintJSON(map[string]any{"items": collected})
}
+620
View File
@@ -0,0 +1,620 @@
package helpers
import (
"encoding/json"
"fmt"
"io"
"os"
"strings"
"testing"
"github.com/spf13/cobra"
)
func driveLatestTestCmd() *cobra.Command {
cmd := driveDepthTestCmd()
cmd.Flags().String("order-by", "", "")
cmd.Flags().String("order", "", "")
return cmd
}
func useDriveLatestArgs(t *testing.T) {
t.Helper()
old := os.Args
os.Args = []string{"dws", "drive", "list", "--latest", "5"}
t.Cleanup(func() { os.Args = old })
}
// captureDriveLatestStderr 抓取直写 os.Stderr 的进度/提示行(不经 deps.Out.errW)。
func captureDriveLatestStderr(t *testing.T, run func()) string {
t.Helper()
reader, writer, err := os.Pipe()
if err != nil {
t.Fatalf("create stderr pipe: %v", err)
}
previous := os.Stderr
os.Stderr = writer
defer func() {
os.Stderr = previous
_ = reader.Close()
}()
run()
os.Stderr = previous
if err := writer.Close(); err != nil {
t.Fatalf("close stderr writer: %v", err)
}
output, err := io.ReadAll(reader)
if err != nil {
t.Fatalf("read stderr: %v", err)
}
return string(output)
}
// drivePanPage 造一页钉盘响应:首条为文件夹(供 BFS 下钻),其余为文件。
func drivePanPage(prefix string, files int, nextToken string) string {
entries := []string{fmt.Sprintf(`{"fileId":%q,"name":"%s-dir","type":"FOLDER"}`, prefix+"-dir", prefix)}
for i := 0; i < files; i++ {
entries = append(entries, fmt.Sprintf(
`{"fileId":"%s-f%d","name":"%s-f%d.txt","type":"FILE","modifyTime":%d}`,
prefix, i, prefix, i, 1700000000000+i))
}
body := fmt.Sprintf(`{"items":[%s]`, strings.Join(entries, ","))
if nextToken != "" {
body += fmt.Sprintf(`,"nextToken":%q`, nextToken)
}
return body + "}"
}
func docPage(prefix string, nodes int, nextToken string) string {
entries := make([]string, 0, nodes)
for i := 0; i < nodes; i++ {
entries = append(entries, fmt.Sprintf(
`{"nodeId":"%s-n%d","name":"%s-n%d","nodeType":"doc","updateTime":%d}`,
prefix, i, prefix, i, 1700000000000+i))
}
body := fmt.Sprintf(`{"nodes":[%s],"hasMore":true`, strings.Join(entries, ","))
if nextToken != "" {
body += fmt.Sprintf(`,"nextPageToken":%q`, nextToken)
}
return body + "}"
}
func TestCrossPlatformCoverageValidateDriveListLatest(t *testing.T) {
if err := validateDriveListLatest(driveLatestTestCmd(), 0); err == nil {
t.Fatal("latest 0 returned nil")
}
if err := validateDriveListLatest(driveLatestTestCmd(), driveLatestMax+1); err == nil {
t.Fatal("latest above max returned nil")
}
if err := validateDriveListLatest(driveLatestTestCmd(), 1); err != nil {
t.Fatalf("latest 1 returned %v", err)
}
if err := validateDriveListLatest(driveLatestTestCmd(), driveLatestMax); err != nil {
t.Fatalf("latest max returned %v", err)
}
for _, tc := range []struct{ flag, value string }{
{"order-by", "name"},
{"order", "asc"},
{"limit", "5"},
{"max", "5"},
{"cursor", "c1"},
{"next-token", "t1"},
} {
cmd := driveLatestTestCmd()
if err := cmd.Flags().Set(tc.flag, tc.value); err != nil {
t.Fatalf("set --%s: %v", tc.flag, err)
}
err := validateDriveListLatest(cmd, 3)
if err == nil {
t.Fatalf("--latest with --%s returned nil", tc.flag)
}
cliErr, ok := err.(*CLIError)
if !ok || cliErr.Code != CodeInvalidParam {
t.Fatalf("--%s error = %#v", tc.flag, err)
}
// 互斥报错必须给出等效改写,否则 Agent 只能盲试。
if !strings.Contains(cliErr.Message, "--order-by modifyTime --order desc --limit 3") {
t.Fatalf("--%s message lacks rewrite hint: %s", tc.flag, cliErr.Message)
}
}
}
func TestCrossPlatformCoverageApplyDriveListLatest(t *testing.T) {
items := []map[string]any{
{"fileId": "dir", "name": "dir", "type": "FOLDER", "sortTime": int64(9999)},
{"nodeId": "docdir", "name": "docdir", "nodeType": "folder", "sortTime": int64(9999)},
{"fileId": "old", "rel_path": "a/old.txt", "sortTime": int64(100)},
{"fileId": "new", "rel_path": "a/new.txt", "sortTime": int64(300)},
{"fileId": "mid", "rel_path": "a/mid.txt", "sortTime": int64(200)},
{"fileId": "notime", "rel_path": "a/notime.txt", "sortTime": int64(0)},
}
got := applyDriveListLatest(items, 2)
if len(got) != 2 {
t.Fatalf("truncate to 2 = %#v", got)
}
if got[0]["fileId"] != "new" || got[1]["fileId"] != "mid" {
t.Fatalf("sortTime desc order = %#v", got)
}
all := applyDriveListLatest(items, 10)
if len(all) != 4 {
t.Fatalf("folders should be dropped: %#v", all)
}
if all[3]["fileId"] != "notime" {
t.Fatalf("missing timestamp should sort last: %#v", all)
}
}
func TestCrossPlatformCoverageApplyDriveListLatestTieBreak(t *testing.T) {
same := []map[string]any{
{"fileId": "z9", "rel_path": "b/dup.txt", "sortTime": int64(500)},
{"fileId": "a1", "rel_path": "b/dup.txt", "sortTime": int64(500)},
{"fileId": "m5", "rel_path": "a/dup.txt", "sortTime": int64(500)},
}
got := applyDriveListLatest(same, 3)
// 同 sortTime → rel_path 升序 → id 升序,输出完全确定。
if got[0]["fileId"] != "m5" || got[1]["fileId"] != "a1" || got[2]["fileId"] != "z9" {
t.Fatalf("tie-break order = %#v", got)
}
}
func TestCrossPlatformCoverageStripDriveDepthDecorations(t *testing.T) {
items := []map[string]any{
{"fileId": "f1", "depth": 1, "parentId": "p", "rel_path": "a", "sortTime": int64(1), "name": "keep"},
}
stripDriveDepthDecorations(items)
for _, key := range []string{"depth", "parentId", "rel_path", "sortTime"} {
if _, ok := items[0][key]; ok {
t.Fatalf("%s not stripped: %#v", key, items[0])
}
}
if items[0]["name"] != "keep" || items[0]["fileId"] != "f1" {
t.Fatalf("business fields lost: %#v", items[0])
}
}
// assertDriveLatestSuggestion 逐子句校验拒绝产出建议的自洽性:约定每个「;」子句把
// 可照抄的命令写在句尾,于是「让用户去掉 --latest」的子句里,命令本身就不能再带 --latest
// ——否则用户照抄示例必然复现同一个错误。只断言 Suggestion != "" 盖不住这种矛盾。
func assertDriveLatestSuggestion(t *testing.T, suggestion string, latest int) {
t.Helper()
if suggestion == "" {
t.Fatal("latest refusal must carry a suggestion")
}
sawLatestExample := false
for _, clause := range strings.Split(suggestion, ";") {
idx := strings.Index(clause, "dws ")
if idx < 0 {
continue
}
prose, command := clause[:idx], clause[idx:]
if !strings.HasPrefix(command, "dws drive list") {
t.Fatalf("clause command must be a dws drive list invocation: %q", command)
}
keepsLatest := strings.Contains(command, "--latest")
if strings.Contains(command, fmt.Sprintf("--latest %d", latest)) {
sawLatestExample = true
}
if strings.Contains(prose, "去掉 --latest") && keepsLatest {
t.Fatalf("clause tells the user to drop --latest but its command keeps it: %q", clause)
}
}
// 缩小范围后仍能取 Top-N 是首选出路,不能只剩「去掉 --latest」这条退路。
if !sawLatestExample {
t.Fatalf("suggestion lost the --latest %d retry example: %q", latest, suggestion)
}
}
func TestCrossPlatformCoverageDriveLatestTruncatedRefusesTopN(t *testing.T) {
useDriveLatestArgs(t)
// 每页 50 条(1 文件夹 + 49 文件)且始终有 nextToken:40 页打满全局 2000 上限。
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: drivePanPage("p", driveDepthPageSize-1, "next")},
}}
out, err := runDepthBFSRaw(t, caller, newDrivePanDepthRoute(), "", 3, "", 5)
if err == nil {
t.Fatal("truncated latest returned nil error")
}
cliErr, ok := err.(*CLIError)
if !ok || cliErr.Code != CodeContentTruncated {
t.Fatalf("error = %#v", err)
}
if !strings.Contains(cliErr.Message, "LATEST_SCAN_TRUNCATED") {
t.Fatalf("message = %q", cliErr.Message)
}
assertDriveLatestSuggestion(t, cliErr.Suggestion, 5)
// 拒绝即不产出:截断集上的 Top-N 不是全局最新,stdout 必须为空。
if out.Len() != 0 {
t.Fatalf("stdout should be empty, got %q", out.String())
}
if caller.calls != driveDepthMaxItems/driveDepthPageSize {
t.Fatalf("calls = %d, want %d", caller.calls, driveDepthMaxItems/driveDepthPageSize)
}
}
func TestCrossPlatformCoverageDocLatestTruncatedRefusesTopN(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: docPage("d", docDepthPageSize, "next")},
}}
out, err := runDepthBFSRaw(t, caller, newDocDepthRoute(), "root1", 1, "", 5)
cliErr, ok := err.(*CLIError)
if !ok || cliErr.Code != CodeContentTruncated {
t.Fatalf("error = %#v", err)
}
assertDriveLatestSuggestion(t, cliErr.Suggestion, 5)
if out.Len() != 0 {
t.Fatalf("stdout should be empty, got %q", out.String())
}
}
func TestCrossPlatformCoverageDriveDepthTruncatedWithoutLatestKeepsPartial(t *testing.T) {
useDriveDepthArgs(t)
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: drivePanPage("p", driveDepthPageSize-1, "next")},
}}
// 回归护栏:不带 latest 时截断仍以 truncated:true 输出满额部分结果。
result, err := runDepthBFS(t, caller, newDrivePanDepthRoute(), "", 3, "")
if err != nil {
t.Fatal(err)
}
if result["truncated"] != true {
t.Fatalf("truncated = %#v", result["truncated"])
}
if items := result["items"].([]any); len(items) != driveDepthMaxItems {
t.Fatalf("items = %d, want %d", len(items), driveDepthMaxItems)
}
}
func TestCrossPlatformCoverageDocLatestSingleLayerStripsDecorations(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"nodes":[
{"nodeId":"nDir","name":"folder","nodeType":"folder","updateTime":1700000009000},
{"nodeId":"nOld","name":"old.doc","nodeType":"doc","updateTime":1700000001000},
{"nodeId":"nNew","name":"new.doc","nodeType":"doc","updateTime":1700000005000}],"hasMore":false}`},
}}
result, err := runDepthBFSLatest(t, caller, newDocDepthRoute(), "root1", 1, "", 2)
if err != nil {
t.Fatal(err)
}
items := result["items"].([]any)
if len(items) != 2 {
t.Fatalf("folders should be excluded from Top-N: %#v", items)
}
first := items[0].(map[string]any)
if first["nodeId"] != "nNew" {
t.Fatalf("newest first = %#v", first)
}
// 单层输出与普通 list 对齐:不带 BFS 装饰字段。
for _, key := range []string{"depth", "parentId", "rel_path", "sortTime"} {
if _, ok := first[key]; ok {
t.Fatalf("%s leaked into single-layer output: %#v", key, first)
}
}
}
func TestCrossPlatformCoverageDocLatestMultiLayerKeepsDecorations(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"nodes":[{"nodeId":"nDir","name":"folder","nodeType":"folder","updateTime":1700000009000}],"hasMore":false}`},
{text: `{"nodes":[{"nodeId":"nInner","name":"inner.doc","nodeType":"doc","updateTime":1700000005000}],"hasMore":false}`},
}}
result, err := runDepthBFSLatest(t, caller, newDocDepthRoute(), "root1", 2, "", 3)
if err != nil {
t.Fatal(err)
}
items := result["items"].([]any)
if len(items) != 1 {
t.Fatalf("items = %#v", items)
}
// depth>1 时保留 rel_path,用户需要知道命中文件在哪一层。
if items[0].(map[string]any)["rel_path"] != "folder/inner.doc" {
t.Fatalf("rel_path = %#v", items[0])
}
// sortTime 是内部排序字段,depth>1 保留装饰字段也不得把它带出去。
if _, ok := items[0].(map[string]any)["sortTime"]; ok {
t.Fatalf("sortTime leaked into multi-layer output: %#v", items[0])
}
}
// 不带 latest 的普通递归:装饰字段照旧输出,内部 sortTime 一个都不能漏。
func TestCrossPlatformCoverageDriveDepthNeverLeaksSortTime(t *testing.T) {
useDriveDepthArgs(t)
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"items":[{"fileId":"f1","name":"a.txt","type":"FILE","modifyTime":1700000001000}]}`},
}}
result, err := runDepthBFS(t, caller, newDrivePanDepthRoute(), "", 2, "")
if err != nil {
t.Fatal(err)
}
items := result["items"].([]any)
if len(items) != 1 {
t.Fatalf("items = %#v", items)
}
first := items[0].(map[string]any)
if _, ok := first["sortTime"]; ok {
t.Fatalf("sortTime leaked into plain depth output: %#v", first)
}
// depth/parentId/rel_path 是 depth>1 的公开契约,不能被一起删掉。
for _, key := range []string{"depth", "parentId", "rel_path"} {
if _, ok := first[key]; !ok {
t.Fatalf("%s missing from depth output: %#v", key, first)
}
}
if first["modifyTime"] == nil || first["name"] != "a.txt" {
t.Fatalf("business fields lost: %#v", first)
}
}
// 递归途中目录读失败 → 排序基不完整,与截断同一条防线:非零退出 + 不产出 Top-N。
func TestCrossPlatformCoverageDriveLatestFolderFailureRefusesTopN(t *testing.T) {
useDriveLatestArgs(t)
out, err := runDepthBFSRaw(t, driveLatestFolderFailureCaller(), newDrivePanDepthRoute(), "", 3, "", 5)
if err == nil {
t.Fatal("incomplete scan returned nil error")
}
cliErr, ok := err.(*CLIError)
if !ok || cliErr.Code != CodeContentTruncated {
t.Fatalf("error = %#v", err)
}
if !strings.Contains(cliErr.Message, "LATEST_SCAN_INCOMPLETE") {
t.Fatalf("message = %q", cliErr.Message)
}
// errors[] 不再进 stdout,失败详情必须落在消息里,否则用户完全瞎。
for _, want := range []string{"dirA", "permission_denied", "denied"} {
if !strings.Contains(cliErr.Message, want) {
t.Fatalf("message lacks %q: %s", want, cliErr.Message)
}
}
assertDriveLatestSuggestion(t, cliErr.Suggestion, 5)
if out.Len() != 0 {
t.Fatalf("stdout should be empty, got %q", out.String())
}
}
// 根目录读取失败的 folder 名回落:根入队时不带 name(driveDepthFolder{id, depth:0}),
// 拒绝产出的消息若直接用 folderName 就成了 "folder=" 空洞提示。未指定 --folder 时
// id 也为空,须一路回落到 <root>。
func TestCrossPlatformCoverageDriveLatestRootPageFailureNamesRoot(t *testing.T) {
useDriveLatestArgs(t)
_, err := runDepthBFSRaw(t, driveLatestRootPageFailureCaller(), newDrivePanDepthRoute(), "", 2, "", 5)
cliErr, ok := err.(*CLIError)
if !ok || cliErr.Code != CodeContentTruncated {
t.Fatalf("error = %#v", err)
}
if !strings.Contains(cliErr.Message, "folder=<root>") {
t.Fatalf("message should name the root folder: %s", cliErr.Message)
}
assertDriveLatestSuggestion(t, cliErr.Suggestion, 5)
}
// 指定了 --folder:folderName 仍为空,回落到 folderId 而不是 <root>,
// 否则用户无法知道是哪个目录读不到。
func TestCrossPlatformCoverageDriveLatestRootPageFailureFallsBackToFolderID(t *testing.T) {
useDriveLatestArgs(t)
_, err := runDepthBFSRaw(t, driveLatestRootPageFailureCaller(), newDrivePanDepthRoute(), "rootFolder", 2, "", 5)
cliErr, ok := err.(*CLIError)
if !ok || cliErr.Code != CodeContentTruncated {
t.Fatalf("error = %#v", err)
}
if !strings.Contains(cliErr.Message, "folder=rootFolder") {
t.Fatalf("message should fall back to the folder id: %s", cliErr.Message)
}
assertDriveLatestSuggestion(t, cliErr.Suggestion, 5)
}
// 护栏:同一场景不带 latest 时行为不变(partial + errors[] + 退出码 0)。
func TestCrossPlatformCoverageDriveDepthFolderFailureWithoutLatestKeepsPartial(t *testing.T) {
useDriveDepthArgs(t)
result, err := runDepthBFSLatest(t, driveLatestFolderFailureCaller(), newDrivePanDepthRoute(), "", 3, "", 0)
if err != nil {
t.Fatal(err)
}
if items := result["items"].([]any); len(items) != 2 {
t.Fatalf("partial items = %#v", items)
}
errs := result["errors"].([]any)
if len(errs) != 1 || errs[0].(map[string]any)["folderId"] != "fA" {
t.Fatalf("errors = %#v", errs)
}
}
// 不可恢复错误(auth/网络)+ latest:不吐 partial,直接回根因错误码。
func TestCrossPlatformCoverageDriveLatestUnrecoverableRefusesTopN(t *testing.T) {
useDriveLatestArgs(t)
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"items":[
{"fileId":"fA","name":"dirA","type":"FOLDER"},
{"fileId":"fX","name":"x.txt","type":"FILE","modifyTime":1700000001000}]}`},
{text: `{"errorCode":"DWS_SERVICE_UNAUTHORIZED"}`},
}}
out, err := runDepthBFSRaw(t, caller, newDrivePanDepthRoute(), "", 3, "", 5)
// 根因(token 过期)比通用截断 token 更可操作,原样上抛。
cliErr, ok := err.(*CLIError)
if !ok || cliErr.Code != CodeAuthTokenExpired {
t.Fatalf("error = %T %v", err, err)
}
if out.Len() != 0 {
t.Fatalf("stdout should be empty, got %q", out.String())
}
// 对应的 latest=0 partial 护栏见 TestCrossPlatformCoverageRunDriveListDepthUnrecoverable。
}
// 根目录成功(1 文件夹 + 1 文件),子目录 403:可恢复失败进 errors[]。
func driveLatestFolderFailureCaller() *scriptedToolCaller {
return &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"items":[
{"fileId":"fA","name":"dirA","type":"FOLDER","modifyTime":1700000009000},
{"fileId":"fX","name":"x.txt","type":"FILE","modifyTime":1700000001000}]}`},
{text: `{"errorCode":"forbidden.noPermission","errorMsg":"denied"}`},
}}
}
// 根目录首页有收获、第 2 页 403:depth==0 不触发「零收获直返」,失败落进 errors[],
// 于是 latest 下由 driveLatestIncompleteError 报告一个 folderName 为空的失败目录。
func driveLatestRootPageFailureCaller() *scriptedToolCaller {
return &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"items":[{"fileId":"fX","name":"x.txt","type":"FILE","modifyTime":1700000001000}],"nextToken":"page2"}`},
{text: `{"errorCode":"forbidden.noPermission","errorMsg":"denied"}`},
}}
}
func TestCrossPlatformCoverageRunDriveListLatestDryRun(t *testing.T) {
out := installDepthCaller(t, &scriptedToolCaller{format: "json", dry: true})
cmd := driveLatestTestCmd()
if err := runDriveListLatest(cmd, map[string]any{"spaceId": "s1"}, "folder1", 3, "", true); err != nil {
t.Fatal(err)
}
var payload map[string]any
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
t.Fatalf("dry-run output: %v\n%s", err, out.String())
}
if payload["tool"] != "list_files" || payload["latest"] != float64(3) {
t.Fatalf("payload = %#v", payload)
}
args := payload["args"].(map[string]any)
if args["orderBy"] != "modifyTime" || args["order"] != "desc" ||
args["spaceId"] != "s1" || args["parentId"] != "folder1" {
t.Fatalf("dry-run args = %#v", args)
}
}
func TestCrossPlatformCoverageRunDriveListLatestPanSingleLayer(t *testing.T) {
useDriveLatestArgs(t)
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"items":[
{"fileId":"fDir","name":"日报归档","type":"FOLDER","modifyTime":1700000009000},
{"fileId":"f1","name":"日报-0801.docx","type":"FILE","modifyTime":1700000005000},
{"fileId":"f2","name":"周报-0801.docx","type":"FILE","modifyTime":1700000004000},
{"fileId":"f3","name":"日报-0731.docx","type":"FILE","modifyTime":1700000003000}],"nextToken":"more"}`},
{text: `{"items":[{"fileId":"f4","name":"日报-0730.docx","type":"FILE","modifyTime":1700000002000}]}`},
}}
out := installDepthCaller(t, caller)
cmd := driveLatestTestCmd()
if err := runDriveListLatest(cmd, map[string]any{}, "", 2, "*日报*", true); err != nil {
t.Fatal(err)
}
// 服务端已按 modifyTime desc 返回,首页凑够 2 条即停,不该翻第二页。
if caller.calls != 1 {
t.Fatalf("calls = %d, want 1", caller.calls)
}
items := decodeDepthResult(t, out)["items"].([]any)
if len(items) != 2 {
t.Fatalf("items = %#v", items)
}
if items[0].(map[string]any)["fileId"] != "f1" || items[1].(map[string]any)["fileId"] != "f3" {
t.Fatalf("folder/pattern filtering failed: %#v", items)
}
}
// 上游对文件名字段有 name / fileName 两种形态,pattern 匹配必须两种都认。
func TestCrossPlatformCoverageRunDriveListLatestFallsBackToFileName(t *testing.T) {
useDriveLatestArgs(t)
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"items":[
{"fileId":"f1","fileName":"日报-0801.docx","type":"FILE","modifyTime":1700000005000},
{"fileId":"f2","fileName":"周报-0801.docx","type":"FILE","modifyTime":1700000004000}]}`},
}}
out := installDepthCaller(t, caller)
cmd := driveLatestTestCmd()
if err := runDriveListLatest(cmd, map[string]any{}, "", 5, "*日报*", true); err != nil {
t.Fatal(err)
}
items := decodeDepthResult(t, out)["items"].([]any)
// 只命中 f1 才说明 pattern 匹配用的是 fileName 回落值,而非空串。
if len(items) != 1 || items[0].(map[string]any)["fileId"] != "f1" {
t.Fatalf("fileName fallback not used for pattern match: %#v", items)
}
}
func TestCrossPlatformCoverageRunDriveListLatestPagesUntilFilled(t *testing.T) {
useDriveLatestArgs(t)
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"items":[{"fileId":"fDir","name":"dir","type":"FOLDER"}],"nextToken":"p2"}`},
{text: `{"items":[{"fileId":"f1","name":"a.txt","type":"FILE","modifyTime":1700000001000}]}`},
}}
out := installDepthCaller(t, caller)
cmd := driveLatestTestCmd()
// quiet=false 覆盖翻页进度输出分支。
stderr := captureDriveLatestStderr(t, func() {
if err := runDriveListLatest(cmd, map[string]any{}, "", 1, "", false); err != nil {
t.Fatal(err)
}
})
if caller.calls != 2 {
t.Fatalf("calls = %d, want 2", caller.calls)
}
if !strings.Contains(stderr, "latest 扫描中") {
t.Fatalf("progress line missing: %q", stderr)
}
if items := decodeDepthResult(t, out)["items"].([]any); len(items) != 1 {
t.Fatalf("items = %#v", items)
}
}
func TestCrossPlatformCoverageRunDriveListLatestShortfall(t *testing.T) {
useDriveLatestArgs(t)
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"items":[{"fileId":"f1","name":"a.txt","type":"FILE","modifyTime":1700000001000}]}`},
}}
out := installDepthCaller(t, caller)
cmd := driveLatestTestCmd()
var runErr error
stderr := captureDriveLatestStderr(t, func() {
runErr = runDriveListLatest(cmd, map[string]any{}, "", 5, "*.txt", true)
})
// 凑不满 N 不是失败:照常输出已找到的部分,提示走 stderr。
if runErr != nil {
t.Fatalf("shortfall returned error: %v", runErr)
}
if !strings.Contains(stderr, "找到 1/5 条") || !strings.Contains(stderr, `--pattern "*.txt"`) {
t.Fatalf("shortfall hint = %q", stderr)
}
if items := decodeDepthResult(t, out)["items"].([]any); len(items) != 1 {
t.Fatalf("items = %#v", items)
}
}
func TestCrossPlatformCoverageRunDriveListLatestStopsAtScanCap(t *testing.T) {
useDriveLatestArgs(t)
// 每页 50 条全是文件夹:永远凑不够,靠 driveLatestScanMax 兜底停机。
folders := make([]string, 0, driveDepthPageSize)
for i := 0; i < driveDepthPageSize; i++ {
folders = append(folders, fmt.Sprintf(`{"fileId":"d%d","name":"d%d","type":"FOLDER"}`, i, i))
}
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: fmt.Sprintf(`{"items":[%s],"nextToken":"loop"}`, strings.Join(folders, ","))},
}}
out := installDepthCaller(t, caller)
cmd := driveLatestTestCmd()
stderr := captureDriveLatestStderr(t, func() {
if err := runDriveListLatest(cmd, map[string]any{}, "", 3, "", true); err != nil {
t.Fatal(err)
}
})
if caller.calls != driveLatestScanMax/driveDepthPageSize {
t.Fatalf("calls = %d, want %d", caller.calls, driveLatestScanMax/driveDepthPageSize)
}
if !strings.Contains(stderr, fmt.Sprintf("已扫描 %d 条", driveLatestScanMax)) {
t.Fatalf("scan cap hint = %q", stderr)
}
if items := decodeDepthResult(t, out)["items"].([]any); len(items) != 0 {
t.Fatalf("items = %#v", items)
}
}
func TestCrossPlatformCoverageRunDriveListLatestFetchError(t *testing.T) {
useDriveLatestArgs(t)
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"errorCode":"forbidden.noPermission","errorMsg":"denied"}`},
}}
installDepthCaller(t, caller)
cmd := driveLatestTestCmd()
err := runDriveListLatest(cmd, map[string]any{}, "", 3, "", true)
if err == nil {
t.Fatal("fetch error returned nil")
}
if !strings.Contains(err.Error(), "latest 扫描第 1 页失败") {
t.Fatalf("error = %v", err)
}
}
@@ -0,0 +1,209 @@
package helpers
import (
"strings"
"testing"
)
// 命令层路由:--latest 的校验时机、三条落点(钉盘单层 / 钉盘 BFS / 知识库 BFS)。
// 后置处理器与扫描循环的行为断言在 drive_latest_test.go。
func TestCrossPlatformCoverageDriveListLatestRejectedWithVersions(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newDriveCommand,
"list", "--versions", "--node", "node-1", "--latest", "3")
if err == nil || !strings.Contains(err.Error(), "--versions 与 --latest 不能同时使用") {
t.Fatalf("err = %v", err)
}
if len(caller.calls) != 0 {
t.Fatalf("calls = %#v", caller.calls)
}
}
// versions 合法使用 --limit:--latest 的互斥校验不得在此路径上误伤。
func TestCrossPlatformCoverageDriveListVersionsWithLimitStillWorks(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newDriveCommand,
"list", "--versions", "--node", "node-1", "--limit", "5")
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 || caller.calls[0].toolName != "list_file_versions" {
t.Fatalf("calls = %#v", caller.calls)
}
}
func TestCrossPlatformCoverageDriveListLatestRejectsOutOfRange(t *testing.T) {
for _, value := range []string{"0", "51"} {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newDriveCommand, "list", "--latest", value)
if err == nil || !strings.Contains(err.Error(), "--latest 必须为 1~50 的整数") {
t.Fatalf("--latest %s err = %v", value, err)
}
if len(caller.calls) != 0 {
t.Fatalf("--latest %s calls = %#v", value, caller.calls)
}
}
}
func TestCrossPlatformCoverageDriveListLatestRejectsExclusiveFlags(t *testing.T) {
// 用真实 newDriveCommand 构造:RegisterCrossProductAliases 自动注册的隐藏别名
// (--page-size / --page-token / --next-token)必须同样被互斥校验拦住。
for _, extra := range [][]string{
{"--limit", "5"},
{"--max", "5"},
{"--page-size", "5"},
{"--order-by", "name"},
{"--order", "asc"},
{"--cursor", "c1"},
{"--page-token", "t1"},
{"--next-token", "t1"},
} {
caller := &guardedMutationCaller{}
args := append([]string{"list", "--latest", "3"}, extra...)
err := executeGuardedMutationCommand(t, caller, newDriveCommand, args...)
// 报错点名用户实际传的那个 flag,而不是语义组主名。
want := "--latest 不能与 " + extra[0] + " 同时使用"
if err == nil || !strings.Contains(err.Error(), want) {
t.Fatalf("%v err = %v, want %q", extra, err, want)
}
if len(caller.calls) != 0 {
t.Fatalf("%v calls = %#v", extra, caller.calls)
}
}
}
// Agent 常用 --cursor "$CURSOR" 模板,首页展开为空串:空值不算「传了」,不得误伤。
func TestCrossPlatformCoverageDriveListLatestAllowsEmptyCursorTemplate(t *testing.T) {
useDriveLatestArgs(t)
caller := &depthArgsRecordingCaller{steps: []scriptedToolStep{
{text: `{"items":[{"fileId":"f1","name":"a.txt","type":"FILE","modifyTime":1700000001000}]}`},
}}
err := executeDriveCommand(t, caller,
"list", "--latest", "1", "--cursor", "", "--page-token", "", "--next-token", "")
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v, want 1", caller.calls)
}
}
// --depth 的分页互斥同样按语义组覆盖隐藏别名(此前 --page-size/--page-token 被静默忽略)。
func TestCrossPlatformCoverageDriveListDepthRejectsPaginationAliases(t *testing.T) {
for _, extra := range [][]string{
{"--limit", "10"},
{"--max", "10"},
{"--page-size", "10"},
{"--cursor", "c1"},
{"--page-token", "t1"},
{"--next-token", "t1"},
} {
caller := &guardedMutationCaller{}
args := append([]string{"list", "--depth", "2"}, extra...)
err := executeGuardedMutationCommand(t, caller, newDriveCommand, args...)
want := "--depth 不能与 " + extra[0] + " 同时使用"
if err == nil || !strings.Contains(err.Error(), want) {
t.Fatalf("%v err = %v, want %q", extra, err, want)
}
if len(caller.calls) != 0 {
t.Fatalf("%v calls = %#v", extra, caller.calls)
}
}
}
func TestCrossPlatformCoverageDriveListLatestPanRoute(t *testing.T) {
useDriveLatestArgs(t)
caller := &depthArgsRecordingCaller{steps: []scriptedToolStep{
{text: `{"items":[
{"fileId":"f1","name":"a.txt","type":"FILE","modifyTime":1700000002000},
{"fileId":"f2","name":"b.txt","type":"FILE","modifyTime":1700000001000}]}`},
}}
err := executeDriveCommand(t, caller,
"list", "--latest", "2", "--space-id", "sp-1", "--thumbnail", "--folder", "root-folder")
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v, want 1", caller.calls)
}
args := caller.calls[0]
// 钉盘单层 latest 借服务端排序:orderBy/order 由 CLI 强制注入。
if args["orderBy"] != "modifyTime" || args["order"] != "desc" {
t.Fatalf("server-side ordering missing: %#v", args)
}
if args["spaceId"] != "sp-1" || args["withThumbnail"] != true ||
args["parentId"] != "root-folder" || args["maxResults"] != float64(driveDepthPageSize) {
t.Fatalf("pan latest args = %#v", args)
}
}
func TestCrossPlatformCoverageDriveListLatestPanRejectsNumericFolder(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newDriveCommand,
"list", "--latest", "3", "--folder", "12345")
if err == nil {
t.Fatal("numeric drive folder returned nil")
}
if len(caller.calls) != 0 {
t.Fatalf("calls = %#v", caller.calls)
}
}
func TestCrossPlatformCoverageDriveListLatestPanDepthRoutesBFS(t *testing.T) {
useDriveLatestArgs(t)
caller := &depthArgsRecordingCaller{steps: []scriptedToolStep{
{text: `{"items":[{"fileId":"f1","name":"a.txt","type":"FILE","modifyTime":1700000002000}]}`},
}}
err := executeDriveCommand(t, caller, "list", "--latest", "2", "--depth", "2")
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v, want 1", caller.calls)
}
// depth>1 走 BFS,不注入服务端排序(多目录合并后由后置处理器统一排序)。
if _, ok := caller.calls[0]["orderBy"]; ok {
t.Fatalf("BFS route should not force orderBy: %#v", caller.calls[0])
}
}
func TestCrossPlatformCoverageDriveListLatestWorkspaceRoutesDocBFS(t *testing.T) {
caller := &depthArgsRecordingCaller{steps: []scriptedToolStep{
{text: `{"nodes":[
{"nodeId":"n1","name":"old.doc","nodeType":"doc","updateTime":1700000001000},
{"nodeId":"n2","name":"new.doc","nodeType":"doc","updateTime":1700000005000}],"hasMore":false}`},
}}
err := executeDriveCommand(t, caller, "list", "--workspace", "ws-1", "--latest", "2")
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v, want 1", caller.calls)
}
args := caller.calls[0]
if args["workspaceId"] != "ws-1" || args["pageSize"] != float64(docDepthPageSize) {
t.Fatalf("doc latest args = %#v", args)
}
}
// --pattern 在知识库单层原本被拒;latest 打开 BFS 路径后应放行(先递归后过滤)。
func TestCrossPlatformCoverageDriveListLatestWorkspaceAllowsPattern(t *testing.T) {
caller := &depthArgsRecordingCaller{steps: []scriptedToolStep{
{text: `{"nodes":[
{"nodeId":"n1","name":"日报-0801","nodeType":"doc","updateTime":1700000001000},
{"nodeId":"n2","name":"周报-0801","nodeType":"doc","updateTime":1700000005000}],"hasMore":false}`},
}}
if err := executeDriveCommand(t, caller,
"list", "--workspace", "ws-1", "--latest", "2", "--pattern", "*日报*"); err != nil {
t.Fatal(err)
}
// 不带 latest 的知识库单层仍然拒绝 --pattern。
rejected := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, rejected, newDriveCommand,
"list", "--workspace", "ws-1", "--pattern", "*日报*")
if err == nil || !strings.Contains(err.Error(), "--pattern 仅适用于钉盘文件列表") {
t.Fatalf("err = %v", err)
}
}
+69
View File
@@ -0,0 +1,69 @@
package helpers
import (
"encoding/json"
"strconv"
"strings"
"time"
)
// ──────────────────────────────────────────────────────────
// 钉盘 / 知识库条目的修改时间归一化
//
// 两条路由的时间字段名与类型都不统一:钉盘返回 modifyTime,知识库返回 updateTime,
// 值可能是毫秒数字、数字字符串或 RFC3339 字符串。--latest 需要跨路由排序,
// 因此在此处收敛为统一的 Unix 毫秒。纯函数,不修改入参。
// ──────────────────────────────────────────────────────────
// 探测顺序即优先级:先精确字段名,再回落到各服务端的历史别名。
var driveModifiedTimeKeys = []string{
"modifiedTime",
"modifyTime",
"modified_time",
"gmtModified",
"lastModifiedTime",
"updateTime",
}
// driveModifiedMillis 按 driveModifiedTimeKeys 顺序探测条目的修改时间。
// 首个可解析出正毫秒值的字段胜出;全部缺失或不可解析时返回 (0, false)。
func driveModifiedMillis(item map[string]any) (int64, bool) {
for _, key := range driveModifiedTimeKeys {
v, ok := item[key]
if !ok {
continue
}
if ms, ok := driveToMillis(v); ok {
return ms, true
}
}
return 0, false
}
// driveToMillis 把时间字段归一化为 Unix 毫秒。支持毫秒时间戳(数字或数字字符串)
// 与 RFC3339 字符串;非正值与无法识别的形态一律返回 (0, false),由调用方决定降级策略。
func driveToMillis(v any) (int64, bool) {
switch t := v.(type) {
case float64:
if t <= 0 {
return 0, false
}
return int64(t), true
case json.Number:
if n, err := t.Int64(); err == nil && n > 0 {
return n, true
}
case string:
s := strings.TrimSpace(t)
if s == "" {
return 0, false
}
if n, err := strconv.ParseInt(s, 10, 64); err == nil && n > 0 {
return n, true
}
if tm, err := time.Parse(time.RFC3339, s); err == nil {
return tm.UnixMilli(), true
}
}
return 0, false
}
+75
View File
@@ -0,0 +1,75 @@
package helpers
import (
"encoding/json"
"testing"
"time"
)
func TestCrossPlatformCoverageDriveToMillis(t *testing.T) {
rfc := "2023-11-14T22:13:20Z"
want := func() int64 {
tm, err := time.Parse(time.RFC3339, rfc)
if err != nil {
t.Fatalf("parse fixture: %v", err)
}
return tm.UnixMilli()
}()
for _, tc := range []struct {
name string
in any
ms int64
valid bool
}{
{"float64", float64(1700000000000), 1700000000000, true},
{"float64 non-positive", float64(0), 0, false},
{"json.Number", json.Number("1700000000000"), 1700000000000, true},
{"json.Number non-numeric", json.Number("abc"), 0, false},
{"json.Number non-positive", json.Number("-1"), 0, false},
{"numeric string", "1700000000000", 1700000000000, true},
{"rfc3339 string", rfc, want, true},
{"blank string", " ", 0, false},
{"garbage string", "not-a-time", 0, false},
{"unsupported type", true, 0, false},
{"nil", nil, 0, false},
} {
ms, ok := driveToMillis(tc.in)
if ok != tc.valid || ms != tc.ms {
t.Fatalf("%s: driveToMillis(%#v) = (%d, %v), want (%d, %v)", tc.name, tc.in, ms, ok, tc.ms, tc.valid)
}
}
}
func TestCrossPlatformCoverageDriveModifiedMillis(t *testing.T) {
if _, ok := driveModifiedMillis(map[string]any{"name": "no time"}); ok {
t.Fatal("item without any time key reported a timestamp")
}
// 探测顺序即优先级:精确字段名胜出于历史别名。
priority := map[string]any{
"updateTime": float64(1),
"gmtModified": float64(2),
"modifyTime": float64(3),
"modifiedTime": float64(4),
}
if ms, ok := driveModifiedMillis(priority); !ok || ms != 4 {
t.Fatalf("priority = (%d, %v), want (4, true)", ms, ok)
}
// 靠前的键不可解析时继续回落,不能提前判定为无时间。
fallback := map[string]any{
"modifiedTime": "",
"modifyTime": float64(0),
"updateTime": float64(1700000000000),
}
if ms, ok := driveModifiedMillis(fallback); !ok || ms != 1700000000000 {
t.Fatalf("fallback = (%d, %v)", ms, ok)
}
for _, key := range driveModifiedTimeKeys {
if ms, ok := driveModifiedMillis(map[string]any{key: float64(1234)}); !ok || ms != 1234 {
t.Fatalf("key %s = (%d, %v)", key, ms, ok)
}
}
}
+3 -495
View File
@@ -14,7 +14,6 @@ import (
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
// ──────────────────────────────────────────────────────────
@@ -196,72 +195,7 @@ func newMailCommand() *cobra.Command {
mailboxProfileCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
mailboxSharedWithMeCmd := &cobra.Command{
Use: "shared-with-me",
Short: "查询共享给我的邮箱",
Long: `查询他人共享给当前用户的邮箱账号列表,包含共享关系类型。
共享关系(relationships)取值:
LOGIN 登录(可登录该共享邮箱)
SEND_AS 代发(以该邮箱身份发送邮件)
SEND_ON_BEHALF 代表发送(代表该邮箱发送邮件)
返回字段:
total 可访问的共享账号总数
targets 可访问的共享账号列表`,
Example: ` dws mail mailbox shared-with-me
dws mail mailbox shared-with-me --limit 20 --offset 0`,
RunE: func(cmd *cobra.Command, args []string) error {
toolArgs := map[string]any{}
if cmd.Flags().Changed("limit") {
limit, _ := cmd.Flags().GetInt("limit")
toolArgs["limit"] = limit
}
if cmd.Flags().Changed("offset") {
offset, _ := cmd.Flags().GetInt("offset")
toolArgs["offset"] = offset
}
if len(toolArgs) == 0 {
return callMCPTool("list_shared_with_me", nil)
}
return callMCPTool("list_shared_with_me", toolArgs)
},
}
DeclareLeafMetadata(mailboxSharedWithMeCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "mail",
Name: "list_shared_with_me",
CanonicalPath: "mail.list_shared_with_me",
CLIPath: "mail mailbox shared-with-me",
PrimaryCLIPath: "mail mailbox shared-with-me",
},
Description: "查询共享给我的邮箱",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "mail", RPCName: "list_shared_with_me"},
},
Selection: contract.SelectionSpec{
AgentSummary: "查询他人共享给当前用户的邮箱",
UseWhen: []string{"需要列出登录/代发/代表发送权限的共享邮箱时"},
AvoidWhen: []string{"列出自己邮箱用 mail mailbox list"},
Examples: []string{"dws mail mailbox shared-with-me"},
},
Parameters: []contract.ParamDecl{
{Name: "limit", Property: "limit", InterfaceType: "integer"},
{Name: "offset", Property: "offset", InterfaceType: "integer"},
},
},
})
mailboxSharedWithMeCmd.Flags().Int("limit", 0, "返回数量上限 (可选)")
mailboxSharedWithMeCmd.Flags().Int("offset", 0, "偏移量 (可选)")
mailboxCmd.AddCommand(mailboxListCmd, mailboxProfileCmd, mailboxSharedWithMeCmd)
mailboxCmd.AddCommand(mailboxListCmd, mailboxProfileCmd)
messageCmd := &cobra.Command{Use: "message", Short: "邮件管理", RunE: groupRunE}
@@ -2192,256 +2126,9 @@ internetMessageId 来源:message send / draft send / message reply / message r
messageVerifyCmd.Flags().String("email", "", "邮件所属邮箱地址 (必填)")
messageVerifyCmd.Flags().String("internet-message-id", "", "邮件的 internetMessageId (必填),取自发送类命令返回值")
messageExportCmd := &cobra.Command{
Use: "export",
Short: "导出/备份邮件(EML格式)",
Long: `导出指定邮件为 EML 格式文件并保存到本地。
不指定 --filename 时,默认以邮件主题作为文件名。
文件保存在当前工作目录下,扩展名为 .eml。
默认不覆盖同名文件,使用 --overwrite 强制覆盖。
注意:目前仅支持 100KB 以内的邮件导出。
编排流程:
1. 调用 get_email_by_message_id 获取邮件主题(用作默认文件名)
2. 调用 export_message_mime 获取 EML 内容
3. 将 EML 内容原子写入本地文件`,
Example: ` dws mail message export --email user@company.com --id <messageId>
dws mail message export --email user@company.com --id <messageId> --filename my-mail`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "email", "id"); err != nil {
return err
}
email := mustGetFlag(cmd, "email")
messageID := mustGetFlag(cmd, "id")
filename := mustGetFlag(cmd, "filename")
overwrite, _ := cmd.Flags().GetBool("overwrite")
if deps.Caller.DryRun() {
// Human plan summary (no "[DRY-RUN]" tag): Schema dry-run
// evidence classifies "操作:" + audited DryRun() as plan.
deps.Out.PrintKeyValue("操作", "导出邮件为 EML 文件")
deps.Out.PrintKeyValue("email", email)
deps.Out.PrintKeyValue("messageId", messageID)
if filename != "" {
deps.Out.PrintKeyValue("filename", filename)
}
deps.Out.PrintKeyValue("overwrite", fmt.Sprintf("%v", overwrite))
deps.Out.PrintKeyValue("编排", "get_email_by_message_id → export_message_mime → 写入本地 .eml 文件")
return nil
}
ctx := cmd.Context()
if filename == "" {
msgText, err := callMCPToolReturnText(ctx, "get_email_by_message_id", map[string]any{
"email": email,
"messageId": messageID,
})
if err != nil {
return fmt.Errorf("获取邮件信息失败: %w", err)
}
var msgData map[string]any
if err := json.Unmarshal([]byte(msgText), &msgData); err == nil {
data := msgData
if result, ok := data["result"].(map[string]any); ok {
data = result
}
if msg, ok := data["message"].(map[string]any); ok {
data = msg
}
if subj, ok := data["subject"].(string); ok && subj != "" {
filename = subj
}
}
if filename == "" {
filename = messageID
}
}
filename = sanitizeMailFilename(filename)
exportText, err := callMCPToolReturnText(ctx, "export_message_mime", map[string]any{
"email": email,
"id": messageID,
})
if err != nil {
return fmt.Errorf("导出邮件失败: %w", err)
}
var exportData map[string]any
if err := json.Unmarshal([]byte(exportText), &exportData); err != nil {
return fmt.Errorf("解析导出结果失败: %w", err)
}
if result, ok := exportData["result"].(map[string]any); ok {
exportData = result
}
emlContent, _ := exportData["emlContent"].(string)
if emlContent == "" {
return fmt.Errorf("导出结果为空: %s", exportText)
}
destPath := filename + ".eml"
if err := atomicWriteFile(destPath, []byte(emlContent), 0600, overwrite); err != nil {
if os.IsExist(err) {
return fmt.Errorf("文件 %s 已存在,使用 --overwrite 覆盖", destPath)
}
return fmt.Errorf("保存文件失败: %w", err)
}
deps.Out.PrintInfo(fmt.Sprintf("邮件已导出到: %s", destPath))
return nil
},
}
DeclareLeafMetadata(messageExportCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "mail",
Name: "export_message_mime",
CanonicalPath: "mail.export_message_mime",
CLIPath: "mail message export",
PrimaryCLIPath: "mail message export",
},
Description: "导出/备份邮件为本地 EML 文件",
DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false},
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Orchestrates get_email_by_message_id + export_message_mime + local file write.",
},
Selection: contract.SelectionSpec{
AgentSummary: "导出邮件为本地 EML 备份",
UseWhen: []string{"需要把单封邮件备份为本地 .eml 文件时"},
AvoidWhen: []string{"仅查看正文用 mail message get;分享到 IM 用 mail message share-to-chat"},
Examples: []string{"dws mail message export --email user@company.com --id <messageId>"},
},
Parameters: []contract.ParamDecl{
{Name: "email", Property: "email", Required: boolPtr(true)},
{Name: "id", Property: "id", Required: boolPtr(true)},
{Name: "filename", Property: "filename"},
{Name: "overwrite", Property: "overwrite", InterfaceType: "boolean"},
},
},
})
messageExportCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
messageExportCmd.Flags().String("id", "", "邮件ID (必填)")
messageExportCmd.Flags().String("filename", "", "导出文件名(不含扩展名),默认使用邮件主题")
messageExportCmd.Flags().Bool("overwrite", false, "是否覆盖同名文件,默认 false")
messageShareToChatCmd := &cobra.Command{
Use: "share-to-chat",
Short: "[危险] 分享邮件至IM聊天",
Long: `将指定邮件分享到钉钉单聊。
参数说明:
--users 目标用户UID列表,逗号分隔(规范名),兼容 --uids
--yes 确认执行此危险操作 (必填)
默认需要 --yes 确认才能执行;--dry-run 仅预览分享计划,不发起真实请求。
服务端可能返回风险提示(riskMessage)和 sign;在 --yes 已通过的前提下,
将展示风险提示并自动携带 sign 重新请求。`,
Example: ` dws mail message share-to-chat --email user@company.com --id <messageId> --users uid1,uid2 --yes
dws mail message share-to-chat --email user@company.com --id <messageId> --users uid1 --yes`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "email", "id"); err != nil {
return err
}
mcpArgs := map[string]any{
"email": mustGetFlag(cmd, "email"),
"id": mustGetFlag(cmd, "id"),
}
if users := flagOrFallback(cmd, "users", "uids"); users != "" {
mcpArgs["uids"] = parseRecipients(users)
}
yes, _ := cmd.Flags().GetBool("yes")
if deps.Caller.DryRun() {
// Human plan summary (no "[DRY-RUN]" tag): Schema dry-run
// evidence classifies "操作:" + audited DryRun() as plan.
deps.Out.PrintKeyValue("操作", "分享邮件至 IM 聊天")
deps.Out.PrintKeyValue("email", mustGetFlag(cmd, "email"))
deps.Out.PrintKeyValue("messageId", mustGetFlag(cmd, "id"))
if users := flagOrFallback(cmd, "users", "uids"); users != "" {
deps.Out.PrintKeyValue("users", users)
}
deps.Out.PrintKeyValue("yes", fmt.Sprintf("%v", yes))
deps.Out.PrintKeyValue("说明", "仅预览分享计划,不发起真实分享请求")
return nil
}
if !commandBoolFlag(cmd, "yes") {
return apperrors.NewValidation(
"分享邮件至 IM 为高风险操作;获得用户确认后加 --yes 执行",
apperrors.WithReason("confirmation_required"),
apperrors.WithHint("先确认目标用户与邮件内容;用户明确同意后以相同参数追加 --yes"),
apperrors.WithActions("确认目标用户与邮件", "获得用户确认后使用 --yes 执行"),
)
}
ctx := cmd.Context()
firstText, err := callMCPToolReturnText(ctx, "share_message_to_chat", mcpArgs)
if err != nil {
return fmt.Errorf("分享邮件失败: %w", err)
}
var firstResult map[string]any
if err := json.Unmarshal([]byte(firstText), &firstResult); err != nil {
return fmt.Errorf("解析分享结果失败: %w", err)
}
if result, ok := firstResult["result"].(map[string]any); ok {
firstResult = result
}
if sign, ok := firstResult["sign"].(string); ok && sign != "" {
if riskMsg, _ := firstResult["riskMessage"].(string); riskMsg != "" {
deps.Out.PrintInfo(fmt.Sprintf("[风险提示] %s", riskMsg))
}
mcpArgs["sign"] = sign
return callMCPTool("share_message_to_chat", mcpArgs)
}
// firstResult is already a parsed object (possibly unwrapped from result).
return deps.Out.PrintJSON(firstResult)
},
}
DeclareLeafMetadata(messageShareToChatCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "high",
Confirmation: "user_required", Idempotency: "non_idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "mail",
Name: "share_message_to_chat",
CanonicalPath: "mail.share_message_to_chat",
CLIPath: "mail message share-to-chat",
PrimaryCLIPath: "mail message share-to-chat",
},
Description: "分享邮件至 IM 单聊",
DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false},
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "mail", RPCName: "share_message_to_chat"},
},
Selection: contract.SelectionSpec{
AgentSummary: "把邮件分享到钉钉单聊",
UseWhen: []string{"需要将指定邮件分享给钉钉用户(单聊)时"},
AvoidWhen: []string{"仅导出本地备份用 mail message export;群聊发消息用 chat message send"},
Examples: []string{"dws mail message share-to-chat --email user@company.com --id <messageId> --users uid1"},
},
Parameters: []contract.ParamDecl{
{Name: "email", Property: "email", Required: boolPtr(true)},
{Name: "id", Property: "id", Required: boolPtr(true)},
{Name: "users", Property: "uids"},
{Name: "yes", Property: "yes", InterfaceType: "boolean"},
},
},
})
messageShareToChatCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
messageShareToChatCmd.Flags().String("id", "", "邮件ID (必填)")
messageShareToChatCmd.Flags().String("users", "", "目标用户UID列表,逗号分隔")
messageShareToChatCmd.Flags().String("uids", "", "--users 的别名")
_ = messageShareToChatCmd.Flags().MarkHidden("uids")
messageShareToChatCmd.Flags().Bool("yes", false, "确认执行此危险操作 (必填)")
messageCmd.AddCommand(messageListCmd, messageSearchCmd, messageGetCmd, messageSendCmd,
messageReplyCmd, messageReplyAllCmd, messageForwardCmd,
messageBatchMoveCmd, messageBatchDeleteCmd, messageBatchModifyCmd, messageBatchGetCmd, messageVerifyCmd, messageExportCmd, messageShareToChatCmd)
messageBatchMoveCmd, messageBatchDeleteCmd, messageBatchModifyCmd, messageBatchGetCmd, messageVerifyCmd)
sentMessageCmd := &cobra.Command{Use: "sent-message", Short: "已发送邮件管理", RunE: groupRunE}
@@ -3713,129 +3400,7 @@ object 与 operation 合法组合:
blockListRemoveCmd.Flags().String("entries", "", "逗号分隔的地址列表,支持邮件地址(如123@domain.com)或域名(如@domain.com)")
blockListCmd.AddCommand(blockListListCmd, blockListAddCmd, blockListRemoveCmd)
calendarCmd := &cobra.Command{Use: "calendar", Short: "邮箱日历管理", RunE: groupRunE}
calendarListCmd := &cobra.Command{
Use: "list",
Short: "列出用户可访问的日历列表",
Long: `列出用户可访问的日历列表,包括用户自己创建以及接受共享后生成的日历。
返回的 id 可作为 calendar-event list 的 --id / --folder-id 使用。`,
Example: ` dws mail calendar list --email user@company.com`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "email"); err != nil {
return err
}
return callMCPTool("list_mailbox_calendars", map[string]any{
"email": mustGetFlag(cmd, "email"),
})
},
}
DeclareLeafMetadata(calendarListCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "mail",
Name: "list_mailbox_calendars",
CanonicalPath: "mail.list_mailbox_calendars",
CLIPath: "mail calendar list",
PrimaryCLIPath: "mail calendar list",
},
Description: "列出邮箱日历文件夹",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "mail", RPCName: "list_mailbox_calendars"},
},
Selection: contract.SelectionSpec{
AgentSummary: "列出邮箱可访问的日历文件夹",
UseWhen: []string{"查询邮箱日历文件夹 id,以便继续查日程时"},
AvoidWhen: []string{"钉钉主日历日程请用 dws calendar event list"},
Examples: []string{"dws mail calendar list --email user@company.com"},
},
Parameters: []contract.ParamDecl{
{Name: "email", Property: "email", Required: boolPtr(true)},
},
},
})
calendarListCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
calendarCmd.AddCommand(calendarListCmd)
calendarEventCmd := &cobra.Command{Use: "calendar-event", Short: "邮箱日历日程管理", RunE: groupRunE}
calendarEventListCmd := &cobra.Command{
Use: "list",
Short: "查询指定日历时间范围内的日程",
Long: `查询指定邮箱日历文件夹在 UTC 时间区间 [startTime, endTime) 内出现的日程,支持 cursor 分页。循环日程会展开为该时间范围内的单次日程。`,
Example: ` dws mail calendar-event list --email user@company.com --id <calendarFolderId> --start "2026-07-01T00:00:00Z" --end "2026-07-31T23:59:59Z"
dws mail calendar-event list --email user@company.com --id <calendarFolderId> --start "2026-07-01T00:00:00Z" --end "2026-07-31T23:59:59Z" --cursor <cursor>`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "email", "start", "end"); err != nil {
return err
}
if err := validateRequiredFlagWithAliases(cmd, "id", "folder-id"); err != nil {
return err
}
toolArgs := map[string]any{
"email": mustGetFlag(cmd, "email"),
"id": flagOrFallback(cmd, "id", "folder-id"),
"startTime": flagOrFallback(cmd, "start", "start-time"),
"endTime": flagOrFallback(cmd, "end", "end-time"),
}
if cursor := mustGetFlag(cmd, "cursor"); cursor != "" {
toolArgs["cursor"] = cursor
}
return callMCPTool("list_mailbox_calendar_events", toolArgs)
},
}
DeclareLeafMetadata(calendarEventListCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "mail",
Name: "list_mailbox_calendar_events",
CanonicalPath: "mail.list_mailbox_calendar_events",
CLIPath: "mail calendar-event list",
PrimaryCLIPath: "mail calendar-event list",
},
Description: "查询邮箱日历文件夹时间范围内的日程",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "mail", RPCName: "list_mailbox_calendar_events"},
},
Selection: contract.SelectionSpec{
AgentSummary: "查询邮箱日历日程列表",
UseWhen: []string{"已知邮箱日历文件夹 id,需要按 UTC 时间窗列出日程时"},
AvoidWhen: []string{"钉钉主日历请用 dws calendar event list;未知文件夹 id 时先 mail calendar list"},
Examples: []string{"dws mail calendar-event list --email user@company.com --id <calendarFolderId> --start \"2026-07-01T00:00:00Z\" --end \"2026-07-31T23:59:59Z\""},
},
Parameters: []contract.ParamDecl{
{Name: "email", Property: "email", Required: boolPtr(true)},
{Name: "id", Property: "id", Required: boolPtr(true)},
{Name: "start", Property: "startTime", Required: boolPtr(true)},
{Name: "end", Property: "endTime", Required: boolPtr(true)},
{Name: "cursor", Property: "cursor"},
},
},
})
calendarEventListCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
calendarEventListCmd.Flags().String("id", "", "日历文件夹ID (必填)")
calendarEventListCmd.Flags().String("folder-id", "", "--id 的别名")
_ = calendarEventListCmd.Flags().MarkHidden("folder-id")
calendarEventListCmd.Flags().String("start", "", "视图开始UTC时间 (必填)")
calendarEventListCmd.Flags().String("start-time", "", "--start 的别名")
_ = calendarEventListCmd.Flags().MarkHidden("start-time")
calendarEventListCmd.Flags().String("end", "", "视图结束UTC时间 (必填)")
calendarEventListCmd.Flags().String("end-time", "", "--end 的别名")
_ = calendarEventListCmd.Flags().MarkHidden("end-time")
calendarEventListCmd.Flags().String("cursor", "", "分页光标 (可选)")
calendarEventCmd.AddCommand(calendarEventListCmd)
root.AddCommand(mailboxCmd, messageCmd, sentMessageCmd, draftCmd, threadCmd, folderCmd, tagCmd, userCmd, attachmentCmd, templateCmd, contactCmd, autoReplyCmd, ruleCmd, allowListCmd, blockListCmd, calendarCmd, calendarEventCmd)
root.AddCommand(mailboxCmd, messageCmd, sentMessageCmd, draftCmd, threadCmd, folderCmd, tagCmd, userCmd, attachmentCmd, templateCmd, contactCmd, autoReplyCmd, ruleCmd, allowListCmd, blockListCmd)
return root
}
@@ -4370,60 +3935,3 @@ func runMailAttachmentDownload(cmd *cobra.Command) error {
deps.Out.PrintInfo(fmt.Sprintf("附件已保存到: %s", destPath))
return nil
}
func sanitizeMailFilename(name string) string {
name = strings.TrimSpace(name)
name = strings.ReplaceAll(name, "/", "_")
name = strings.ReplaceAll(name, "\\", "_")
name = strings.ReplaceAll(name, "\x00", "")
if name == "" {
return "mail"
}
return name
}
// mailAtomicLink is the no-clobber commit for atomicWriteFile (test-injectable).
var mailAtomicLink = os.Link
// atomicWriteFile 原子写入文件:先写同目录临时文件,成功后提交到目标路径。
// overwrite=false 使用 link(2) 实现存在即失败;overwrite=true 使用 rename 覆盖。
func atomicWriteFile(path string, data []byte, perm os.FileMode, overwrite bool) error {
dir := filepath.Dir(path)
tmp, err := atomicCreateTemp(dir, "."+filepath.Base(path)+".*.tmp")
if err != nil {
return fmt.Errorf("创建临时文件失败: %w", err)
}
tmpName := tmp.Name()
success := false
defer func() {
if !success {
_ = tmp.Close()
_ = atomicRemove(tmpName)
}
}()
if err := tmp.Chmod(perm); err != nil {
return fmt.Errorf("设置文件权限失败: %w", err)
}
if _, err := tmp.Write(data); err != nil {
return fmt.Errorf("写入数据失败: %w", err)
}
if err := tmp.Sync(); err != nil {
return fmt.Errorf("同步磁盘失败: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("关闭临时文件失败: %w", err)
}
if overwrite {
if err := atomicRename(tmpName, path); err != nil {
return fmt.Errorf("重命名文件失败: %w", err)
}
success = true
return nil
}
if err := mailAtomicLink(tmpName, path); err != nil {
return err
}
_ = atomicRemove(tmpName)
success = true
return nil
}
+1 -2
View File
@@ -49,13 +49,12 @@ func newMarkdownCommand() *cobra.Command {
root := &cobra.Command{
Use: "markdown",
Short: "Markdown 文件处理",
Long: "创建、覆盖、修补、对比和获取钉盘或文档空间中的原生 Markdown 文件。",
Long: "创建、覆盖、修补和获取钉盘或文档空间中的原生 Markdown 文件。",
RunE: groupRunE,
}
root.AddCommand(
newMarkdownFetchCmd(),
newMarkdownCreateCmd(),
newMarkdownDiffCmd(),
newMarkdownOverwriteCmd(),
newMarkdownPatchCmd(),
)
-491
View File
@@ -1,491 +0,0 @@
package helpers
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
// ──────────────────────────────────────────────────────────
// dws markdown diff
// ──────────────────────────────────────────────────────────
// diffResult 是 diff 命令的输出结构。
type diffResult struct {
Mode string `json:"mode"`
Changed bool `json:"changed"`
AddedLines int `json:"added_lines"`
DeletedLines int `json:"deleted_lines"`
Hunks int `json:"hunks"`
Diff string `json:"diff"`
}
// diff 命令的限制(包级变量以便覆盖测试注入更小阈值)。
var (
maxDiffFileSize int64 = 10 * 1024 * 1024 // 单侧文件大小上限 10MB
diffDownloadTimeout = 10 * time.Minute // 下载远端内容超时(与项目其他下载命令一致)
diffComputeTimeout = 30 * time.Second // 本地 diff 计算超时
diffJSONMarshalIndent = json.MarshalIndent
runMarkdownUnifiedDiff = computeUnifiedDiff
)
// formatFileSize 返回人类可读的文件大小。
func formatFileSize(size int64) string {
if size >= 1024*1024 {
return fmt.Sprintf("%.1f MB", float64(size)/float64(1024*1024))
}
if size >= 1024 {
return fmt.Sprintf("%.1f KB", float64(size)/float64(1024))
}
return fmt.Sprintf("%d B", size)
}
// checkFileSize 校验文件大小是否超过限制。
func checkFileSize(path string) error {
info, err := os.Stat(path)
if err != nil {
return err
}
if info.Size() > maxDiffFileSize {
return fmt.Errorf("文件大小 %s 超过限制 %s,请使用更小的文件", formatFileSize(info.Size()), formatFileSize(maxDiffFileSize))
}
return nil
}
// downloadRemoteContent 下载远端文件内容并返回其文本。
// versionNum <= 0 时走 download_file(用 fileId),> 0 时走 download_file_version(用 nodeId + version)。
func downloadRemoteContent(ctx context.Context, fileID string, versionNum int) (string, error) {
var text string
var err error
if versionNum > 0 {
text, err = callMCPToolReturnTextOnServer(ctx, "drive", "download_file_version", map[string]any{
"nodeId": fileID,
"version": versionNum,
})
} else {
text, err = callMCPToolReturnTextOnServer(ctx, "drive", "download_file", map[string]any{
"fileId": fileID,
})
}
if err != nil {
return "", err
}
resourceURL, dlHeaders, err := parseDownloadInfo(text)
if err != nil {
return "", err
}
// 下载时即限制大小,避免完整下载超大文件后才拦截(约束网络流量与内存占用)
content, err := diffDownloadLimited(ctx, resourceURL, dlHeaders)
if err != nil {
return "", err
}
return string(content), nil
}
// diffDownloadLimited 下载远端内容,并在下载过程中强制执行 maxDiffFileSize 上限。
// 包级变量以便测试注入。
var diffDownloadLimited = defaultDiffDownloadLimited
// defaultDiffDownloadLimited 通过 HTTP GET 下载内容:先用 Content-Length 预检,
// 再用 io.LimitReader 将实际读取量限制为 maxDiffFileSize+1 字节,超限即报错,
// 从而约束网络流量与内存占用,而非在完整下载后才校验。
func defaultDiffDownloadLimited(ctx context.Context, url string, headers map[string]string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
for k, v := range headers {
req.Header.Set(k, v)
}
client := &http.Client{Timeout: diffDownloadTimeout}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
}
// Content-Length 预检:可在读取 body 前提前拦截超大文件
if resp.ContentLength > maxDiffFileSize {
return nil, fmt.Errorf("远端文件大小 %s 超过限制 %s,请使用更小的文件", formatFileSize(resp.ContentLength), formatFileSize(maxDiffFileSize))
}
// 实际读取限制为 maxDiffFileSize+1 字节,读满即判定超限(防止 Content-Length 缺失或造假)
data, err := io.ReadAll(io.LimitReader(resp.Body, maxDiffFileSize+1))
if err != nil {
return nil, err
}
if int64(len(data)) > maxDiffFileSize {
return nil, fmt.Errorf("远端文件大小超过限制 %s,请使用更小的文件", formatFileSize(maxDiffFileSize))
}
return data, nil
}
// computeUnifiedDiff 使用 Go stdlib patience diff 算法计算 unified diff,并统计变更行数。
func computeUnifiedDiff(left, right string, contextLines int) (string, int, int, int, bool) {
out := UnifiedDiff("left", []byte(left), "right", []byte(right), contextLines)
if len(out) == 0 {
return "", 0, 0, 0, false
}
text := string(out)
added, deleted, hunks := 0, 0, 0
// 只统计首个 @@ 之后的 hunk 区行:头部三行(diff/---/+++)不参与计数,
// hunk 区内每行必带单字符前缀,内容行以 --/++ 开头也不会被误判为文件头而漏计
inHunk := false
for _, line := range strings.Split(text, "\n") {
if strings.HasPrefix(line, "@@") {
hunks++
inHunk = true
continue
}
if !inHunk {
continue
}
if strings.HasPrefix(line, "+") {
added++
} else if strings.HasPrefix(line, "-") {
deleted++
}
}
changed := added > 0 || deleted > 0
return text, added, deleted, hunks, changed
}
// ensureMarkdownDiffType 校验 markdown diff 的目标文件类型。
// markdown 产品域面向 .md 文件,非 md 文件拦截并回引到对应产品命令。
// 类型探测复用 fetchFileInfo(显式路由 drive server 的 get_file_info),
// 探测失败或类型未知时不阻断,让后续 MCP 工具自行报错。
func ensureMarkdownDiffType(ctx context.Context, nodeID string) error {
info := fetchFileInfo(ctx, nodeID)
switch info.extension {
case "", "md", "markdown":
return nil
case "adoc":
return fmt.Errorf("该文件为钉钉在线文档 (adoc),不支持 markdown diff\n请使用 dws doc 对应命令(如 dws doc version list / dws doc export)")
case "axls":
return fmt.Errorf("该文件为钉钉在线表格 (axls),不支持 markdown diff\n请使用 dws sheet 对应命令")
case "amind", "adraw":
return fmt.Errorf("该文件为钉钉在线%s (%s),暂不支持历史版本管理\nmarkdown diff 与 dws drive list --versions / dws drive download --version 均不支持该类型", describeDingTalkDocType(info.extension), info.extension)
default:
return fmt.Errorf("该文件为 %s 文件,markdown diff 仅支持 .md 文件\n普通文件的历史版本请使用 dws drive list --versions / dws drive download --version / dws drive revert", info.extension)
}
}
// newMarkdownDiffCmd 创建 markdown diff 子命令。
func newMarkdownDiffCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "diff",
Short: "比较 Markdown 内容差异",
Long: `比较远端 Markdown 文件的两个版本,或远端版本与本地文件,生成 unified diff。
模式:
remote_vs_remote: --version V1 --version2 V2 (两个历史版本)
remote_vs_remote: --version V1 (历史版本 vs 最新)
remote_vs_local: --file ./local.md (最新 vs 本地)
remote_vs_local: --version V1 --file ./local.md (历史版本 vs 本地)
历史版本号通过 dws drive list --versions 获取。
--file 与 --version2 不能同时使用。
限制:
- 仅支持 .md 文件(在线文档/表格请使用 doc/sheet 命令)
- 单侧文件大小上限: 10 MB
- 下载超时: 10 分钟
- diff 计算超时: 30 秒`,
Example: ` # 比较两个历史版本
dws markdown diff --node <dentryUuid> --version 3 --version2 5
# 历史版本 vs 最新版本
dws markdown diff --node <dentryUuid> --version 3
# 最新版本 vs 本地文件
dws markdown diff --node <dentryUuid> --file ./draft.md
# 历史版本 vs 本地文件
dws markdown diff --node <dentryUuid> --version 3 --file ./draft.md`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
version1, _ := cmd.Flags().GetInt("version")
version2, _ := cmd.Flags().GetInt("version2")
localFile, _ := cmd.Flags().GetString("file")
contextLines, _ := cmd.Flags().GetInt("context")
// fail-fast 参数校验(置于 dry-run 之前):显式传了版本号但非正整数时立即报错,
// 避免静默降级为“最新版本”;--context 允许 0(无上下文),仅拒绝负值
if cmd.Flags().Changed("version") && version1 <= 0 {
return fmt.Errorf("--version 必须为正整数,当前值: %d", version1)
}
if cmd.Flags().Changed("version2") && version2 <= 0 {
return fmt.Errorf("--version2 必须为正整数,当前值: %d", version2)
}
if contextLines < 0 {
return fmt.Errorf("--context 不能为负数,当前值: %d", contextLines)
}
// 互斥校验:--file 与 --version2 不能同时使用
if localFile != "" && version2 > 0 {
return fmt.Errorf("--file 与 --version2 不能同时使用")
}
// 模式判定
var mode string
if localFile != "" {
mode = "remote_vs_local"
} else {
mode = "remote_vs_remote"
}
// remote_vs_remote 模式至少需要一个版本号,否则两侧均取最新版本,diff 必为空
if localFile == "" && version1 == 0 && version2 == 0 {
return fmt.Errorf("remote_vs_remote 模式至少需要指定 --version 或 --version2 之一")
}
if deps.Caller.DryRun() {
deps.Out.PrintKeyValue("操作", "Markdown 内容 Diff")
deps.Out.PrintKeyValue("模式", mode)
deps.Out.PrintKeyValue("节点ID", nodeID)
if version1 > 0 {
deps.Out.PrintKeyValue("左侧版本", fmt.Sprintf("%d", version1))
} else {
deps.Out.PrintKeyValue("左侧版本", "最新")
}
if localFile != "" {
deps.Out.PrintKeyValue("右侧", localFile)
} else if version2 > 0 {
deps.Out.PrintKeyValue("右侧版本", fmt.Sprintf("%d", version2))
} else {
deps.Out.PrintKeyValue("右侧版本", "最新")
}
return nil
}
// 下载超时 context(与项目其他下载命令一致:10 分钟)
ctx, cancel := context.WithTimeout(context.Background(), diffDownloadTimeout)
defer cancel()
// 类型守卫置于 dry-run 之后,确保 dry-run 不产生任何服务端调用
if err := ensureMarkdownDiffType(ctx, nodeID); err != nil {
return err
}
// remote_vs_local 模式:先校验本地文件大小,避免下载后才发现过大
if localFile != "" {
if err := checkFileSize(localFile); err != nil {
return err
}
}
// 输出格式读全局 --format(默认 json),json 时输出结构化结果,其余输出文本摘要
isJSON := deps.Caller.Format() == "json"
// 进度属带外诊断信息,统一写入 stderr,保证 stdout 在两种模式下都是纯净输出
progress := func(msg string) { fmt.Fprintln(os.Stderr, msg) }
// 下载左侧内容
progress("[1/3] 获取左侧内容...")
leftContent, err := downloadRemoteContent(ctx, nodeID, version1)
if err != nil {
return err
}
// 获取右侧内容
var rightContent string
if localFile != "" {
// remote_vs_local: 读取本地文件(大小已校验)
progress("[2/3] 读取本地文件...")
data, err := os.ReadFile(localFile)
if err != nil {
return fmt.Errorf("读取本地文件失败: %w", err)
}
rightContent = string(data)
} else {
// remote_vs_remote: 下载右侧远端内容
progress("[2/3] 获取右侧内容...")
rightContent, err = downloadRemoteContent(ctx, nodeID, version2)
if err != nil {
return err
}
}
// 计算 diff(带超时保护)
progress("[3/3] 计算差异...")
type diffOutput struct {
text string
added int
deleted int
hunks int
changed bool
}
resultCh := make(chan diffOutput, 1)
// Capture seams before the goroutine so test restorers cannot race
// against a still-running compute after the timeout path returns.
computeDiff := runMarkdownUnifiedDiff
marshalIndent := diffJSONMarshalIndent
go func() {
text, added, deleted, hunks, changed := computeDiff(leftContent, rightContent, contextLines)
resultCh <- diffOutput{text, added, deleted, hunks, changed}
}()
select {
case res := <-resultCh:
diffText, added, deleted, hunks, changed := res.text, res.added, res.deleted, res.hunks, res.changed
result := diffResult{
Mode: mode,
Changed: changed,
AddedLines: added,
DeletedLines: deleted,
Hunks: hunks,
Diff: diffText,
}
// 输出
if isJSON {
data, err := marshalIndent(result, "", " ")
if err != nil {
return fmt.Errorf("JSON 序列化失败: %w", err)
}
deps.Out.PrintRaw(string(data))
} else {
deps.Out.PrintKeyValue("模式", result.Mode)
if result.Changed {
deps.Out.PrintKeyValue("是否有变更", "是")
} else {
deps.Out.PrintKeyValue("是否有变更", "否")
}
deps.Out.PrintKeyValue("新增行数", fmt.Sprintf("%d", result.AddedLines))
deps.Out.PrintKeyValue("删除行数", fmt.Sprintf("%d", result.DeletedLines))
deps.Out.PrintKeyValue("差异块数", fmt.Sprintf("%d", result.Hunks))
if result.Changed {
deps.Out.PrintRaw("")
deps.Out.PrintRaw(result.Diff)
}
}
case <-time.After(diffComputeTimeout):
return fmt.Errorf("diff 计算超时(%s),文件可能过大,请尝试减小 --context 或使用更小的文件", diffComputeTimeout)
}
return nil
},
}
cmd.Flags().String("node", "", "文件 ID (dentryUuid) 或 URL (必填)")
cmd.Flags().Int("version", 0, "左侧历史版本号 (可选,不传=最新版本)")
cmd.Flags().Int("version2", 0, "右侧历史版本号 (可选,不传=最新版本;不能与 --file 同时使用)")
cmd.Flags().String("file", "", "本地 .md 文件路径 (可选,指定后进入 remote_vs_local 模式)")
cmd.Flags().Int("context", 3, "diff 上下文行数 (默认 3)")
// --node 隐藏别名(与 version/fetch 子命令一致)
cmd.Flags().String("url", "", "")
cmd.Flags().String("id", "", "")
cmd.Flags().String("node-id", "", "")
cmd.Flags().String("doc-id", "", "")
cmd.Flags().String("file-id", "", "")
_ = cmd.Flags().MarkHidden("url")
_ = cmd.Flags().MarkHidden("id")
_ = cmd.Flags().MarkHidden("node-id")
_ = cmd.Flags().MarkHidden("doc-id")
_ = cmd.Flags().MarkHidden("file-id")
RegisterCrossProductAliases(cmd)
cli.AnnotateRuntimeRequiredFlags(cmd, "node")
DeclareLeafMetadata(cmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "markdown",
Name: "diff",
CanonicalPath: "markdown.diff",
CLIPath: "markdown diff",
PrimaryCLIPath: "markdown diff",
},
Description: "比较远端 Markdown 文件的两个版本,或远端版本与本地文件,生成 unified diff",
DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false},
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Local diff workflow: download remote version(s) and/or read a local file, then compute unified diff client-side.",
},
Selection: contract.SelectionSpec{
AgentSummary: "比较 Markdown 文件版本或本地草稿差异",
UseWhen: []string{"需要对比远端 .md 历史版本,或远端最新/历史版本与本地草稿的差异时"},
AvoidWhen: []string{"在线文档/表格差异请用 doc/sheet;普通二进制文件版本请用 drive list --versions / drive download --version"},
Examples: []string{"dws markdown diff --node <nodeId> --version 3 --version2 5"},
},
Parameters: []contract.ParamDecl{
{Name: "node", Property: "nodeId", Required: boolPtr(true)},
{Name: "version", Property: "version", InterfaceType: "integer"},
{Name: "version2", Property: "version2", InterfaceType: "integer"},
{Name: "file", Property: "file"},
{Name: "context", Property: "context", InterfaceType: "integer"},
},
},
})
return cmd
}
// fetchFileInfo 通过 get_file_info 获取扩展名(markdown diff 类型守卫用)。
// 探测失败返回零值,由调用方决定是否阻断。
type markdownFileInfo struct {
name string
extension string
}
func fetchFileInfo(ctx context.Context, nodeID string) (info markdownFileInfo) {
text, err := callMCPToolReturnTextOnServer(ctx, "drive", "get_file_info", map[string]any{"fileId": nodeID})
if err != nil {
return
}
var resp map[string]any
if err := json.Unmarshal([]byte(text), &resp); err != nil {
return
}
data := resp
if result, ok := resp["result"].(map[string]any); ok {
data = result
}
if name, ok := data["name"].(string); ok {
info.name = name
}
if ext, ok := data["extension"].(string); ok {
info.extension = strings.ToLower(strings.TrimPrefix(ext, "."))
}
return
}
func describeDingTalkDocType(ext string) string {
switch strings.ToLower(ext) {
case "adoc":
return "文档"
case "axls":
return "表格"
case "amind":
return "脑图"
case "adraw":
return "画图"
default:
return "文件"
}
}
+10 -260
View File
@@ -3,7 +3,6 @@ package helpers
import (
"fmt"
"strconv"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/spf13/cobra"
@@ -19,7 +18,7 @@ func newMinutesCommand() *cobra.Command {
contract.RegisterProductDecl(contract.ProductDecl{
ID: "minutes",
Selection: contract.ProductSelectionDecl{
AgentSummary: "查询和维护钉钉听记的转写、摘要、待办、权限、录音、标签、说话人总结、语音备忘及文件上传会话。",
AgentSummary: "查询和维护钉钉听记的转写、摘要、待办、权限、录音、标签、说话人总结及文件上传会话。",
UseWhen: []string{
"用户要查找、读取、编辑或管理钉钉听记及其录音、转写、摘要和衍生内容。",
},
@@ -1283,10 +1282,7 @@ func newMinutesCommand() *cobra.Command {
Selection: contract.SelectionSpec{
AgentSummary: "添加听记个人热词,用于优化语音识别中专有名词、人名等的识别准确率。",
UseWhen: []string{"需要添加听记个人热词以优化专有名词/人名识别时(单词不超过约10汉字)"},
AvoidWhen: []string{
"要查看已有热词时改用 dws minutes hot-word list",
"要删除热词时改用 dws minutes hot-word delete",
},
AvoidWhen: []string{"要查看已有热词时改用 dws minutes hot-word list"},
Examples: []string{
"dws minutes hot-word add --words \"钉钉\"",
"dws minutes hot-word add --words \"OKR,钉钉,Copilot\"",
@@ -1332,70 +1328,13 @@ func newMinutesCommand() *cobra.Command {
Selection: contract.SelectionSpec{
AgentSummary: "查询当前用户配置的所有听记热词列表。",
UseWhen: []string{"需要查看当前用户已配置的听记个人热词列表时"},
AvoidWhen: []string{
"要添加热词时改用 hot-word add",
"要删除热词时改用 hot-word delete",
},
Examples: []string{"dws minutes hot-word list"},
AvoidWhen: []string{"要添加热词时改用 hot-word add"},
Examples: []string{"dws minutes hot-word list"},
},
},
})
hotWordDeleteCmd := &cobra.Command{
Use: "delete",
Short: "批量删除个人热词",
Long: `批量删除听记个人热词。
支持一次删除多个热词(逗号分隔)。删除后对应热词不再参与后续语音识别优化。`,
Example: ` dws minutes hot-word delete --words "钉钉"
dws minutes hot-word delete --words "OKR,钉钉,Copilot"`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "words"); err != nil {
return err
}
return callMCPTool("delete_personal_hotword", map[string]any{
"hotWordList": parseCSVValues(mustGetFlag(cmd, "words")),
})
},
}
DeclareLeafMetadata(hotWordDeleteCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "minutes",
Name: "delete_personal_hotword",
CanonicalPath: "minutes.delete_personal_hotword",
CLIPath: "minutes hot-word delete",
PrimaryCLIPath: "minutes hot-word delete",
},
Description: "批量删除听记个人热词。删除后对应热词不再参与后续语音识别优化。",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "minutes", RPCName: "delete_personal_hotword"},
},
Selection: contract.SelectionSpec{
AgentSummary: "批量删除听记个人热词,清理误加或过时热词。",
UseWhen: []string{"用户要删除/移除已配置的听记个人热词时"},
AvoidWhen: []string{
"要添加热词时改用 hot-word add",
"不确定现有热词时先用 hot-word list",
},
Examples: []string{
"dws minutes hot-word delete --words \"钉钉\"",
"dws minutes hot-word delete --words \"OKR,钉钉,Copilot\"",
},
},
Parameters: []contract.ParamDecl{
{Name: "words", Property: "hotWordList"},
},
},
})
hotWordDeleteCmd.Flags().String("words", "", "要删除的热词,多个用逗号分隔 (必填)")
hotWordCmd.AddCommand(hotWordAddCmd, hotWordListCmd, hotWordDeleteCmd)
hotWordCmd.AddCommand(hotWordAddCmd, hotWordListCmd)
// ── replace-text 命令 ───────────────────────────────────────
replaceTextCmd := &cobra.Command{
@@ -1679,8 +1618,8 @@ func newMinutesCommand() *cobra.Command {
uploadCmd.AddCommand(uploadCreateCmd, uploadCompleteCmd, uploadCancelCmd)
// ── permission 子组 ─────────────────────────────────────────
// 听记成员权限管理:批量添加/移除成员及其权限、为当前用户申请权限。
// 对应 MCP 工具 add_member_permission / remove_member_permission / apply_minutes_permission。
// 听记成员权限管理:批量添加/移除成员及其权限。
// 对应 MCP 工具 add_member_permission / remove_member_permission。
permissionCmd := &cobra.Command{Use: "permission", Short: "听记成员权限管理", RunE: groupRunE}
// permission add — 对应 MCP 工具 add_member_permission
@@ -1770,7 +1709,6 @@ func newMinutesCommand() *cobra.Command {
UseWhen: []string{"已知听记 uuid,需要批量给听记增加成员并设置权限(policy 0管理员/1所有者/2可编辑/3可查看下载/4仅查看)时"},
AvoidWhen: []string{
"要移除成员权限时改用 dws minutes permission remove",
"当前用户自己申请访问权限时改用 dws minutes permission apply",
"成员、权限策略或听记 id 未确认时不要添加",
},
Examples: []string{
@@ -1846,7 +1784,6 @@ func newMinutesCommand() *cobra.Command {
UseWhen: []string{"用户明确要求批量移除听记成员权限,使其失去访问时"},
AvoidWhen: []string{
"要添加权限时改用 permission add",
"当前用户自己申请访问权限时改用 permission apply",
"成员或听记 id 未确认时不要移除",
},
Examples: []string{
@@ -1866,85 +1803,7 @@ func newMinutesCommand() *cobra.Command {
_ = permissionRemoveCmd.Flags().MarkHidden("task-uuids")
permissionRemoveCmd.Flags().String("member-uids", "", "成员钉钉 UID 列表,逗号分隔 (必填)")
// permission apply — 对应 MCP 工具 apply_minutes_permission
permissionApplyCmd := &cobra.Command{
Use: "apply",
Short: "为当前用户申请听记权限",
Long: `为当前登录用户申请指定听记的权限。
适用于用户无权限访问某听记(如打开分享链接提示无权限)时,主动向听记所有者发起权限申请。
权限类型 (--policy):
2 = 可编辑
3 = 可查看/下载
4 = 仅查看`,
Example: ` dws minutes permission apply --id <taskUuid> --policy 4
dws minutes permission apply --id <taskUuid> --policy 2`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlagWithAliases(cmd, "id", "url", "task-uuid", "uuid"); err != nil {
return err
}
if err := validateRequiredFlags(cmd, "policy"); err != nil {
return err
}
policyID, err := strconv.ParseInt(mustGetFlag(cmd, "policy"), 10, 64)
if err != nil || policyID < 2 || policyID > 4 {
return fmt.Errorf("flag --policy must be an integer between 2 and 4 (2=可编辑, 3=可查看/下载, 4=仅查看)")
}
return callMCPTool("apply_minutes_permission", map[string]any{
"taskUuid": flagOrFallback(cmd, "id", "url", "task-uuid", "uuid"),
"policyId": float64(policyID),
})
},
}
DeclareLeafMetadata(permissionApplyCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "minutes",
Name: "apply_minutes_permission",
CanonicalPath: "minutes.apply_minutes_permission",
CLIPath: "minutes permission apply",
PrimaryCLIPath: "minutes permission apply",
},
Description: "为当前登录用户申请指定听记的权限。",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "minutes", RPCName: "apply_minutes_permission"},
},
Selection: contract.SelectionSpec{
AgentSummary: "为当前登录用户申请指定听记的访问权限(可编辑/可查看下载/仅查看)。",
UseWhen: []string{"当前用户对某听记无权限,需要向所有者申请访问(policy 2/3/4)时"},
AvoidWhen: []string{
"所有者批量给他人加权限时改用 permission add",
"要移除他人权限时改用 permission remove",
},
Examples: []string{
"dws minutes permission apply --id <taskUuid> --policy 4",
"dws minutes permission apply --id <taskUuid> --policy 2",
},
},
Parameters: []contract.ParamDecl{
{Name: "id", Property: "taskUuid"},
{Name: "policy", Property: "policyId"},
},
},
})
permissionApplyCmd.Flags().String("id", "", "听记 taskUuid (必填)")
permissionApplyCmd.Flags().String("url", "", "--id 的别名")
_ = permissionApplyCmd.Flags().MarkHidden("url")
permissionApplyCmd.Flags().String("task-uuid", "", "--id 的别名 (兼容 OpenAPI 字段名)")
_ = permissionApplyCmd.Flags().MarkHidden("task-uuid")
permissionApplyCmd.Flags().String("uuid", "", "--id 的别名")
_ = permissionApplyCmd.Flags().MarkHidden("uuid")
permissionApplyCmd.Flags().String("policy", "", "权限类型: 2=可编辑, 3=可查看/下载, 4=仅查看 (必填)")
permissionCmd.AddCommand(permissionAddCmd, permissionRemoveCmd, permissionApplyCmd)
permissionCmd.AddCommand(permissionAddCmd, permissionRemoveCmd)
// ── tag 子组 ────────────────────────────────────────────────
// 听记标签/分组管理:查询用户标签列表、按标签查询听记。
@@ -2069,122 +1928,13 @@ func newMinutesCommand() *cobra.Command {
tagCmd.AddCommand(tagListCmd, tagQueryCmd)
// ── audio-memo 子组 ────────────────────────────
// 语音备忘查询:对应 MCP 工具 list_audio_memos。
// 用户身份由网关按登录态注入 uid,agent/CLI 无需传入。
// 返回值 items[].audioUrl 为带签名的音频 URL(含 &),因此使用
// callMCPToolUnescaped 输出,避免 & 被转义为 \u0026(与 upload 一致)。
audioMemoCmd := &cobra.Command{Use: "audio-memo", Short: "语音备忘查询", RunE: groupRunE}
audioMemoListCmd := &cobra.Command{
Use: "list",
Short: "查询语音备忘列表",
Long: `查询当前用户的语音备忘列表,支持分页和时间范围筛选。
分页:首页 --cursor 留空(或 0),后续把上一页返回的 nextCursor 回填到 --cursor。
时间范围:--start/--end 为 ISO-8601(可选),不传默认查询近一年。`,
Example: ` dws minutes audio-memo list
dws minutes audio-memo list --max 500
dws minutes audio-memo list --start "2026-01-01T00:00:00+08:00" --end "2026-07-21T23:59:59+08:00"
dws minutes audio-memo list --cursor 1740000000000`,
RunE: func(cmd *cobra.Command, args []string) error {
toolArgs := map[string]any{}
max, _ := cmd.Flags().GetFloat64("max")
if max <= 0 || max > 1000 {
return fmt.Errorf("flag --max must be between 1 and 1000")
}
toolArgs["pageSize"] = max
if cmd.Flags().Changed("cursor") {
cursor, _ := cmd.Flags().GetInt64("cursor")
if cursor < 0 {
return fmt.Errorf("flag --cursor must be >= 0")
}
toolArgs["cursor"] = float64(cursor)
}
startStr, _ := cmd.Flags().GetString("start")
endStr, _ := cmd.Flags().GetString("end")
// China Standard Time has no DST; FixedZone avoids zoneinfo nil-fallback branches.
loc := time.FixedZone("Asia/Shanghai", 8*3600)
var startMs, endMs int64
if startStr != "" {
var err error
startMs, err = parseISOTimeToMillis("start", startStr)
if err != nil {
return err
}
toolArgs["startTime"] = time.UnixMilli(startMs).In(loc).Format(time.RFC3339)
}
if endStr != "" {
var err error
endMs, err = parseISOTimeToMillis("end", endStr)
if err != nil {
return err
}
toolArgs["endTime"] = time.UnixMilli(endMs).In(loc).Format(time.RFC3339)
}
if startStr != "" && endStr != "" {
if err := validateTimeRange(startMs, endMs); err != nil {
return err
}
}
return callMCPToolUnescaped("list_audio_memos", toolArgs)
},
}
DeclareLeafMetadata(audioMemoListCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "minutes",
Name: "list_audio_memos",
CanonicalPath: "minutes.list_audio_memos",
CLIPath: "minutes audio-memo list",
PrimaryCLIPath: "minutes audio-memo list",
},
Description: "查询当前用户的语音备忘列表,支持分页和时间范围筛选。",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "minutes", RPCName: "list_audio_memos"},
},
Selection: contract.SelectionSpec{
AgentSummary: "查询当前用户的语音备忘列表(独立于听记列表与 get audio)。",
UseWhen: []string{"用户要查看语音备忘/录音备忘列表时(可带时间范围或翻页)"},
AvoidWhen: []string{
"要查听记列表改用 minutes list",
"只要某篇听记的音频地址改用 minutes get audio",
},
Examples: []string{
"dws minutes audio-memo list",
"dws minutes audio-memo list --start \"2026-01-01T00:00:00+08:00\" --end \"2026-07-21T23:59:59+08:00\"",
},
},
Parameters: []contract.ParamDecl{
{Name: "max", Property: "pageSize"},
{Name: "cursor", Property: "cursor"},
{Name: "start", Property: "startTime"},
{Name: "end", Property: "endTime"},
},
},
})
audioMemoListCmd.Flags().Float64("max", 200, "每页数据条数 (默认 200,上限 1000)")
audioMemoListCmd.Flags().Int64("cursor", 0, "翻页游标,回填上一页返回的 nextCursor (首页留空)")
audioMemoListCmd.Flags().String("start", "", "开始时间 ISO-8601 (可选,默认近一年)")
audioMemoListCmd.Flags().String("end", "", "结束时间 ISO-8601 (可选)")
audioMemoCmd.AddCommand(audioMemoListCmd)
minutesCmd := &cobra.Command{
Use: "minutes",
Short: "AI 听记 / 会议纪要",
Long: `管理钉钉AI听记:查询列表、获取详情、摘要、转写、待办、关键字、音频地址、思维导图、发言人管理、文件上传、成员权限管理、语音备忘查询,以及修改标题和纪要内容。`,
Long: `管理钉钉AI听记:查询列表、获取详情、摘要、转写、待办、关键字、音频地址、思维导图、发言人管理、文件上传、成员权限管理,以及修改标题和纪要内容。`,
RunE: groupRunE,
}
minutesCmd.AddCommand(minutesListCmd, minutesGetCmd, minutesUpdateCmd, minutesRecordCmd, mindGraphCmd, speakerCmd, hotWordCmd, replaceTextCmd, audioMemoCmd, uploadCmd, permissionCmd, tagCmd)
minutesCmd.AddCommand(minutesListCmd, minutesGetCmd, minutesUpdateCmd, minutesRecordCmd, mindGraphCmd, speakerCmd, hotWordCmd, replaceTextCmd, uploadCmd, permissionCmd, tagCmd)
return minutesCmd
}
@@ -1,708 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/spf13/cobra"
)
func executeMarkdownDiff(t *testing.T, args ...string) error {
t.Helper()
testseam.Protect(t, &os.Args)
os.Args = append([]string{"dws", "markdown"}, args...)
root := newMarkdownCommand()
root.SilenceErrors = true
root.SilenceUsage = true
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(args)
return root.Execute()
}
func TestCrossPlatformCoverageMarkdownDiffCommand(t *testing.T) {
t.Run("oversized local file", func(t *testing.T) {
testseam.Swap(t, &maxDiffFileSize, int64(8))
big := filepath.Join(t.TempDir(), "big.md")
if err := os.WriteFile(big, []byte("0123456789"), 0o644); err != nil {
t.Fatal(err)
}
if err := checkFileSize(big); err == nil {
t.Fatal("expected oversized local file")
}
})
t.Run("download helpers and ensure type", func(t *testing.T) {
testseam.Protect(t, &diffDownloadLimited)
diffDownloadLimited = func(context.Context, string, map[string]string) ([]byte, error) {
return []byte("left\n"), nil
}
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"resourceUrl":"https://example.test/a","headers":{"X":"1"}}`},
}})
if _, err := downloadRemoteContent(context.Background(), "fid", 0); err != nil {
t.Fatalf("download latest: %v", err)
}
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"resourceUrl":"https://example.test/a"}`},
}})
if _, err := downloadRemoteContent(context.Background(), "nid", 3); err != nil {
t.Fatalf("download version: %v", err)
}
diffDownloadLimited = func(context.Context, string, map[string]string) ([]byte, error) {
return nil, errors.New("dl boom")
}
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"resourceUrl":"https://example.test/a"}`},
}})
if _, err := downloadRemoteContent(context.Background(), "fid", 0); err == nil {
t.Fatal("expected download limited error")
}
for _, tc := range []struct {
ext string
ok bool
}{
{"md", true}, {"markdown", true}, {"", true},
{"adoc", false}, {"axls", false}, {"amind", false}, {"adraw", false}, {"pdf", false},
} {
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
{text: fmt.Sprintf(`{"result":{"name":"x","extension":%q}}`, tc.ext)},
}})
err := ensureMarkdownDiffType(context.Background(), "n1")
if tc.ok && err != nil {
t.Fatalf("ext %q: %v", tc.ext, err)
}
if !tc.ok && err == nil {
t.Fatalf("ext %q: expected type error", tc.ext)
}
}
for _, ext := range []string{"adoc", "axls", "amind", "adraw", "other"} {
if describeDingTalkDocType(ext) == "" {
t.Fatalf("describe %q empty", ext)
}
}
})
t.Run("defaultDiffDownloadLimited http paths", func(t *testing.T) {
okSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Length", "4")
_, _ = w.Write([]byte("data"))
}))
t.Cleanup(okSrv.Close)
got, err := defaultDiffDownloadLimited(context.Background(), okSrv.URL, map[string]string{"X-Test": "1"})
if err != nil || string(got) != "data" {
t.Fatalf("ok download: %v %q", err, got)
}
badStatus := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadGateway)
_, _ = w.Write([]byte("nope"))
}))
t.Cleanup(badStatus.Close)
if _, err := defaultDiffDownloadLimited(context.Background(), badStatus.URL, nil); err == nil {
t.Fatal("expected non-200")
}
testseam.Swap(t, &maxDiffFileSize, int64(3))
tooBigHeader := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Length", "100")
_, _ = w.Write([]byte("xxxx"))
}))
t.Cleanup(tooBigHeader.Close)
if _, err := defaultDiffDownloadLimited(context.Background(), tooBigHeader.URL, nil); err == nil {
t.Fatal("expected content-length guard")
}
chunkedBig := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
// No Content-Length: force LimitReader path to observe oversize body.
hj, ok := w.(http.Hijacker)
if !ok {
http.Error(w, "no hijack", 500)
return
}
conn, bufrw, err := hj.Hijack()
if err != nil {
return
}
defer conn.Close()
payload := strings.Repeat("x", 16)
_, _ = bufrw.WriteString("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n")
_, _ = bufrw.WriteString(fmt.Sprintf("%x\r\n%s\r\n0\r\n\r\n", len(payload), payload))
_ = bufrw.Flush()
}))
t.Cleanup(chunkedBig.Close)
if _, err := defaultDiffDownloadLimited(context.Background(), chunkedBig.URL, nil); err == nil {
t.Fatal("expected body size guard")
}
readFail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
hj, ok := w.(http.Hijacker)
if !ok {
return
}
conn, bufrw, err := hj.Hijack()
if err != nil {
return
}
_, _ = bufrw.WriteString("HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\n")
_ = bufrw.Flush()
_ = conn.Close() // truncate body → ReadAll error
}))
t.Cleanup(readFail.Close)
maxDiffFileSize = 1000
if _, err := defaultDiffDownloadLimited(context.Background(), readFail.URL, nil); err == nil {
t.Fatal("expected read error")
}
if _, err := defaultDiffDownloadLimited(context.Background(), "http://%\x00", nil); err == nil {
t.Fatal("expected bad url")
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := defaultDiffDownloadLimited(ctx, okSrv.URL, nil); err == nil {
t.Fatal("expected canceled ctx")
}
})
t.Run("computeUnifiedDiff deletes", func(t *testing.T) {
text, add, del, hunks, changed := computeUnifiedDiff("a\nb\n", "a\n", 2)
if !changed || del < 1 || hunks < 1 || add != 0 || text == "" {
t.Fatalf("delete-only diff: changed=%v add=%d del=%d hunks=%d", changed, add, del, hunks)
}
})
t.Run("validation and dry-run modes", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true})
if err := executeMarkdownDiff(t, "diff"); err == nil {
t.Fatal("expected missing node")
}
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "0"); err == nil {
t.Fatal("expected version>0")
}
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version2", "0"); err == nil {
t.Fatal("expected version2>0")
}
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--context", "-1"); err == nil {
t.Fatal("expected context>=0")
}
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--file", "a.md", "--version2", "2"); err == nil {
t.Fatal("expected file/version2 mutex")
}
if err := executeMarkdownDiff(t, "diff", "--node", "n1"); err == nil {
t.Fatal("expected remote needs version")
}
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "2"); err != nil {
t.Fatalf("dry-run remote: %v", err)
}
if err := executeMarkdownDiff(t, "diff", "--url", "n1", "--file", "local.md"); err != nil {
t.Fatalf("dry-run local latest: %v", err)
}
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "2", "--file", "local.md"); err != nil {
t.Fatalf("dry-run local version: %v", err)
}
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version2", "3"); err != nil {
t.Fatalf("dry-run version2-only: %v", err)
}
})
t.Run("remote vs local and remote vs remote execute", func(t *testing.T) {
testseam.Protect(t, &diffDownloadLimited)
diffDownloadLimited = func(context.Context, string, map[string]string) ([]byte, error) {
return []byte("alpha\n"), nil
}
local := filepath.Join(t.TempDir(), "right.md")
if err := os.WriteFile(local, []byte("beta\n"), 0o644); err != nil {
t.Fatal(err)
}
installScriptedCaller(t, &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: `{"extension":"md"}`},
{text: `{"resourceUrl":"https://example.test/l"}`},
}})
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--file", local); err != nil {
t.Fatalf("local json: %v", err)
}
installScriptedCaller(t, &scriptedToolCaller{format: "text", steps: []scriptedToolStep{
{text: `{"extension":"md"}`},
{text: `{"resourceUrl":"https://example.test/l"}`},
{text: `{"resourceUrl":"https://example.test/r"}`},
}})
diffDownloadLimited = func(_ context.Context, url string, _ map[string]string) ([]byte, error) {
if strings.Contains(url, "/r") {
return []byte("alpha\n"), nil
}
return []byte("gamma\n"), nil
}
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "1", "--version2", "2"); err != nil {
t.Fatalf("remote text changed: %v", err)
}
installScriptedCaller(t, &scriptedToolCaller{format: "text", steps: []scriptedToolStep{
{text: `{"extension":"md"}`},
{text: `{"resourceUrl":"https://example.test/l"}`},
{text: `{"resourceUrl":"https://example.test/r"}`},
}})
diffDownloadLimited = func(context.Context, string, map[string]string) ([]byte, error) {
return []byte("same\n"), nil
}
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "1", "--version2", "2"); err != nil {
t.Fatalf("remote text unchanged: %v", err)
}
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"extension":"adoc"}`},
}})
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "1"); err == nil {
t.Fatal("expected type guard")
}
testseam.Protect(t, &maxDiffFileSize)
maxDiffFileSize = 2
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"extension":"md"}`},
}})
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--file", local); err == nil {
t.Fatal("expected local size fail")
}
maxDiffFileSize = 10 * 1024 * 1024
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"extension":"md"}`},
{text: `{"resourceUrl":"https://example.test/l"}`},
}})
diffDownloadLimited = func(context.Context, string, map[string]string) ([]byte, error) {
return []byte("x\n"), nil
}
missingLocal := filepath.Join(t.TempDir(), "missing.md")
// pass size check by writing then removing after checkFileSize... actually RunE checks size first then reads.
// Create file for size check, then make ReadFile fail via directory path.
dirAsFile := t.TempDir()
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--file", dirAsFile); err == nil {
t.Fatal("expected read local dir failure")
}
_ = missingLocal
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"extension":"md"}`},
{text: `{"resourceUrl":"https://example.test/l"}`},
{err: errors.New("right fail")},
}})
diffDownloadLimited = func(context.Context, string, map[string]string) ([]byte, error) {
return []byte("x\n"), nil
}
// second download uses version tool — make parse fail on second call via empty resource
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"extension":"md"}`},
{text: `{"resourceUrl":"https://example.test/l"}`},
{text: `{}`},
}})
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "1", "--version2", "2"); err == nil {
t.Fatal("expected right download parse fail")
}
})
t.Run("json marshal and compute timeout", func(t *testing.T) {
testseam.Protect(t, &diffDownloadLimited)
testseam.Protect(t, &diffJSONMarshalIndent)
testseam.Protect(t, &runMarkdownUnifiedDiff)
testseam.Protect(t, &diffComputeTimeout)
diffDownloadLimited = func(context.Context, string, map[string]string) ([]byte, error) {
return []byte("a\n"), nil
}
installScriptedCaller(t, &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: `{"extension":"md"}`},
{text: `{"resourceUrl":"https://example.test/l"}`},
{text: `{"resourceUrl":"https://example.test/r"}`},
}})
diffJSONMarshalIndent = func(any, string, string) ([]byte, error) {
return nil, errors.New("marshal boom")
}
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "1", "--version2", "2"); err == nil || !strings.Contains(err.Error(), "JSON") {
t.Fatalf("expected marshal err, got %v", err)
}
diffJSONMarshalIndent = json.MarshalIndent
runMarkdownUnifiedDiff = func(string, string, int) (string, int, int, int, bool) {
time.Sleep(50 * time.Millisecond)
return "", 0, 0, 0, false
}
diffComputeTimeout = time.Millisecond
installScriptedCaller(t, &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: `{"extension":"md"}`},
{text: `{"resourceUrl":"https://example.test/l"}`},
{text: `{"resourceUrl":"https://example.test/r"}`},
}})
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "1", "--version2", "2"); err == nil || !strings.Contains(err.Error(), "超时") {
t.Fatalf("expected timeout, got %v", err)
}
})
t.Run("fetchFileInfo extension field", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"extension":".MD","name":"x.md"}`},
}})
info := fetchFileInfo(context.Background(), "n")
if info.extension != "md" || info.name != "x.md" {
t.Fatalf("info=%+v", info)
}
})
}
func TestCrossPlatformCoverageMailExportShareAndAtomicWrite(t *testing.T) {
t.Run("message export dry-run and execute", func(t *testing.T) {
cwd := t.TempDir()
t.Chdir(cwd)
installScriptedCaller(t, &scriptedToolCaller{dry: true})
if err := executePR868Command(t, newMailCommand(),
"message", "export", "--email", "u@c.com", "--id", "m1", "--filename", "named"); err != nil {
t.Fatalf("export dry-run: %v", err)
}
installScriptedCaller(t, &scriptedToolCaller{dry: true})
if err := executePR868Command(t, newMailCommand(),
"message", "export", "--email", "u@c.com", "--id", "m1"); err != nil {
t.Fatalf("export dry-run default name: %v", err)
}
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"result":{"message":{"subject":"Hello/World"}}}`},
{text: `{"result":{"emlContent":"From: a\r\n\r\nbody"}}`},
}}
installScriptedCaller(t, caller)
if err := executePR868Command(t, newMailCommand(),
"message", "export", "--email", "u@c.com", "--id", "m1"); err != nil {
t.Fatalf("export: %v", err)
}
if _, err := os.Stat("Hello_World.eml"); err != nil {
t.Fatalf("missing eml: %v", err)
}
// exist without overwrite
caller2 := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"emlContent":"x"}`},
}}
installScriptedCaller(t, caller2)
if err := executePR868Command(t, newMailCommand(),
"message", "export", "--email", "u@c.com", "--id", "m1", "--filename", "Hello_World"); err == nil {
t.Fatal("expected exist error")
}
caller3 := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"emlContent":"y"}`},
}}
installScriptedCaller(t, caller3)
if err := executePR868Command(t, newMailCommand(),
"message", "export", "--email", "u@c.com", "--id", "m1", "--filename", "Hello_World", "--overwrite"); err != nil {
t.Fatalf("overwrite: %v", err)
}
// subject fallback to message id when missing
caller4 := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"result":{}}`},
{text: `{"emlContent":"z"}`},
}}
installScriptedCaller(t, caller4)
if err := executePR868Command(t, newMailCommand(),
"message", "export", "--email", "u@c.com", "--id", "msg-fallback"); err != nil {
t.Fatalf("fallback name: %v", err)
}
})
t.Run("share-to-chat paths", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true})
if err := executePR868Command(t, newMailCommand(),
"message", "share-to-chat", "--email", "u@c.com", "--id", "m1", "--users", "u1"); err != nil {
t.Fatalf("share dry-run: %v", err)
}
installScriptedCaller(t, &scriptedToolCaller{dry: true})
if err := executePR868Command(t, newMailCommand(),
"message", "share-to-chat", "--email", "u@c.com", "--id", "m1"); err != nil {
t.Fatalf("share dry-run no users: %v", err)
}
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{`}}})
if err := executePR868Command(t, newMailCommand(),
"message", "share-to-chat", "--email", "u@c.com", "--id", "m1", "--users", "u1", "--yes"); err == nil {
t.Fatal("expected parse error")
}
// Piped stdin "yes" must not bypass the explicit --yes gate.
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"result":{"sign":"sig","riskMessage":"careful"}}`},
}})
if err := executeMailShare(t, strings.NewReader("yes\n"),
"message", "share-to-chat", "--email", "u@c.com", "--id", "m1", "--users", "u1"); err == nil {
t.Fatal("expected confirmation_required without --yes")
}
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"sign":"sig"}`},
}})
if err := executeMailShare(t, strings.NewReader("yes\n"),
"message", "share-to-chat", "--email", "u@c.com", "--id", "m1", "--users", "u1"); err == nil {
t.Fatal("expected confirmation_required without --yes")
}
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"sign":"sig","riskMessage":"careful"}`},
{text: `{"ok":true}`},
}}
installScriptedCaller(t, caller)
if err := executePR868Command(t, newMailCommand(),
"message", "share-to-chat", "--email", "u@c.com", "--id", "m1", "--users", "u1", "--yes"); err != nil {
t.Fatalf("share with yes: %v", err)
}
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"ok":true}`}}})
if err := executePR868Command(t, newMailCommand(),
"message", "share-to-chat", "--email", "u@c.com", "--id", "m1", "--users", "u1", "--yes"); err != nil {
t.Fatalf("json success path: %v", err)
}
})
t.Run("calendar-event missing folder id", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{})
if err := executePR868Command(t, newMailCommand(),
"calendar-event", "list",
"--email", "u@c.com",
"--start", "2026-07-01T00:00:00Z",
"--end", "2026-07-31T23:59:59Z",
); err == nil {
t.Fatal("expected missing id/folder-id")
}
})
t.Run("sanitize and atomic write", func(t *testing.T) {
if got := sanitizeMailFilename(" a/b\\c\x00 "); got != "a_b_c" {
t.Fatalf("sanitize=%q", got)
}
if got := sanitizeMailFilename(" "); got != "mail" {
t.Fatalf("empty sanitize=%q", got)
}
dir := t.TempDir()
path := filepath.Join(dir, "out.eml")
if err := atomicWriteFile(path, []byte("one"), 0o600, false); err != nil {
t.Fatal(err)
}
if err := atomicWriteFile(path, []byte("two"), 0o600, false); err == nil {
t.Fatal("expected exist without overwrite")
}
if err := atomicWriteFile(path, []byte("two"), 0o600, true); err != nil {
t.Fatal(err)
}
if err := atomicWriteFile(filepath.Join(dir, "no-such", "x.eml"), []byte("x"), 0o600, false); err == nil {
t.Fatal("expected create temp fail")
}
testseam.Protect(t, &atomicCreateTemp)
testseam.Protect(t, &atomicRemove)
testseam.Protect(t, &atomicRename)
atomicRemove = func(string) error { return nil }
atomicCreateTemp = func(string, string) (atomicTempFile, error) {
return &atomicFakeTemp{chmodErr: errors.New("chmod boom")}, nil
}
if err := atomicWriteFile(filepath.Join(dir, "c.eml"), []byte("x"), 0o600, false); err == nil || !strings.Contains(err.Error(), "权限") {
t.Fatalf("chmod: %v", err)
}
atomicCreateTemp = func(string, string) (atomicTempFile, error) {
return &atomicFakeTemp{writeErr: errors.New("write boom")}, nil
}
if err := atomicWriteFile(filepath.Join(dir, "w.eml"), []byte("x"), 0o600, false); err == nil || !strings.Contains(err.Error(), "写入") {
t.Fatalf("write: %v", err)
}
atomicCreateTemp = func(string, string) (atomicTempFile, error) {
return &atomicFakeTemp{syncErr: errors.New("sync boom")}, nil
}
if err := atomicWriteFile(filepath.Join(dir, "s.eml"), []byte("x"), 0o600, false); err == nil || !strings.Contains(err.Error(), "同步") {
t.Fatalf("sync: %v", err)
}
atomicCreateTemp = func(string, string) (atomicTempFile, error) {
return &atomicFakeTemp{closeErr: errors.New("close boom")}, nil
}
if err := atomicWriteFile(filepath.Join(dir, "cl.eml"), []byte("x"), 0o600, false); err == nil || !strings.Contains(err.Error(), "关闭") {
t.Fatalf("close: %v", err)
}
atomicCreateTemp = func(string, string) (atomicTempFile, error) {
return &atomicFakeTemp{}, nil
}
atomicRename = func(string, string) error { return errors.New("rename boom") }
if err := atomicWriteFile(filepath.Join(dir, "r.eml"), []byte("x"), 0o600, true); err == nil || !strings.Contains(err.Error(), "重命名") {
t.Fatalf("rename: %v", err)
}
})
t.Run("export save non-exist failure", func(t *testing.T) {
cwd := t.TempDir()
t.Chdir(cwd)
testseam.Swap(t, &atomicCreateTemp, func(string, string) (atomicTempFile, error) {
return nil, errors.New("nospc")
})
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"emlContent":"body"}`},
}})
if err := executePR868Command(t, newMailCommand(),
"message", "export", "--email", "u@c.com", "--id", "m1", "--filename", "x"); err == nil || !strings.Contains(err.Error(), "保存文件失败") {
t.Fatalf("expected save failure, got %v", err)
}
})
}
func executeMailShare(t *testing.T, in io.Reader, args ...string) error {
t.Helper()
testseam.Protect(t, &os.Args)
os.Args = append([]string{"dws", "mail"}, args...)
root := newMailCommand()
root.SilenceErrors = true
root.SilenceUsage = true
root.SetIn(in)
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(args)
return root.Execute()
}
func TestCrossPlatformCoverageDiffEngineEdges(t *testing.T) {
// Expand matches backward + negative context clamp + missing newline marker.
old := []byte("a\nb\nc\nd\ne")
neu := []byte("a\nb\nC\nd\ne\n")
diff := UnifiedDiff("o", old, "n", neu, -3)
if len(diff) == 0 {
t.Fatal("expected diff with negative context")
}
diff2 := UnifiedDiff("o", []byte("same"), "n", []byte("same\nextra\n"), 2)
if !strings.Contains(string(diff2), "No newline at end of file") && !strings.Contains(string(diff2), "+extra") {
t.Fatalf("unexpected diff2=%q", diff2)
}
_ = UnifiedDiff("o", []byte("1\n2\n3\n4\n5\n6\n7\n"), "n", []byte("1\n2\n3\nX\n5\n6\n7\n"), 10)
_ = UnifiedDiff("o", []byte("only-old\n"), "n", []byte("only-new\n"), 0)
// Non-unique "common" lines before a unique anchor → backward expand (L90).
_ = UnifiedDiff("o", []byte("OLD\ncommon\ncommon\nUNIQUE\n"), "n", []byte("NEW\ncommon\ncommon\nUNIQUE\n"), 1)
_ = UnifiedDiff("o", []byte("OLD\ncommon\ncommon\nUNIQUE\ntail\n"), "n", []byte("NEW\ncommon\ncommon\nUNIQUE\ntail\nextra\n"), 2)
// Large context after an early emitted chunk → chunk.x/y clamps + new-chunk prefix.
_ = UnifiedDiff("o", []byte("A\nU\nZ\n"), "n", []byte("B\nU\nZ\n"), 10)
_ = UnifiedDiff("o", []byte("U\nZ\n"), "n", []byte("B\nU\nZ\n"), 10)
_ = UnifiedDiff("o", []byte("A\nU\nZ\n"), "n", []byte("U\nZ\n"), 10)
_ = UnifiedDiff("o", []byte("A\n\n\nU\nrest\n"), "n", []byte("B\n\n\nU\nrest\nmore\n"), 5)
if nonNeg(-3) != 0 || nonNeg(0) != 0 || nonNeg(4) != 4 {
t.Fatalf("nonNeg")
}
}
func TestCrossPlatformCoverageDriveLatestRemaining(t *testing.T) {
items := []map[string]any{
{"name": "a", "sortTime": int64(5), "rel_path": "same", "fileId": "2", "type": "file"},
{"name": "b", "sortTime": int64(5), "rel_path": "same", "fileId": "1", "type": "file"},
{"name": "c", "sortTime": int64(5), "rel_path": "z", "fileId": "3", "type": "file"},
{"nodeType": "Folder", "name": "folder"},
}
got := applyDriveListLatest(items, 10)
if len(got) != 3 {
t.Fatalf("len=%d", len(got))
}
if n, err := json.Number("99").Int64(); err != nil || n != 99 {
t.Fatal(err)
}
if ms, ok := toMillis(json.Number("99")); !ok || ms != 99 {
t.Fatalf("json.Number millis=%v %v", ms, ok)
}
if _, ok := toMillis(json.Number("-1")); ok {
t.Fatal("negative json.Number")
}
if _, ok := toMillis(struct{}{}); ok {
t.Fatal("unknown type")
}
// pagination + quiet=false progress + shortfall hint with pattern
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"items":[{"name":"a.md","fileId":"1","type":"file"},{"name":"x.bin","fileId":"2","type":"file"},{"fileId":"3","type":"file"}],"nextToken":"n1"}`},
{text: `{"items":[{"name":"b.md","fileName":"b.md","fileId":"4","type":"file"}],"nextToken":""}`},
}}
installScriptedCaller(t, caller)
testseam.Protect(t, &os.Args)
os.Args = []string{"dws", "drive", "list"}
cmd := &cobra.Command{Use: "list"}
cmd.SetContext(context.Background())
if err := runDriveListLatest(cmd, map[string]any{"spaceId": "s"}, "folder", 5, "*.md", false); err != nil {
t.Fatalf("latest paginate: %v", err)
}
// nil context uses Background
caller2 := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"items":[{"name":"a.md","fileId":"1","type":"file"}],"nextToken":""}`},
}}
installScriptedCaller(t, caller2)
cmd2 := &cobra.Command{Use: "list"}
if err := runDriveListLatest(cmd2, nil, "", 1, "", true); err != nil {
t.Fatalf("nil ctx: %v", err)
}
}
func TestCrossPlatformCoverageDriveListLatestBadFolder(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
if err := executePR868Command(t, newDriveCommand(), "list", "--latest", "2", "--folder", "12345"); err == nil {
t.Fatal("expected numeric folder rejection")
}
}
func TestCrossPlatformCoverageDriveDepthLatestTruncatedAndSortTime(t *testing.T) {
useDriveDepthArgs(t)
var sb strings.Builder
sb.WriteString(`{"items":[`)
for i := 0; i < driveDepthMaxItems; i++ {
if i > 0 {
sb.WriteString(",")
}
fmt.Fprintf(&sb, `{"fileId":"f%d","name":"file-%d.txt","type":"FILE","modifiedTime":%d}`, i, i, 1000+i)
}
sb.WriteString(`]}`)
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: sb.String()}}}
out := installDepthCaller(t, caller)
cmd := &cobra.Command{Use: "list"}
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 2)
if err == nil || !strings.Contains(err.Error(), "LATEST_SCAN_TRUNCATED") {
t.Fatalf("err=%v, want LATEST_SCAN_TRUNCATED", err)
}
_ = out
}
func TestCrossPlatformCoverageWhiteboardSeams(t *testing.T) {
testseam.Swap(t, &whiteboardJSONMarshal, func(any) ([]byte, error) { return nil, errors.New("boom") })
if buildWhiteboardCardJSONML("b", "w") != "" {
t.Fatal("expected empty on marshal fail")
}
testseam.Swap(t, &prepareWhiteboardCard, func(*cobra.Command, string) (string, error) {
return "", errors.New("bad template")
})
installScriptedCaller(t, &scriptedToolCaller{})
if err := executeWhiteboardCommand(t, "insert", "--node", "doc-1", "--yes"); err == nil || !strings.Contains(err.Error(), "白板卡片模板") {
t.Fatalf("expected prepare fail, got %v", err)
}
testseam.Protect(t, &os.Args)
os.Args = []string{"dws", "doc", "whiteboard"}
// nil entry in blocks list
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"blocks":[null,{"blockId":"b","jsonml":"[\"card\",{\"metadata\":{\"id\":\"w\"}}]"}]}`},
}})
if _, err := queryWhiteboardCardNode(context.Background(), "n", "b"); err != nil {
t.Fatalf("nil entry skip: %v", err)
}
}
@@ -1,488 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
import (
"context"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
func executePR868Command(t *testing.T, root *cobra.Command, args ...string) error {
t.Helper()
oldArgs := os.Args
os.Args = append([]string{"dws", root.Name()}, args...)
t.Cleanup(func() { os.Args = oldArgs })
root.SilenceErrors = true
root.SilenceUsage = true
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(args)
return root.Execute()
}
func TestCrossPlatformCoverageMinutesNewSurfaces(t *testing.T) {
t.Run("hot-word delete dry-run", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
if err := executePR868Command(t, newMinutesCommand(), "hot-word", "delete", "--words", "钉钉,OKR"); err != nil {
t.Fatalf("hot-word delete dry-run: %v", err)
}
})
t.Run("hot-word delete missing words", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true})
if err := executePR868Command(t, newMinutesCommand(), "hot-word", "delete"); err == nil {
t.Fatal("expected missing --words error")
}
})
t.Run("hot-word delete executes", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{}`}}}
installScriptedCaller(t, caller)
if err := executePR868Command(t, newMinutesCommand(), "hot-word", "delete", "--words", "钉钉"); err != nil {
t.Fatalf("hot-word delete: %v", err)
}
if caller.tool != "delete_personal_hotword" {
t.Fatalf("tool=%q", caller.tool)
}
})
t.Run("permission apply dry-run", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
if err := executePR868Command(t, newMinutesCommand(), "permission", "apply", "--id", "task-1", "--policy", "4"); err != nil {
t.Fatalf("permission apply dry-run: %v", err)
}
})
t.Run("permission apply alias uuid", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{}`}}}
installScriptedCaller(t, caller)
if err := executePR868Command(t, newMinutesCommand(), "permission", "apply", "--uuid", "task-2", "--policy", "2"); err != nil {
t.Fatalf("permission apply alias: %v", err)
}
if caller.tool != "apply_minutes_permission" {
t.Fatalf("tool=%q", caller.tool)
}
if caller.args["taskUuid"] != "task-2" || caller.args["policyId"] != float64(2) {
t.Fatalf("args=%#v", caller.args)
}
})
t.Run("permission apply invalid policy", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{})
if err := executePR868Command(t, newMinutesCommand(), "permission", "apply", "--id", "task", "--policy", "1"); err == nil {
t.Fatal("expected invalid policy")
}
if err := executePR868Command(t, newMinutesCommand(), "permission", "apply", "--id", "task", "--policy", "x"); err == nil {
t.Fatal("expected non-numeric policy")
}
})
t.Run("permission apply missing flags", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{})
if err := executePR868Command(t, newMinutesCommand(), "permission", "apply", "--policy", "4"); err == nil {
t.Fatal("expected missing id")
}
if err := executePR868Command(t, newMinutesCommand(), "permission", "apply", "--id", "task"); err == nil {
t.Fatal("expected missing policy")
}
})
t.Run("audio-memo list default", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"items":[]}`}}}
installScriptedCaller(t, caller)
if err := executePR868Command(t, newMinutesCommand(), "audio-memo", "list"); err != nil {
t.Fatalf("audio-memo list: %v", err)
}
if caller.tool != "list_audio_memos" {
t.Fatalf("tool=%q", caller.tool)
}
if caller.args["pageSize"] != float64(200) {
t.Fatalf("pageSize=%#v", caller.args["pageSize"])
}
})
t.Run("audio-memo list with range and cursor", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"items":[]}`}}}
installScriptedCaller(t, caller)
err := executePR868Command(t, newMinutesCommand(),
"audio-memo", "list",
"--max", "10",
"--cursor", "1740000000000",
"--start", "2026-01-01T00:00:00+08:00",
"--end", "2026-07-21T23:59:59+08:00",
)
if err != nil {
t.Fatalf("audio-memo list ranged: %v", err)
}
if caller.args["cursor"] != float64(1740000000000) {
t.Fatalf("cursor=%#v", caller.args["cursor"])
}
start, _ := caller.args["startTime"].(string)
end, _ := caller.args["endTime"].(string)
if !strings.Contains(start, "2026-01-01") || !strings.Contains(end, "2026-07-21") {
t.Fatalf("start/end=%q/%q", start, end)
}
})
t.Run("audio-memo list validation", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{})
if err := executePR868Command(t, newMinutesCommand(), "audio-memo", "list", "--max", "0"); err == nil {
t.Fatal("expected max validation")
}
if err := executePR868Command(t, newMinutesCommand(), "audio-memo", "list", "--max", "1001"); err == nil {
t.Fatal("expected max upper bound")
}
if err := executePR868Command(t, newMinutesCommand(), "audio-memo", "list", "--cursor", "-1"); err == nil {
t.Fatal("expected cursor validation")
}
if err := executePR868Command(t, newMinutesCommand(), "audio-memo", "list",
"--start", "2026-07-21T00:00:00+08:00", "--end", "2026-01-01T00:00:00+08:00"); err == nil {
t.Fatal("expected reversed range error")
}
if err := executePR868Command(t, newMinutesCommand(), "audio-memo", "list", "--start", "bad"); err == nil {
t.Fatal("expected bad start")
}
if err := executePR868Command(t, newMinutesCommand(), "audio-memo", "list", "--end", "bad"); err == nil {
t.Fatal("expected bad end")
}
})
}
func TestCrossPlatformCoverageDocExportGetTaskIDAlias(t *testing.T) {
// Existing primary --job-id must remain usable.
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{{text: `{"status":"SUCCESS","downloadUrl":"https://x"}`}}}
installScriptedCaller(t, caller)
if err := executePR868Command(t, newDocCommand(), "export", "get", "--job-id", "job-legacy"); err != nil {
t.Fatalf("export get --job-id: %v", err)
}
if caller.tool != "query_export_job" || caller.args["jobId"] != "job-legacy" {
t.Fatalf("tool/args=%q %#v", caller.tool, caller.args)
}
// Add-only synonym --task-id.
caller2 := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{{text: `{"status":"PROCESSING"}`}}}
installScriptedCaller(t, caller2)
if err := executePR868Command(t, newDocCommand(), "export", "get", "--task-id", "job-123"); err != nil {
t.Fatalf("export get --task-id: %v", err)
}
if caller2.args["jobId"] != "job-123" {
t.Fatalf("task-id args=%#v", caller2.args)
}
}
func TestCrossPlatformCoverageDriveAliasAndDownloadVersion(t *testing.T) {
t.Run("permission list max-results", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}}
installScriptedCaller(t, caller)
if err := executePR868Command(t, newDriveCommand(), "permission", "list", "--node", "n1", "--max-results", "10"); err != nil {
t.Fatalf("permission list: %v", err)
}
if caller.args["maxResults"] != 10 {
t.Fatalf("maxResults=%#v", caller.args["maxResults"])
}
})
t.Run("cover file-id alias", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
if err := executePR868Command(t, newDriveCommand(), "cover", "--file-id", "n1"); err != nil {
t.Fatalf("cover --file-id: %v", err)
}
})
t.Run("revert doc-id alias dry-run", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
if err := executePR868Command(t, newDriveCommand(), "revert", "--doc-id", "n1", "--version", "3"); err != nil {
t.Fatalf("revert --doc-id: %v", err)
}
})
t.Run("star add url alias", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
if err := executePR868Command(t, newDriveCommand(), "star", "add", "--url", "https://example/n1"); err != nil {
t.Fatalf("star add --url: %v", err)
}
})
t.Run("download --version routes", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
out := filepath.Join(t.TempDir(), "out.pdf")
if err := executePR868Command(t, newDriveCommand(), "download", "--node", "n1", "--version", "3", "--output", out); err != nil {
t.Fatalf("download --version: %v", err)
}
})
}
func TestCrossPlatformCoverageMailCalendarEventFolderID(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
err := executePR868Command(t, newMailCommand(),
"calendar-event", "list",
"--email", "a@b.com",
"--folder-id", "cal-1",
"--start", "2026-07-01T00:00:00Z",
"--end", "2026-07-31T23:59:59Z",
)
if err != nil {
t.Fatalf("calendar-event list --folder-id: %v", err)
}
}
func TestCrossPlatformCoverageUnifiedDiffEngine(t *testing.T) {
if got := UnifiedDiff("a", []byte("same\n"), "b", []byte("same\n"), 3); got != nil {
t.Fatalf("identical should be nil, got %q", got)
}
old := []byte("one\ntwo\nthree\n")
neu := []byte("one\nTWO\nthree\nfour\n")
diff := UnifiedDiff("old.txt", old, "new.txt", neu, 2)
if len(diff) == 0 || !strings.Contains(string(diff), "@@") {
t.Fatalf("expected hunk diff, got %q", diff)
}
_ = UnifiedDiff("o", []byte("a\nb\n"), "n", []byte("a\nc\n"), 0)
_ = UnifiedDiff("o", []byte("{\n\n}\n"), "n", []byte("{\n x\n}\n"), 3)
_ = UnifiedDiff("o", []byte("alpha\n"), "n", []byte("beta\n"), 1)
}
func TestCrossPlatformCoverageDriveLatestHelpers(t *testing.T) {
cmd := &cobra.Command{Use: "list"}
cmd.Flags().Int("latest", 0, "")
cmd.Flags().String("order-by", "", "")
cmd.Flags().String("order", "", "")
cmd.Flags().Int("limit", 0, "")
cmd.Flags().Int("max", 0, "")
cmd.Flags().String("cursor", "", "")
cmd.Flags().String("next-token", "", "")
if err := validateDriveListLatest(cmd, 0); err == nil {
t.Fatal("expected latest lower bound")
}
if err := validateDriveListLatest(cmd, 51); err == nil {
t.Fatal("expected latest upper bound")
}
_ = cmd.ParseFlags([]string{"--order-by=name"})
if err := validateDriveListLatest(cmd, 3); err == nil {
t.Fatal("expected exclusive order-by")
}
cmd2 := &cobra.Command{Use: "list"}
cmd2.Flags().Int("latest", 0, "")
cmd2.Flags().String("order-by", "", "")
cmd2.Flags().String("order", "", "")
cmd2.Flags().Int("limit", 0, "")
cmd2.Flags().Int("max", 0, "")
cmd2.Flags().String("cursor", "", "")
cmd2.Flags().String("next-token", "", "")
_ = cmd2.ParseFlags([]string{"--limit=10"})
if err := validateDriveListLatest(cmd2, 3); err == nil {
t.Fatal("expected exclusive limit")
}
cmd3 := &cobra.Command{Use: "list"}
cmd3.Flags().Int("latest", 0, "")
cmd3.Flags().String("order-by", "", "")
cmd3.Flags().String("order", "", "")
cmd3.Flags().Int("limit", 0, "")
cmd3.Flags().Int("max", 0, "")
cmd3.Flags().String("cursor", "", "")
cmd3.Flags().String("next-token", "", "")
_ = cmd3.ParseFlags([]string{"--cursor=tok"})
if err := validateDriveListLatest(cmd3, 3); err == nil {
t.Fatal("expected exclusive cursor")
}
if err := validateDriveListLatest(cmd3, 3); err == nil {
// already failed above
}
_ = validateDriveListLatest(&cobra.Command{Use: "x"}, 3) // no exclusive flags
items := []map[string]any{
{"name": "b.txt", "sortTime": int64(1), "rel_path": "b", "fileId": "2", "type": "file"},
{"name": "a.txt", "sortTime": int64(2), "rel_path": "a", "fileId": "1", "type": "file"},
{"name": "dir", "sortTime": int64(9), "type": "folder", "dentryType": "folder"},
}
got := applyDriveListLatest(items, 1)
if len(got) != 1 {
t.Fatalf("latest len=%d", len(got))
}
stripDriveDepthDecorations(got)
if _, ok := got[0]["sortTime"]; ok {
t.Fatal("sortTime should be stripped")
}
if ms, ok := driveItemModifiedMillis(map[string]any{"modifiedTime": float64(123)}); !ok || ms != 123 {
t.Fatalf("float millis=%v %v", ms, ok)
}
if ms, ok := toMillis("2026-01-02T03:04:05Z"); !ok || ms <= 0 {
t.Fatalf("rfc3339 millis=%v %v", ms, ok)
}
if _, ok := toMillis(""); ok {
t.Fatal("empty string should fail")
}
if _, ok := toMillis(float64(-1)); ok {
t.Fatal("negative float should fail")
}
if ms, ok := toMillis("42"); !ok || ms != 42 {
t.Fatalf("int string=%v %v", ms, ok)
}
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
listCmd := &cobra.Command{Use: "list"}
listCmd.SetContext(context.Background())
if err := runDriveListLatest(listCmd, map[string]any{"spaceId": "s"}, "folder", 2, "*.md", true); err != nil {
t.Fatalf("dry-run latest: %v", err)
}
caller := &scriptedToolCaller{steps: []scriptedToolStep{{
text: `{"items":[{"name":"a.md","fileId":"1","type":"file"},{"name":"skip.bin","fileId":"2","type":"file"},{"name":"dir","type":"FOLDER"}],"nextToken":""}`,
}}}
installScriptedCaller(t, caller)
oldArgs := os.Args
os.Args = []string{"dws", "drive", "list"}
t.Cleanup(func() { os.Args = oldArgs })
listCmd2 := &cobra.Command{Use: "list"}
listCmd2.SetContext(context.Background())
if err := runDriveListLatest(listCmd2, nil, "", 5, "*.md", false); err != nil {
t.Fatalf("latest scan: %v", err)
}
}
func executeWhiteboardCommand(t *testing.T, args ...string) error {
t.Helper()
oldArgs := os.Args
os.Args = append([]string{"dws", "doc", "whiteboard"}, args...)
t.Cleanup(func() { os.Args = oldArgs })
root := newDocWhiteboardCommand()
root.SilenceErrors = true
root.SilenceUsage = true
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(args)
return root.Execute()
}
func TestCrossPlatformCoverageWhiteboardInsertPaths(t *testing.T) {
t.Run("dry-run plan", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
if err := executeWhiteboardCommand(t, "insert", "--node", "doc-1"); err != nil {
t.Fatalf("whiteboard insert dry-run: %v", err)
}
})
t.Run("insert with placement and verify", func(t *testing.T) {
caller := &pr868FlexibleCaller{whiteboardID: "wb-persisted"}
testseam.Protect(t, &deps)
InitDeps(caller)
deps.Out.w = io.Discard
deps.Out.errW = io.Discard
testseam.Swap(t, &whiteboardSleep, func(time.Duration) {})
if err := executeWhiteboardCommand(t,
"insert", "--node", "doc-1", "--ref-block", "ref", "--where", "before", "--index", "2", "--yes"); err != nil {
t.Fatalf("whiteboard insert: %v", err)
}
})
t.Run("soft success when verify empty", func(t *testing.T) {
// Empty blocks are eventual-consistency pending, not hard query failure.
caller := &pr868FlexibleCaller{emptyVerify: true}
testseam.Protect(t, &deps)
InitDeps(caller)
deps.Out.w = io.Discard
deps.Out.errW = io.Discard
testseam.Swap(t, &whiteboardSleep, func(time.Duration) {})
if err := executeWhiteboardCommand(t, "insert", "--node", "doc-1", "--yes"); err != nil {
t.Fatalf("pending-block soft success: %v", err)
}
})
t.Run("helpers", func(t *testing.T) {
oldArgs := os.Args
os.Args = []string{"dws", "doc", "whiteboard", "insert"}
t.Cleanup(func() { os.Args = oldArgs })
if buildWhiteboardCardJSONML("b", "w") == "" {
t.Fatal("empty jsonml")
}
if extractWhiteboardID(nil) != "" {
t.Fatal("nil attrs")
}
if extractWhiteboardID(map[string]any{"metadata": map[string]any{"id": "x"}}) != "x" {
t.Fatal("extract id")
}
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{err: context.Canceled}}})
if _, err := queryWhiteboardCardNode(context.Background(), "n", "b"); err == nil {
t.Fatal("expected query error")
}
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{`}}})
if _, err := queryWhiteboardCardNode(context.Background(), "n", "b"); err == nil {
t.Fatal("expected parse error")
}
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"blocks":[{"blockId":"other","jsonml":"[]"}]}`}}})
if _, err := queryWhiteboardCardNode(context.Background(), "n", "b"); err == nil {
t.Fatal("expected missing block")
}
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"blocks":[{"blockId":"b","jsonml":"not-json"}]}`}}})
if _, err := queryWhiteboardCardNode(context.Background(), "n", "b"); err == nil {
t.Fatal("expected jsonml parse error")
}
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":{"blocks":[{"blockId":"b","jsonml":"[\"card\"]"}]}}`}}})
if _, err := queryWhiteboardCardAttrs(context.Background(), "n", "b"); err == nil {
t.Fatal("expected missing attrs")
}
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"blocks":[{"blockId":"b","jsonml":"[\"card\",null]"}]}`}}})
if _, err := queryWhiteboardCardAttrs(context.Background(), "n", "b"); err == nil {
t.Fatal("expected nil attrs")
}
})
}
type pr868FlexibleCaller struct {
whiteboardID string
emptyVerify bool
format string
dry bool
}
func (c *pr868FlexibleCaller) CallTool(_ context.Context, _, tool string, args map[string]any) (*edition.ToolResult, error) {
if tool == "insert_document_block" {
return textToolResult(`{}`), nil
}
if tool == "list_document_blocks" {
if c.emptyVerify {
return textToolResult(`{"blocks":[]}`), nil
}
blockID, _ := args["blockId"].(string)
payload := `{"blocks":[{"blockId":"` + blockID + `","jsonml":"[\"card\",{\"metadata\":{\"type\":\"hetu/draw\",\"id\":\"` + c.whiteboardID + `\"}},[\"span\",{},[\"span\",{},\"\"]]]"}]}`
return textToolResult(payload), nil
}
return textToolResult(`{}`), nil
}
func (c *pr868FlexibleCaller) Format() string { return c.format }
func (c *pr868FlexibleCaller) DryRun() bool { return c.dry }
func (*pr868FlexibleCaller) Fields() string { return "" }
func (*pr868FlexibleCaller) JQ() string { return "" }
func TestCrossPlatformCoverageMarkdownDiffHelpers(t *testing.T) {
if formatFileSize(100) == "" || formatFileSize(2048) == "" || formatFileSize(2*1024*1024) == "" {
t.Fatal("formatFileSize")
}
small := filepath.Join(t.TempDir(), "ok.md")
if err := os.WriteFile(small, []byte("hi\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := checkFileSize(small); err != nil {
t.Fatalf("checkFileSize small: %v", err)
}
if err := checkFileSize(filepath.Join(t.TempDir(), "missing")); err == nil {
t.Fatal("expected missing file")
}
diff, add, _, hunks, changed := computeUnifiedDiff("a\n", "a\nb\n", 2)
if !changed || add < 1 || hunks < 1 || diff == "" {
t.Fatalf("computeUnifiedDiff=%v %d %d %q", changed, add, hunks, diff)
}
if _, _, _, _, ch := computeUnifiedDiff("x\n", "x\n", 2); ch {
t.Fatal("identical should be unchanged")
}
right := filepath.Join(t.TempDir(), "right.md")
if err := os.WriteFile(right, []byte("hi\nthere\n"), 0o644); err != nil {
t.Fatal(err)
}
if describeDingTalkDocType("adoc") == "" {
t.Fatal("describeDingTalkDocType")
}
_ = right
}
@@ -141,23 +141,18 @@ func TestCrossPlatformCoverageProductCommandExamplesAreExecutableContracts(t *te
previousStdin := os.Stdin
previousPut := httpPutFile
previousGet := httpGetFile
previousWhiteboardSleep := whiteboardSleep
t.Cleanup(func() {
deps = previousDeps
os.Args = previousArgs
os.Stdin = previousStdin
httpPutFile = previousPut
httpGetFile = previousGet
whiteboardSleep = previousWhiteboardSleep
})
caller := &productExampleCaller{}
InitDeps(caller)
deps.Out.w = io.Discard
deps.Out.errW = io.Discard
// Product examples execute real RunE paths; whiteboard insert retries must
// not burn the suite timeout on real sleep (race CI uses a 12m package cap).
whiteboardSleep = func(time.Duration) {}
httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
httpGetFile = func(_ context.Context, _ string, _ map[string]string, destPath string) error {
if destPath == "" {
+60 -70
View File
@@ -1,96 +1,63 @@
{
" 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。": " The organization admin has not enabled \"Allow members to access their personal data via CLI\".",
" 组织主管理员:": " Organization super admins: ",
" 请检查网络连接后重试。": " Please check your network connection and retry.",
" 请联系组织主管理员开启后重新登录。": " Please contact the organization super admin to enable it and re-login.",
" (每 %d 秒轮询一次)": " (polling every %d seconds)",
" 授权码: %s": " authorization code: %s",
" 链接: %s": " link: %s",
"%s (第 %d 次尝试)\\\n": "%s (attempt %d)\\\n",
"--title 的别名": "Alias for --title",
"AI 表格 Base": "AI table Base",
"AI 表格 Base 管理": "AI table Base management",
"AITable 多维表格管理": "AITable multi-dimensional table management",
"AI诚聘": "AI Recruitment",
"Base ID (必填)": "Base ID (required)",
"DNS 解析失败。请检查域名拼写和网络 DNS 配置。": "DNS resolution failed. Check the domain spelling and network DNS settings.",
"HTTP 请求超时(等待服务端响应超时)。可通过 --timeout 增大超时时间,或检查服务端是否正常。": "HTTP request timed out waiting for the server. Increase --timeout or check the server.",
"MCP 服务返回了无法解析的协议响应;检查服务版本或上游代理。": "MCP service returned an unparseable protocol response; check service version or upstream proxy.",
"OSS 上传失败 HTTP %d: %s": "OSS upload failed HTTP %d: %s",
"TLS 握手超时。请检查网络连接或代理设置。": "TLS handshake timed out. Check the network connection or proxy settings.",
"[%d] 轮询中... (%ds)": "[%d] polling ... (%ds)",
"prepare_attachment_upload 返回格式异常": "prepare_attachment_upload returned abnormal format",
"refresh_token 刷新失败": "refresh_token refresh failed",
"refresh_token 刷新失败,将尝试扫码登录": "refresh_token refresh failed, will attempt QR code login",
"true=已完成, false=未完成": "true=completed, false=not completed",
"⏳ 等待授权中...": "⏳ Waiting for authorization...",
"⏳ 该组织尚未开启 CLI 数据访问权限,请在浏览器中提交授权申请...": "⏳ CLI data access is not enabled for this organization, please submit an authorization request in the browser...",
"⚠️ 即将删除 %s: %s\\\n": "⚠️ About to delete %s: %s\\\n",
"⚠️ 无法检查 CLI 数据访问权限状态": "⚠️ Unable to verify CLI data access permission status",
"⚠️ 该组织尚未开启 CLI 数据访问权限": "⚠️ CLI data access is not enabled for this organization",
"✅ 权限已开启,继续登录...": "✅ Permission enabled, continuing login...",
"上传失败: %w": "upload failed: %w",
"上游服务异常;可稍后重试。": "Upstream service error; retry later.",
"上游服务异常;可稍后重试,若持续失败请查看 recovery snapshot。": "Upstream service error; retry later. If persistent, check recovery snapshot.",
"下载失败 (HTTP %d): %s": "download failed (HTTP %d): %s",
"下载失败: %w": "Download failed: %w",
"不是文件: ": "not a file: ",
"人才查询": "Talent query",
"优先级: 10低/20普通/30较高/40紧急": "Priority: 10=low/20=normal/30=high/40=urgent",
"使用授权码换取 Access Token...": "Exchanging authorization code for Access Token...",
"保存 token 失败": "Failed to save token",
"保存Keyword subscription规则": "Save keyword subscription rule",
"修改待办任务": "Update todo task",
"修改执行者的待办完成状态": "Update executor todo done status",
"写入文件失败: %w": "write file failed: %w",
"准备上传失败: %w": "prepare upload failed: %w",
"分页查询审批实例": "Paginated query approval instances",
"列出已安装的插件": "List installed plugins",
"列出插件所有配置项": "List all config values for a plugin",
"创建 / 查询 / 更新 / 删除待办": "Create / query / update / delete todo",
"创建待办": "Create todo",
"创建文件失败: %w": "create file failed: %w",
"创建请求失败": "Failed to create request",
"删除待办": "Delete todo",
"删除 AI 表格": "Delete AI table",
"删除原因(可选)": "Delete reason (optional)",
"删除字段": "Delete field",
"删除待办": "Delete todo",
"删除指定 Base(高风险、不可逆)。使用 --yes 跳过确认。": "Delete specified Base (high risk, irreversible). Use --yes to skip confirmation.",
"删除指定字段(高风险、不可逆)。使用 --yes 跳过确认。": "Delete specified field (high risk, irreversible). Use --yes to skip confirmation.",
"删除指定数据表(高风险、不可逆)。使用 --yes 跳过确认。": "Delete specified data table (high risk, irreversible). Use --yes to skip confirmation.",
"删除插件配置项": "Remove a plugin config value",
"删除数据表": "Delete data table",
"删除行记录": "Delete row records",
"卸载已安装的插件": "Remove an installed plugin",
"参数不符合工具输入 schema;请检查 --json/--params/flags。": "Parameters do not match tool input schema; check --json/--params/flags.",
"发送请求失败": "Failed to send request",
"启用插件": "Enable a plugin",
"回调中未收到授权码": "Callback did not receive authorization code",
"如果浏览器未自动打开,请手动访问:\n %s\n\n": "If the browser did not open automatically, please visit:\n %s\n\n",
"字段": "field",
"字段 ID (必填)": "field ID (required)",
"字段管理": "Field management",
"安装插件": "Install a plugin",
"完成状态: true/false": "Done status: true/false",
"完成状态: true=已完成, false=未完成 (必填)": "Done status: true=completed, false=not completed (required)",
"审批实例管理": "Approval instance management",
"将插件 stdio server 编译为原生二进制": "Build plugin's stdio server into a native binary",
"将本地目录注册为开发态插件": "Register a local directory as a dev plugin",
"岗位查询": "Position query",
"工具协议不兼容;请检查服务版本、工具名或升级到包含最新静态端点的 dws 版本。": "Tool protocol incompatible; check service version, tool name, or upgrade to a dws version with the latest static endpoints.",
"工具调用失败;请检查参数和上游服务状态。": "Tool invocation failed; check parameters and upstream service status.",
"已取消操作": "Operation cancelled",
"当前平台 %s 没有可用的预编译二进制": "No pre-built binary available for platform %s",
"待办": "todo",
"待办任务 ID (必填)": "Todo task ID (required)",
"待办任务管理": "Todo task management",
"待办标题 (必填)": "Todo title (required)",
"待办详情": "Todo detail",
"循环待办 (需先设置 --due); 格式: DTSTART:...\\nRRULE:FREQ=DAILY;INTERVAL=1": "Recurring todo (requires --due); format: DTSTART:...\\nRRULE:FREQ=DAILY;INTERVAL=1",
"或者直接打开以下链接:": "Or open the following link:",
"截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)": "Due time ISO-8601 (e.g. 2026-03-10T18:00:00+08:00)",
"所有凭证已失效,请运行 dws auth login 重新登录": "All credentials have expired, please run dws auth login to re-authenticate",
"打开 zip 失败: %w": "open zip failed: %w",
"执行者 userId 列表 (必填)": "Executor userId list (required)",
"批量删除记录(高风险、不可逆),单次最多 100 条。使用 --yes 跳过确认。": "Batch delete records (high risk, irreversible), max 100 per batch. Use --yes to skip confirmation.",
"换取 token 失败": "Failed to exchange token",
"授权失败:未收到授权码": "Authorization failed: no authorization code received",
@@ -98,15 +65,12 @@
"授权码将在 %d 秒后过期。": "Authorization code will expire in %d seconds.",
"授权码已过期,正在重新发起设备授权流程...": "Authorization code expired, restarting device authorization flow...",
"授权超时(5分钟),请重试": "Authorization timeout (5 minutes), please retry",
"插件管理": "Manage plugins",
"操作超时,请重新登录": "Operation timed out, please re-login",
"数据表": "data table",
"数据表 ID (必填)": "data table ID (required)",
"数据表管理": "Data table management",
"文件不存在: ": "File not found: ",
"文件为空": "File is empty",
"文件过大 (%d 字节,限制 %d 字节)": "File too large (%d bytes, limit %d bytes)",
"新标题": "New title",
"无法打开文件: %w": "Cannot open file: %w",
"无法自动打开浏览器": "Cannot automatically open browser",
"无法获取当前二进制路径: %w": "Cannot get current binary path: %w",
@@ -114,10 +78,10 @@
"无法解析符号链接: %w": "Cannot resolve symlink: %w",
"无法读取当前二进制信息: %w": "Cannot read current binary info: %w",
"无法连接到更新服务器: %w": "Cannot connect to update server: %w",
"显示任意命令的帮助文案。\n用法:dws help [命令路径] 查看完整说明。": "Help provides help for any command in the application.\nSimply type dws help [path to command] for full details.",
"静态端点/协商失败;请检查网络、服务版本或升级到包含最新静态端点的 dws 版本。": "Static endpoint/negotiation failed; check network, service version, or upgrade to a dws version with the latest static endpoints.",
"服务端返回了空的 device_code 或 user_code": "Server returned empty device_code or user_code",
"服务端返回错误": "Server returned error",
"服务返回了空结果;请稍后重试。": "Service returned empty results; please retry later.",
"服务返回了空结果;请稍后重试,必要时查看 recovery snapshot。": "Service returned empty results; retry later. Check recovery snapshot if persistent.",
"未找到 MCP Server URL": "MCP Server URL not found",
"未找到认证信息,请运行 dws auth login": "No credentials found, please run dws auth login",
"未登录,请运行 dws auth login": "Not logged in, please run dws auth login",
@@ -126,43 +90,42 @@
"本地文件路径 (必填)": "Local file path (required)",
"权限不足;请检查当前身份是否有权限访问该服务或工具。": "Insufficient permissions; check if current identity has access to this service or tool.",
"构建上传请求失败: %w": "Failed to build upload request: %w",
"查看任意命令的帮助信息": "Help about any command",
"查看插件详情": "Show plugin details",
"查询入职人才": "Query onboarding candidates",
"查询在招岗位": "Query open positions",
"修改待办任务": "Update todo task",
"修改执行者的待办完成状态": "Update executor todo done status",
"优先级: 10低/20普通/30较高/40紧急": "Priority: 10=low/20=normal/30=high/40=urgent",
"查询待办列表": "List todos",
"校验 plugin.json": "Validate a plugin.json",
"检查 CLI 授权状态失败": "Failed to check CLI auth status",
"检查 internal/syncdata 静态端点生成物是否包含目标 server": "Check whether internal/syncdata static endpoint artifacts include the target server",
"待办": "todo",
"待办任务 ID (必填)": "Todo task ID (required)",
"待办任务管理": "Todo task management",
"待办标题 (必填)": "Todo title (required)",
"待办详情": "Todo detail",
"循环待办 (需先设置 --due); 格式: DTSTART:...\\nRRULE:FREQ=DAILY;INTERVAL=1": "Recurring todo (requires --due); format: DTSTART:...\\nRRULE:FREQ=DAILY;INTERVAL=1",
"截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)": "Due time ISO-8601 (e.g. 2026-03-10T18:00:00+08:00)",
"执行者 userId 列表 (必填)": "Executor userId list (required)",
"检查登录状态后重试": "Check login status and retry",
"检查网络、代理和 DNS 配置后重试原命令": "Check network, proxy, and DNS settings, then retry the original command",
"检查服务连通性后重试;如持续失败,请确认 MCP 服务响应正常。": "Check service connectivity and retry; if persistent, verify MCP service is responding.",
"检查服务连通性和协议版本后重试": "Check service connectivity and protocol version, then retry",
"检查登录状态后重试": "Check login status and retry",
"检查组织 CLI 授权状态...": "Checking organization CLI auth status...",
"检查网络、代理和 DNS 配置后重试原命令": "Check network, proxy, and DNS settings, then retry the original command",
"检查认证、权限和参数后重试原命令": "Check authentication, permissions, and parameters, then retry",
"新标题": "New title",
"管理钉钉个人待办:创建、查询列表、查看详情、修改、标记完成、删除。": "Manage DingTalk personal todos: create, list, view detail, update, mark done, delete.",
"步骤 1/3: 准备上传 %s (%d 字节, %s)...\\\n": "Step 1/3: Preparing upload %s (%d bytes, %s)...\\\n",
"步骤 2/3: 上传文件到 OSS...": "Step 2/3: Uploading file to OSS...",
"步骤 3/3: 上传完成!": "Step 3/3: Upload complete!",
"用户拒绝了授权请求": "User rejected the authorization request",
"确认 MCP 服务可访问;若持续失败请稍后重试": "Verify the MCP service is reachable; if the failure persists, retry later",
"完成状态: true/false": "Done status: true/false",
"完成状态: true=已完成, false=未完成 (必填)": "Done status: true=completed, false=not completed (required)",
"确认删除? (yes/no): ": "Confirm delete? (yes/no): ",
"禁用插件": "Disable a plugin",
"等待提交申请中": "Waiting to submit request",
"确认 MCP 服务可访问;若持续失败请稍后重试": "Verify the MCP service is reachable; if the failure persists, retry later",
"等待用户授权...": "Waiting for user authorization...",
"等待管理员审批中": "Waiting for admin approval",
"管理员操作入口:": "Admin settings: ",
"管理插件配置": "Manage plugin configuration",
"管理钉钉个人待办:创建、查询列表、查看详情、修改、标记完成、删除。": "Manage DingTalk personal todos: create, list, view detail, update, mark done, delete.",
"结果格式与客户端预期不一致;请检查服务协议变更或回退到最近可用版本。": "Result format does not match client expectations; check service protocol changes or rollback to latest working version.",
"网络 I/O 超时。可通过 --timeout 增大超时时间,或检查网络连接。": "Network I/O timed out. Increase --timeout or check the network.",
"网络错误,继续重试...": "Network error, retrying...",
"脚手架生成新插件目录": "Scaffold a new plugin directory",
"获取使用指引": "Get usage guide",
"获取审批实例详情": "Get approval instance details",
"获取数量,超过 20 自动分页 (默认 20)": "Fetch count, auto-paginate if over 20 (default 20)",
"获取版本信息失败 (HTTP %d)": "Get version info failed (HTTP %d)",
"覆盖 OAuth 客户端 ID (钉钉 AppKey)": "Override OAuth client ID (DingTalk AppKey)",
"覆盖 OAuth 客户端密钥 (钉钉 AppSecret)": "Override OAuth client secret (DingTalk AppSecret)",
"解析令牌数据失败": "Failed to parse token data",
"解析响应失败": "Failed to parse response",
"解析版本信息失败: %w": "Parse version info failed: %w",
@@ -175,32 +138,59 @@
"设备授权流程失败(已重试 %d 次)": "Device authorization flow failed (retried %d times)",
"设备授权码已过期": "Device authorization code has expired",
"设备授权码已过期(%d 秒),请重试": "Device authorization code expired (%d seconds), please retry",
"设置插件配置项": "Set a plugin config value",
"设置权限失败: %w": "Set permission failed: %w",
"该组织尚未开启 CLI 数据访问权限,请联系管理员开启": "CLI data access is not enabled for this organization, please contact admin to enable it",
"请在浏览器中完成扫码授权。": "Please complete QR code authorization in the browser.",
"请在浏览器中打开以下链接,并输入授权码:": "Please open the following link in your browser and enter the authorization code:",
"请检查服务 endpoint 是否为空或格式不合法。": "Please check if the service endpoint is empty or has invalid format.",
"请检查网络连通性和 MCP 服务状态后重试。": "Check network connectivity and MCP service status, then retry.",
"请求在重试过程中被取消;请检查调用侧超时设置。": "Request was cancelled during retry; check caller-side timeout settings.",
"请求已取消。如果非手动取消,请检查调用侧超时设置。": "Request cancelled. If not cancelled manually, check the caller timeout settings.",
"请求被上游服务拒绝;请检查参数、认证和权限配置。": "Request was rejected by upstream service; check parameters, authentication, and permissions.",
"请求设备授权码...": "Requesting device authorization code...",
"请求设备授权码失败": "Failed to request device authorization code",
"请求超时(上下文截止时间已到)。可通过 --timeout 增大超时时间,或检查网络连接。": "Request timed out (context deadline exceeded). Increase --timeout or check the network.",
"读取 zip 条目失败: %w": "Failed to read zip entry: %w",
"读取响应失败": "Failed to read response",
"读取插件配置项": "Get a plugin config value",
"读取版本信息失败: %w": "Read version info failed: %w",
"调用被拒绝;请检查认证状态、租户身份或访问权限。": "Call rejected; check authentication status, tenant identity, or access permissions.",
"跳过确认直接删除": "Skip confirmation and delete directly",
"--title 的别名": "Alias for --title",
"调用被拒绝;请检查认证状态、租户身份或访问权限。": "Call rejected; check authentication status, tenant identity, or access permissions.",
"轮询过快,间隔增加至 %ds": "Polling too fast, interval increased to %ds",
"运行 dws auth status 确认凭证有效,必要时重新登录": "Run dws auth status to confirm credentials are valid; re-login if needed",
"运行 sync-oss 重新生成静态端点与路由后重试": "Run sync-oss to regenerate static endpoints and routes, then retry",
"返回数据缺少 uploadUrl 或 fileToken": "Response data missing uploadUrl or fileToken",
"连接被拒绝。请确认服务端已启动并正在监听。": "Connection refused. Confirm the server is running and listening.",
"附件工作流": "Attachment workflow",
"静态端点/协商失败;请检查网络、服务版本或升级到包含最新静态端点的 dws 版本。": "Static endpoint/negotiation failed; check network, service version, or upgrade to a dws version with the latest static endpoints.",
"页码 (必填)": "page number (required)",
"🔐 登录钉钉": "🔐 Login to DingTalk"
"⚠️ 无法检查 CLI 数据访问权限状态": "⚠️ Unable to verify CLI data access permission status",
" 请检查网络连接后重试。": " Please check your network connection and retry.",
"检查 CLI 授权状态失败": "Failed to check CLI auth status",
"⚠️ 该组织尚未开启 CLI 数据访问权限": "⚠️ CLI data access is not enabled for this organization",
" 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。": " The organization admin has not enabled \"Allow members to access their personal data via CLI\".",
" 组织主管理员:": " Organization super admins: ",
" 请联系组织主管理员开启后重新登录。": " Please contact the organization super admin to enable it and re-login.",
"管理员操作入口:": "Admin settings: ",
"该组织尚未开启 CLI 数据访问权限,请联系管理员开启": "CLI data access is not enabled for this organization, please contact admin to enable it",
"⏳ 该组织尚未开启 CLI 数据访问权限,请在浏览器中提交授权申请...": "⏳ CLI data access is not enabled for this organization, please submit an authorization request in the browser...",
"✅ 权限已开启,继续登录...": "✅ Permission enabled, continuing login...",
"等待管理员审批中": "Waiting for admin approval",
"等待提交申请中": "Waiting to submit request",
"操作超时,请重新登录": "Operation timed out, please re-login",
"检查组织 CLI 授权状态...": "Checking organization CLI auth status...",
"🔐 登录钉钉": "🔐 Login to DingTalk",
"插件管理": "Manage plugins",
"列出已安装的插件": "List installed plugins",
"安装插件": "Install a plugin",
"查看插件详情": "Show plugin details",
"启用插件": "Enable a plugin",
"禁用插件": "Disable a plugin",
"卸载已安装的插件": "Remove an installed plugin",
"校验 plugin.json": "Validate a plugin.json",
"脚手架生成新插件目录": "Scaffold a new plugin directory",
"将本地目录注册为开发态插件": "Register a local directory as a dev plugin",
"管理插件配置": "Manage plugin configuration",
"设置插件配置项": "Set a plugin config value",
"读取插件配置项": "Get a plugin config value",
"列出插件所有配置项": "List all config values for a plugin",
"删除插件配置项": "Remove a plugin config value",
"将插件 stdio server 编译为原生二进制": "Build plugin's stdio server into a native binary",
"覆盖 OAuth 客户端 ID (钉钉 AppKey)": "Override OAuth client ID (DingTalk AppKey)",
"覆盖 OAuth 客户端密钥 (钉钉 AppSecret)": "Override OAuth client secret (DingTalk AppSecret)",
"查看任意命令的帮助信息": "Help about any command",
"显示任意命令的帮助文案。\n用法:dws help [命令路径] 查看完整说明。": "Help provides help for any command in the application.\nSimply type dws help [path to command] for full details."
}
+60 -70
View File
@@ -1,96 +1,63 @@
{
" 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。": " 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。",
" 组织主管理员:": " 组织主管理员:",
" 请检查网络连接后重试。": " 请检查网络连接后重试。",
" 请联系组织主管理员开启后重新登录。": " 请联系组织主管理员开启后重新登录。",
" (每 %d 秒轮询一次)": " (每 %d 秒轮询一次)",
" 授权码: %s": " 授权码: %s",
" 链接: %s": " 链接: %s",
"%s (第 %d 次尝试)\\\n": "%s (第 %d 次尝试)\\\n",
"--title 的别名": "--title 的别名",
"AI 表格 Base": "AI 表格 Base",
"AI 表格 Base 管理": "AI 表格 Base 管理",
"AITable 多维表格管理": "AITable 多维表格管理",
"AI诚聘": "AI诚聘",
"Base ID (必填)": "Base ID (必填)",
"DNS 解析失败。请检查域名拼写和网络 DNS 配置。": "DNS 解析失败。请检查域名拼写和网络 DNS 配置。",
"HTTP 请求超时(等待服务端响应超时)。可通过 --timeout 增大超时时间,或检查服务端是否正常。": "HTTP 请求超时(等待服务端响应超时)。可通过 --timeout 增大超时时间,或检查服务端是否正常。",
"MCP 服务返回了无法解析的协议响应;检查服务版本或上游代理。": "MCP 服务返回了无法解析的协议响应;检查服务版本或上游代理。",
"OSS 上传失败 HTTP %d: %s": "OSS 上传失败 HTTP %d: %s",
"TLS 握手超时。请检查网络连接或代理设置。": "TLS 握手超时。请检查网络连接或代理设置。",
"[%d] 轮询中... (%ds)": "[%d] 轮询中... (%ds)",
"prepare_attachment_upload 返回格式异常": "prepare_attachment_upload 返回格式异常",
"refresh_token 刷新失败": "refresh_token 刷新失败",
"refresh_token 刷新失败,将尝试扫码登录": "refresh_token 刷新失败,将尝试扫码登录",
"true=已完成, false=未完成": "true=已完成, false=未完成",
"⏳ 等待授权中...": "⏳ 等待授权中...",
"⏳ 该组织尚未开启 CLI 数据访问权限,请在浏览器中提交授权申请...": "⏳ 该组织尚未开启 CLI 数据访问权限,请在浏览器中提交授权申请...",
"⚠️ 即将删除 %s: %s\\\n": "⚠️ 即将删除 %s: %s\\\n",
"⚠️ 无法检查 CLI 数据访问权限状态": "⚠️ 无法检查 CLI 数据访问权限状态",
"⚠️ 该组织尚未开启 CLI 数据访问权限": "⚠️ 该组织尚未开启 CLI 数据访问权限",
"✅ 权限已开启,继续登录...": "✅ 权限已开启,继续登录...",
"上传失败: %w": "上传失败: %w",
"上游服务异常;可稍后重试。": "上游服务异常;可稍后重试。",
"上游服务异常;可稍后重试,若持续失败请查看 recovery snapshot。": "上游服务异常;可稍后重试,若持续失败请查看 recovery snapshot。",
"下载失败 (HTTP %d): %s": "下载失败 (HTTP %d): %s",
"下载失败: %w": "下载失败: %w",
"不是文件: ": "不是文件: ",
"人才查询": "人才查询",
"优先级: 10低/20普通/30较高/40紧急": "优先级: 10低/20普通/30较高/40紧急",
"使用授权码换取 Access Token...": "使用授权码换取 Access Token...",
"保存 token 失败": "保存 token 失败",
"保存Keyword subscription规则": "保存Keyword subscription规则",
"修改待办任务": "修改待办任务",
"修改执行者的待办完成状态": "修改执行者的待办完成状态",
"写入文件失败: %w": "写入文件失败: %w",
"准备上传失败: %w": "准备上传失败: %w",
"分页查询审批实例": "分页查询审批实例",
"列出已安装的插件": "列出已安装的插件",
"列出插件所有配置项": "列出插件所有配置项",
"创建 / 查询 / 更新 / 删除待办": "创建 / 查询 / 更新 / 删除待办",
"创建待办": "创建待办",
"创建文件失败: %w": "创建文件失败: %w",
"创建请求失败": "创建请求失败",
"删除待办": "删除待办",
"删除 AI 表格": "删除 AI 表格",
"删除原因(可选)": "删除原因(可选)",
"删除字段": "删除字段",
"删除待办": "删除待办",
"删除指定 Base(高风险、不可逆)。使用 --yes 跳过确认。": "删除指定 Base(高风险、不可逆)。使用 --yes 跳过确认。",
"删除指定字段(高风险、不可逆)。使用 --yes 跳过确认。": "删除指定字段(高风险、不可逆)。使用 --yes 跳过确认。",
"删除指定数据表(高风险、不可逆)。使用 --yes 跳过确认。": "删除指定数据表(高风险、不可逆)。使用 --yes 跳过确认。",
"删除插件配置项": "删除插件配置项",
"删除数据表": "删除数据表",
"删除行记录": "删除行记录",
"卸载已安装的插件": "卸载已安装的插件",
"参数不符合工具输入 schema;请检查 --json/--params/flags。": "参数不符合工具输入 schema;请检查 --json/--params/flags。",
"发送请求失败": "发送请求失败",
"启用插件": "启用插件",
"回调中未收到授权码": "回调中未收到授权码",
"如果浏览器未自动打开,请手动访问:\n %s\n\n": "如果浏览器未自动打开,请手动访问:\n %s\n\n",
"字段": "字段",
"字段 ID (必填)": "字段 ID (必填)",
"字段管理": "字段管理",
"安装插件": "安装插件",
"完成状态: true/false": "完成状态: true/false",
"完成状态: true=已完成, false=未完成 (必填)": "完成状态: true=已完成, false=未完成 (必填)",
"审批实例管理": "审批实例管理",
"将插件 stdio server 编译为原生二进制": "将插件 stdio server 编译为原生二进制",
"将本地目录注册为开发态插件": "将本地目录注册为开发态插件",
"岗位查询": "岗位查询",
"工具协议不兼容;请检查服务版本、工具名或升级到包含最新静态端点的 dws 版本。": "工具协议不兼容;请检查服务版本、工具名或升级到包含最新静态端点的 dws 版本。",
"工具调用失败;请检查参数和上游服务状态。": "工具调用失败;请检查参数和上游服务状态。",
"已取消操作": "已取消操作",
"当前平台 %s 没有可用的预编译二进制": "当前平台 %s 没有可用的预编译二进制",
"待办": "待办",
"待办任务 ID (必填)": "待办任务 ID (必填)",
"待办任务管理": "待办任务管理",
"待办标题 (必填)": "待办标题 (必填)",
"待办详情": "待办详情",
"循环待办 (需先设置 --due); 格式: DTSTART:...\\nRRULE:FREQ=DAILY;INTERVAL=1": "循环待办 (需先设置 --due); 格式: DTSTART:...\\nRRULE:FREQ=DAILY;INTERVAL=1",
"或者直接打开以下链接:": "或者直接打开以下链接:",
"截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)": "截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)",
"所有凭证已失效,请运行 dws auth login 重新登录": "所有凭证已失效,请运行 dws auth login 重新登录",
"打开 zip 失败: %w": "打开 zip 失败: %w",
"执行者 userId 列表 (必填)": "执行者 userId 列表 (必填)",
"批量删除记录(高风险、不可逆),单次最多 100 条。使用 --yes 跳过确认。": "批量删除记录(高风险、不可逆),单次最多 100 条。使用 --yes 跳过确认。",
"换取 token 失败": "换取 token 失败",
"授权失败:未收到授权码": "授权失败:未收到授权码",
@@ -98,15 +65,12 @@
"授权码将在 %d 秒后过期。": "授权码将在 %d 秒后过期。",
"授权码已过期,正在重新发起设备授权流程...": "授权码已过期,正在重新发起设备授权流程...",
"授权超时(5分钟),请重试": "授权超时(5分钟),请重试",
"插件管理": "插件管理",
"操作超时,请重新登录": "操作超时,请重新登录",
"数据表": "数据表",
"数据表 ID (必填)": "数据表 ID (必填)",
"数据表管理": "数据表管理",
"文件不存在: ": "文件不存在: ",
"文件为空": "文件为空",
"文件过大 (%d 字节,限制 %d 字节)": "文件过大 (%d 字节,限制 %d 字节)",
"新标题": "新标题",
"无法打开文件: %w": "无法打开文件: %w",
"无法自动打开浏览器": "无法自动打开浏览器",
"无法获取当前二进制路径: %w": "无法获取当前二进制路径: %w",
@@ -114,10 +78,10 @@
"无法解析符号链接: %w": "无法解析符号链接: %w",
"无法读取当前二进制信息: %w": "无法读取当前二进制信息: %w",
"无法连接到更新服务器: %w": "无法连接到更新服务器: %w",
"显示任意命令的帮助文案。\n用法:dws help [命令路径] 查看完整说明。": "显示任意命令的帮助文案。\n用法:dws help [命令路径] 查看完整说明。",
"静态端点/协商失败;请检查网络、服务版本或升级到包含最新静态端点的 dws 版本。": "静态端点/协商失败;请检查网络、服务版本或升级到包含最新静态端点的 dws 版本。",
"服务端返回了空的 device_code 或 user_code": "服务端返回了空的 device_code 或 user_code",
"服务端返回错误": "服务端返回错误",
"服务返回了空结果;请稍后重试。": "服务返回了空结果;请稍后重试。",
"服务返回了空结果;请稍后重试,必要时查看 recovery snapshot。": "服务返回了空结果;请稍后重试,必要时查看 recovery snapshot。",
"未找到 MCP Server URL": "未找到 MCP Server URL",
"未找到认证信息,请运行 dws auth login": "未找到认证信息,请运行 dws auth login",
"未登录,请运行 dws auth login": "未登录,请运行 dws auth login",
@@ -126,43 +90,42 @@
"本地文件路径 (必填)": "本地文件路径 (必填)",
"权限不足;请检查当前身份是否有权限访问该服务或工具。": "权限不足;请检查当前身份是否有权限访问该服务或工具。",
"构建上传请求失败: %w": "构建上传请求失败: %w",
"查看任意命令的帮助信息": "查看任意命令的帮助信息",
"查看插件详情": "查看插件详情",
"查询入职人才": "查询入职人才",
"查询在招岗位": "查询在招岗位",
"修改待办任务": "修改待办任务",
"修改执行者的待办完成状态": "修改执行者的待办完成状态",
"优先级: 10低/20普通/30较高/40紧急": "优先级: 10低/20普通/30较高/40紧急",
"查询待办列表": "查询待办列表",
"校验 plugin.json": "校验 plugin.json",
"检查 CLI 授权状态失败": "检查 CLI 授权状态失败",
"检查 internal/syncdata 静态端点生成物是否包含目标 server": "检查 internal/syncdata 静态端点生成物是否包含目标 server",
"待办": "待办",
"待办任务 ID (必填)": "待办任务 ID (必填)",
"待办任务管理": "待办任务管理",
"待办标题 (必填)": "待办标题 (必填)",
"待办详情": "待办详情",
"循环待办 (需先设置 --due); 格式: DTSTART:...\\nRRULE:FREQ=DAILY;INTERVAL=1": "循环待办 (需先设置 --due); 格式: DTSTART:...\\nRRULE:FREQ=DAILY;INTERVAL=1",
"截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)": "截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)",
"执行者 userId 列表 (必填)": "执行者 userId 列表 (必填)",
"检查登录状态后重试": "检查登录状态后重试",
"检查网络、代理和 DNS 配置后重试原命令": "检查网络、代理和 DNS 配置后重试原命令",
"检查服务连通性后重试;如持续失败,请确认 MCP 服务响应正常。": "检查服务连通性后重试;如持续失败,请确认 MCP 服务响应正常。",
"检查服务连通性和协议版本后重试": "检查服务连通性和协议版本后重试",
"检查登录状态后重试": "检查登录状态后重试",
"检查组织 CLI 授权状态...": "检查组织 CLI 授权状态...",
"检查网络、代理和 DNS 配置后重试原命令": "检查网络、代理和 DNS 配置后重试原命令",
"检查认证、权限和参数后重试原命令": "检查认证、权限和参数后重试原命令",
"新标题": "新标题",
"管理钉钉个人待办:创建、查询列表、查看详情、修改、标记完成、删除。": "管理钉钉个人待办:创建、查询列表、查看详情、修改、标记完成、删除。",
"步骤 1/3: 准备上传 %s (%d 字节, %s)...\\\n": "步骤 1/3: 准备上传 %s (%d 字节, %s)...\\\n",
"步骤 2/3: 上传文件到 OSS...": "步骤 2/3: 上传文件到 OSS...",
"步骤 3/3: 上传完成!": "步骤 3/3: 上传完成!",
"用户拒绝了授权请求": "用户拒绝了授权请求",
"确认 MCP 服务可访问;若持续失败请稍后重试": "确认 MCP 服务可访问;若持续失败请稍后重试",
"完成状态: true/false": "完成状态: true/false",
"完成状态: true=已完成, false=未完成 (必填)": "完成状态: true=已完成, false=未完成 (必填)",
"确认删除? (yes/no): ": "确认删除? (yes/no): ",
"禁用插件": "禁用插件",
"等待提交申请中": "等待提交申请中",
"确认 MCP 服务可访问;若持续失败请稍后重试": "确认 MCP 服务可访问;若持续失败请稍后重试",
"等待用户授权...": "等待用户授权...",
"等待管理员审批中": "等待管理员审批中",
"管理员操作入口:": "管理员操作入口:",
"管理插件配置": "管理插件配置",
"管理钉钉个人待办:创建、查询列表、查看详情、修改、标记完成、删除。": "管理钉钉个人待办:创建、查询列表、查看详情、修改、标记完成、删除。",
"结果格式与客户端预期不一致;请检查服务协议变更或回退到最近可用版本。": "结果格式与客户端预期不一致;请检查服务协议变更或回退到最近可用版本。",
"网络 I/O 超时。可通过 --timeout 增大超时时间,或检查网络连接。": "网络 I/O 超时。可通过 --timeout 增大超时时间,或检查网络连接。",
"网络错误,继续重试...": "网络错误,继续重试...",
"脚手架生成新插件目录": "脚手架生成新插件目录",
"获取使用指引": "获取使用指引",
"获取审批实例详情": "获取审批实例详情",
"获取数量,超过 20 自动分页 (默认 20)": "获取数量,超过 20 自动分页 (默认 20)",
"获取版本信息失败 (HTTP %d)": "获取版本信息失败 (HTTP %d)",
"覆盖 OAuth 客户端 ID (钉钉 AppKey)": "覆盖 OAuth 客户端 ID (钉钉 AppKey)",
"覆盖 OAuth 客户端密钥 (钉钉 AppSecret)": "覆盖 OAuth 客户端密钥 (钉钉 AppSecret)",
"解析令牌数据失败": "解析令牌数据失败",
"解析响应失败": "解析响应失败",
"解析版本信息失败: %w": "解析版本信息失败: %w",
@@ -175,32 +138,59 @@
"设备授权流程失败(已重试 %d 次)": "设备授权流程失败(已重试 %d 次)",
"设备授权码已过期": "设备授权码已过期",
"设备授权码已过期(%d 秒),请重试": "设备授权码已过期(%d 秒),请重试",
"设置插件配置项": "设置插件配置项",
"设置权限失败: %w": "设置权限失败: %w",
"该组织尚未开启 CLI 数据访问权限,请联系管理员开启": "该组织尚未开启 CLI 数据访问权限,请联系管理员开启",
"请在浏览器中完成扫码授权。": "请在浏览器中完成扫码授权。",
"请在浏览器中打开以下链接,并输入授权码:": "请在浏览器中打开以下链接,并输入授权码:",
"请检查服务 endpoint 是否为空或格式不合法。": "请检查服务 endpoint 是否为空或格式不合法。",
"请检查网络连通性和 MCP 服务状态后重试。": "请检查网络连通性和 MCP 服务状态后重试。",
"请求在重试过程中被取消;请检查调用侧超时设置。": "请求在重试过程中被取消;请检查调用侧超时设置。",
"请求已取消。如果非手动取消,请检查调用侧超时设置。": "请求已取消。如果非手动取消,请检查调用侧超时设置。",
"请求被上游服务拒绝;请检查参数、认证和权限配置。": "请求被上游服务拒绝;请检查参数、认证和权限配置。",
"请求设备授权码...": "请求设备授权码...",
"请求设备授权码失败": "请求设备授权码失败",
"请求超时(上下文截止时间已到)。可通过 --timeout 增大超时时间,或检查网络连接。": "请求超时(上下文截止时间已到)。可通过 --timeout 增大超时时间,或检查网络连接。",
"读取 zip 条目失败: %w": "读取 zip 条目失败: %w",
"读取响应失败": "读取响应失败",
"读取插件配置项": "读取插件配置项",
"读取版本信息失败: %w": "读取版本信息失败: %w",
"调用被拒绝;请检查认证状态、租户身份或访问权限。": "调用被拒绝;请检查认证状态、租户身份或访问权限。",
"跳过确认直接删除": "跳过确认直接删除",
"--title 的别名": "--title 的别名",
"调用被拒绝;请检查认证状态、租户身份或访问权限。": "调用被拒绝;请检查认证状态、租户身份或访问权限。",
"轮询过快,间隔增加至 %ds": "轮询过快,间隔增加至 %ds",
"运行 dws auth status 确认凭证有效,必要时重新登录": "运行 dws auth status 确认凭证有效,必要时重新登录",
"运行 sync-oss 重新生成静态端点与路由后重试": "运行 sync-oss 重新生成静态端点与路由后重试",
"返回数据缺少 uploadUrl 或 fileToken": "返回数据缺少 uploadUrl 或 fileToken",
"连接被拒绝。请确认服务端已启动并正在监听。": "连接被拒绝。请确认服务端已启动并正在监听。",
"附件工作流": "附件工作流",
"静态端点/协商失败;请检查网络、服务版本或升级到包含最新静态端点的 dws 版本。": "静态端点/协商失败;请检查网络、服务版本或升级到包含最新静态端点的 dws 版本。",
"页码 (必填)": "页码 (必填)",
"🔐 登录钉钉": "🔐 登录钉钉"
"⚠️ 无法检查 CLI 数据访问权限状态": "⚠️ 无法检查 CLI 数据访问权限状态",
" 请检查网络连接后重试。": " 请检查网络连接后重试。",
"检查 CLI 授权状态失败": "检查 CLI 授权状态失败",
"⚠️ 该组织尚未开启 CLI 数据访问权限": "⚠️ 该组织尚未开启 CLI 数据访问权限",
" 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。": " 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。",
" 组织主管理员:": " 组织主管理员:",
" 请联系组织主管理员开启后重新登录。": " 请联系组织主管理员开启后重新登录。",
"管理员操作入口:": "管理员操作入口:",
"该组织尚未开启 CLI 数据访问权限,请联系管理员开启": "该组织尚未开启 CLI 数据访问权限,请联系管理员开启",
"⏳ 该组织尚未开启 CLI 数据访问权限,请在浏览器中提交授权申请...": "⏳ 该组织尚未开启 CLI 数据访问权限,请在浏览器中提交授权申请...",
"✅ 权限已开启,继续登录...": "✅ 权限已开启,继续登录...",
"等待管理员审批中": "等待管理员审批中",
"等待提交申请中": "等待提交申请中",
"操作超时,请重新登录": "操作超时,请重新登录",
"检查组织 CLI 授权状态...": "检查组织 CLI 授权状态...",
"🔐 登录钉钉": "🔐 登录钉钉",
"插件管理": "插件管理",
"列出已安装的插件": "列出已安装的插件",
"安装插件": "安装插件",
"查看插件详情": "查看插件详情",
"启用插件": "启用插件",
"禁用插件": "禁用插件",
"卸载已安装的插件": "卸载已安装的插件",
"校验 plugin.json": "校验 plugin.json",
"脚手架生成新插件目录": "脚手架生成新插件目录",
"将本地目录注册为开发态插件": "将本地目录注册为开发态插件",
"管理插件配置": "管理插件配置",
"设置插件配置项": "设置插件配置项",
"读取插件配置项": "读取插件配置项",
"列出插件所有配置项": "列出插件所有配置项",
"删除插件配置项": "删除插件配置项",
"将插件 stdio server 编译为原生二进制": "将插件 stdio server 编译为原生二进制",
"覆盖 OAuth 客户端 ID (钉钉 AppKey)": "覆盖 OAuth 客户端 ID (钉钉 AppKey)",
"覆盖 OAuth 客户端密钥 (钉钉 AppSecret)": "覆盖 OAuth 客户端密钥 (钉钉 AppSecret)",
"查看任意命令的帮助信息": "查看任意命令的帮助信息",
"显示任意命令的帮助文案。\n用法:dws help [命令路径] 查看完整说明。": "显示任意命令的帮助文案。\n用法:dws help [命令路径] 查看完整说明。"
}
+821
View File
@@ -0,0 +1,821 @@
package recovery
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
)
func TestCrossPlatformCoverageRecoveryModelEdges(t *testing.T) {
typedCases := []struct {
name string
err error
raw string
want string
}{
{"validation required", apperrors.NewValidation("required value"), "", cliMissingParamCode},
{"validation invalid", apperrors.NewValidation("bad json"), "", cliInvalidJSONCode},
{"auth permission reason", apperrors.NewAuth("denied", apperrors.WithReason("http_403")), "", cliPermissionCode},
{"auth forbidden", apperrors.NewAuth("forbidden"), "", cliPermissionCode},
{"auth expired", apperrors.NewAuth("expired"), "", cliAuthExpiredCode},
{"api rate reason", apperrors.NewAPI("limited", apperrors.WithReason("http_429")), "", cliRateLimitCode},
{"api rate message", apperrors.NewAPI("rate limit", apperrors.WithRetryable(true)), "", cliRateLimitCode},
{"api timeout reason", apperrors.NewAPI("failed", apperrors.WithReason("request_timeout")), "", cliTimeoutCode},
{"api timeout message", apperrors.NewDiscovery("connection reset"), "", cliTimeoutCode},
{"api invalid params", apperrors.NewAPI("failed", apperrors.WithReason("invalid_params")), "", cliInvalidJSONCode},
{"api method missing", apperrors.NewAPI("failed", apperrors.WithReason("method_not_found")), "", cliResourceNotFoundCode},
{"api 404", apperrors.NewAPI("failed", apperrors.WithReason("http_404")), "", cliResourceNotFoundCode},
{"raw auth", nil, "user_token_illegal", cliAuthExpiredCode},
{"raw permission", nil, "permission denied", cliPermissionCode},
{"raw rate", nil, "too many requests", cliRateLimitCode},
{"raw timeout", nil, "deadline exceeded", cliTimeoutCode},
{"raw missing", nil, "base_not_found", cliResourceNotFoundCode},
{"unknown", nil, "strange", ""},
}
for _, tt := range typedCases {
t.Run(tt.name, func(t *testing.T) {
if got := canonicalCLIErrorCode(tt.err, tt.raw); got != tt.want {
t.Fatalf("canonicalCLIErrorCode = %q, want %q", got, tt.want)
}
})
}
for _, name := range []string{"get_x", "list_x", "search_x", "query_x", "status_x", "download_x"} {
if InferOperationKind(name) != OperationRead {
t.Errorf("%q should be read", name)
}
}
for _, name := range []string{"create_x", "update_x", "delete_x", "send_x", "generate_image", "edit_image", "upscale", "isolate"} {
if InferOperationKind(name) != OperationWrite {
t.Errorf("%q should be write", name)
}
}
if InferOperationKind("") != OperationUnknown || InferOperationKind("execute") != OperationUnknown {
t.Fatal("unknown operation inference failed")
}
input := map[string]any{
"short": "value",
"long": strings.Repeat("x", 121),
"text": "secret body",
"names": []string{"a", "b"},
"textItems": []string{"a"},
"items": []any{"a", map[string]any{"x": 1}},
"records": []map[string]any{{"name": "n"}},
"objects": []map[string]any{{"name": "n", "token": "drop"}},
"payload": map[string]any{"name": "n", "token": "drop"},
"fields": map[string]any{"z": 1, "a": 2},
"stringMap": map[string]string{"name": "n", "token": "drop"},
"jsonMap": map[string]string{"z": "1", "a": "2"},
"number": 7,
"token": "drop",
}
summary := SummarizeArgs(input)
if len(summary) == 0 || summary["number"] != 7 {
t.Fatalf("summary = %#v", summary)
}
replayed := sanitizeReplayMap(input)
if _, ok := replayed["token"]; ok {
t.Fatal("sensitive replay field retained")
}
for _, value := range []any{
map[string]any{"safe": "x", "token": "drop"},
map[string]string{"safe": "x", "token": "drop"},
[]any{map[string]any{"safe": "x"}},
[]string{"a"},
[]map[string]any{{"safe": "x"}},
42,
} {
if sanitizeReplayValue(value) == nil {
t.Fatalf("sanitizeReplayValue(%T) returned nil", value)
}
}
if sanitizeReplayMap(nil) != nil || sanitizeReplayStringMap(nil) != nil {
t.Fatal("empty replay maps should be nil")
}
if got := sanitizeReplayField("data", map[string]string{"safe": "x"}); got == nil {
t.Fatal("string map sanitization failed")
}
if got := sanitizeReplayField("data", []map[string]any{{"safe": "x"}}); got == nil {
t.Fatal("map slice sanitization failed")
}
if got := sanitizeReplayField("data", strings.Repeat("x", 121)); got == nil {
t.Fatal("long string sanitization failed")
}
argv := sanitizeArgv([]string{
"dws", "--token=one", "--auth-code=two", "--refresh-token=three",
"--auth-code", "four", "--refresh-token", "five", "--plain",
})
if len(argv) != 9 || strings.Contains(strings.Join(argv, " "), "one") {
t.Fatalf("sanitized argv = %#v", argv)
}
if sanitizeArgv(nil) != nil {
t.Fatal("nil argv should remain nil")
}
if value, ok := redactSensitiveArg("--plain=x"); ok || value != "" {
t.Fatalf("plain arg redacted: %q %v", value, ok)
}
if keys := sortedKeys(map[string]any{"b": 1, "a": 2}); strings.Join(keys, ",") != "a,b" {
t.Fatalf("sorted keys = %#v", keys)
}
if keys := sortedStringKeys(map[string]string{"b": "1", "a": "2"}); strings.Join(keys, ",") != "a,b" {
t.Fatalf("sorted string keys = %#v", keys)
}
normalized := normalizeRawError(" HTTPS://example.test/x?q=secret 123456 2026-07-13T01:02:03Z abcdefghijklmnopqrstuvwxyz ")
if strings.Contains(normalized, "secret") || strings.Contains(normalized, "123456") {
t.Fatalf("normalizeRawError = %q", normalized)
}
ctx := BuildContext(CaptureInput{ToolName: "get_x"})
if ctx.OperationKind != OperationRead || ctx.RawError != "" {
t.Fatalf("BuildContext defaults = %#v", ctx)
}
replay := BuildReplay(CaptureInput{ToolName: "create_x"})
if replay.OperationKind != OperationWrite || replay.RedactedCommand != "" {
t.Fatalf("BuildReplay defaults = %#v", replay)
}
}
func TestCrossPlatformCoveragePlannerRuleAndSearchEdges(t *testing.T) {
rules := []struct {
name string
rc RecoveryContext
category string
retry bool
}{
{"input", RecoveryContext{CLIErrorCode: cliInvalidJSONCode}, "input", false},
{"resource suffix", RecoveryContext{CLIErrorCode: "THING_NOT_FOUND"}, "resource", false},
{"permission", RecoveryContext{HTTPStatus: 403}, "permission", false},
{"rate read", RecoveryContext{HTTPStatus: 429, OperationKind: OperationRead}, "rate_limit", true},
{"rate write http", RecoveryContext{HTTPStatus: 429, OperationKind: OperationWrite, CallStage: "http"}, "rate_limit", true},
{"rate write non-http", RecoveryContext{HTTPStatus: 429, OperationKind: OperationWrite, CallStage: "decode"}, "rate_limit", false},
{"network read", RecoveryContext{HTTPStatus: 503, OperationKind: OperationRead}, "network", true},
{"network write", RecoveryContext{RawError: "timeout", OperationKind: OperationWrite, CallStage: "http"}, "network", false},
}
for _, tt := range rules {
t.Run(tt.name, func(t *testing.T) {
plan := NewPlanner(nil).PlanWithOptions(context.Background(), tt.rc, PlanOptions{EnableDocSearch: false})
if plan.Category != tt.category || plan.ShouldRetry != tt.retry {
t.Fatalf("plan = %#v", plan)
}
})
}
if safeToRetry(RecoveryContext{OperationKind: OperationUnknown}, "network") {
t.Fatal("unknown operation should not retry")
}
if normalizeDecisionOwner(RecoveryPlan{DecisionOwner: DecisionOwnerAgent}) != DecisionOwnerAgent ||
normalizeDecisionOwner(RecoveryPlan{Category: "unknown"}) != DecisionOwnerAgent ||
normalizeDecisionOwner(RecoveryPlan{Category: "auth"}) != DecisionOwnerBuiltinRule {
t.Fatal("decision owner normalization failed")
}
queryRC := RecoveryContext{CLIErrorCode: "CODE", ToolName: "get_x", CommandPath: []string{"product", "get"}, RawError: "Error alpha alpha beta 123456"}
if query := BuildFallbackQuery(queryRC); !strings.Contains(query, "CODE") || !strings.Contains(query, "alpha") {
t.Fatalf("fallback query = %q", query)
}
if got := stableKeywords(""); got != nil {
t.Fatalf("empty stable keywords = %#v", got)
}
many := stableKeywords("one two three four five six seven eight")
if len(many) != 6 {
t.Fatalf("stable keyword cap = %#v", many)
}
if containsAny("Hello WORLD", "none", "world") != true || containsAny("hello", "x") {
t.Fatal("containsAny failed")
}
if got := normalizeSafeActions([]string{"wait_and_retry", "unsafe", "wait_and_retry"}); len(got) != 1 {
t.Fatalf("safe actions = %#v", got)
}
hit := KBHit{
Source: "docs", Title: "errors", URL: "https://docs",
Snippet: "| httpCode | code | message | reason | action |\n| --- | --- | --- | --- | --- |\n| 403 | Forbidden | denied | permission | Request access |\n| 404 | Missing | gone | deleted | %s |\n| bad | short | row |",
}
retrieval := KnowledgeRetrieval{
KBHits: []KBHit{hit, hit},
DocSearch: DocSearch{
Request: &ToolCallRecord{ServerID: "docs", ToolName: "search", Arguments: map[string]any{"q": "x"}},
Response: &ToolResponse{Content: []ToolResponseBlock{{Type: "text", Text: "ok"}}},
Items: []DocSearchItem{{Title: "one"}},
},
}
plan := NewPlanner(fakeRetriever{retrieval: retrieval}).Plan(context.Background(), RecoveryContext{RawError: "unknown issue"})
if plan.DocSearch.Status != "success" || len(plan.KBHits) != 1 || len(plan.DocActions) != 1 {
t.Fatalf("retrieved plan = %#v", plan)
}
if !plan.DecisionHints.PermissionSensitive || !plan.DecisionHints.ResourceStateRelated {
t.Fatalf("decision hints = %#v", plan.DecisionHints)
}
emptyPlan := NewPlanner(fakeRetriever{}).Plan(context.Background(), RecoveryContext{RawError: "unknown"})
if emptyPlan.DocSearch.Status != "empty" {
t.Fatalf("empty search = %#v", emptyPlan.DocSearch)
}
errPlan := NewPlanner(fakeRetriever{err: errors.New("docs down")}).Plan(context.Background(), RecoveryContext{RawError: "unknown"})
if errPlan.DocSearch.Status != "error" || errPlan.DocSearch.Error != "docs down" {
t.Fatalf("error search = %#v", errPlan.DocSearch)
}
customErr := NewPlanner(fakeRetriever{
retrieval: KnowledgeRetrieval{DocSearch: DocSearch{Status: "error", Error: "custom"}},
err: errors.New("docs down"),
}).Plan(context.Background(), RecoveryContext{RawError: "unknown"})
if customErr.DocSearch.Error != "custom" {
t.Fatalf("custom search error = %#v", customErr.DocSearch)
}
emptyErr := NewPlanner(fakeRetriever{
retrieval: KnowledgeRetrieval{DocSearch: DocSearch{Status: "error"}},
}).Plan(context.Background(), RecoveryContext{RawError: "unknown"})
if emptyErr.DocSearch.Status != "error" {
t.Fatalf("empty search error = %#v", emptyErr.DocSearch)
}
skipped := NewPlanner(nil).Plan(context.Background(), RecoveryContext{RawError: "unknown"})
if skipped.DocSearch.Status != "skipped" {
t.Fatalf("nil retriever = %#v", skipped.DocSearch)
}
var nilPlanner *Planner
if got := nilPlanner.searchKnowledge(context.Background(), "query", RecoveryContext{}); got.DocSearch.Status != "skipped" {
t.Fatalf("nil planner search = %#v", got)
}
if got := NewPlanner(nil).searchKnowledge(context.Background(), "", RecoveryContext{}); got.DocSearch.Status != "skipped" {
t.Fatalf("empty query search = %#v", got)
}
actions := extractDocActions([]KBHit{{Snippet: ""}, hit})
if len(actions) != 1 || len(splitTableRow("| a | b |")) != 2 {
t.Fatalf("doc actions = %#v", actions)
}
if len(dedupeDocActions(append(actions, actions...))) != 1 ||
len(dedupeKBHits([]KBHit{hit, hit})) != 1 {
t.Fatal("dedupe failed")
}
if cloneDocActions(nil) != nil || cloneKBHits(nil) != nil {
t.Fatal("nil clone should remain nil")
}
if got := cloneDocSearch(retrieval.DocSearch); got.Request == retrieval.DocSearch.Request || got.Response == retrieval.DocSearch.Response {
t.Fatal("doc search clone retained pointers")
}
if got := stableKeywords("a ok https://example.test/path?q=x valid"); len(got) != 1 || got[0] != "valid" {
t.Fatalf("placeholder/small keyword filtering = %#v", got)
}
if got := uniqueStrings([]string{" ", "one", "one"}); len(got) != 1 {
t.Fatalf("unique strings = %#v", got)
}
if got := extractDocActions([]KBHit{{Snippet: "ordinary text\n| 1 | 2 | 3 | 4 | act |"}}); len(got) != 1 {
t.Fatalf("non-table doc line handling = %#v", got)
}
var nilPlan *RecoveryPlan
HydratePlanForEvent("evt", RecoveryContext{}, Replay{}, nilPlan)
}
type fakeRecoveryInvoker struct {
result *transport.ToolCallResult
err error
}
func (f fakeRecoveryInvoker) CallToolDirect(context.Context, string, string, map[string]any) (*transport.ToolCallResult, error) {
return f.result, f.err
}
func TestCrossPlatformCoverageExecutorAndProbeEdges(t *testing.T) {
last := LastError{
EventID: "evt-1",
Context: RecoveryContext{
CommandPath: []string{"aitable", "base", "get"},
ServerID: "aitable",
ToolName: "get_base",
OperationKind: OperationRead,
ArgsSummary: map[string]any{"baseId": "base_1"},
RawError: "unknown",
},
Replay: Replay{
ServerID: "aitable",
ToolName: "get_base",
OperationKind: OperationRead,
ToolArgs: map[string]any{"baseId": "base_1"},
RedactedCommand: "dws aitable base get",
},
}
if got := (*Executor)(nil).Execute(context.Background(), last); got.Status != "analysis_failed" {
t.Fatalf("nil executor = %#v", got)
}
if got := (&Executor{}).Execute(context.Background(), last); got.Status != "analysis_failed" {
t.Fatalf("nil planner executor = %#v", got)
}
result := &transport.ToolCallResult{Blocks: []transport.ContentBlock{{Type: "text", Text: "{\"bases\":[1]}"}}}
executor := NewExecutor(NewPlanner(fakeRetriever{}), fakeRecoveryInvoker{result: result})
bundle := executor.Execute(context.Background(), last)
if bundle.Status != "needs_agent_action" || len(bundle.ProbeResults) != 2 ||
bundle.Plan.AgentRoute.Payload == nil || len(bundle.Plan.AgentRoute.Payload.ProbeResults) != 2 {
t.Fatalf("successful bundle = %#v", bundle)
}
if bundle.FinalizeHint.Command == "" || len(bundle.AgentTask.MustReadRefs) != 4 {
t.Fatalf("agent handoff metadata = %#v %#v", bundle.FinalizeHint, bundle.AgentTask)
}
failing := NewExecutor(NewPlanner(fakeRetriever{err: errors.New("docs")}), fakeRecoveryInvoker{err: errors.New("probe")})
failedBundle := failing.Execute(context.Background(), LastError{EventID: "evt-2", Context: last.Context})
if failedBundle.Status != "analysis_failed" ||
!strings.Contains(failedBundle.AnalysisError, "replay data missing") ||
!strings.Contains(failedBundle.AnalysisError, "doc search failed") {
t.Fatalf("failed bundle = %#v", failedBundle)
}
executor.probes = nil
if got := executor.runProbes(context.Background(), last.Context, last.Replay, RecoveryPlan{}); got != nil {
t.Fatalf("empty probes = %#v", got)
}
executor.probes = []Probe{
func(context.Context, ToolInvoker, RecoveryContext, Replay, RecoveryPlan) *ProbeResult { return nil },
func(context.Context, ToolInvoker, RecoveryContext, Replay, RecoveryPlan) *ProbeResult {
return &ProbeResult{Name: "custom", Status: "success"}
},
}
if got := executor.runProbes(context.Background(), last.Context, last.Replay, RecoveryPlan{}); len(got) != 1 {
t.Fatalf("custom probes = %#v", got)
}
if got := probeUnknownContextAudit(context.Background(), nil, RecoveryContext{}, Replay{}, RecoveryPlan{Category: "auth"}); got != nil {
t.Fatalf("built-in plan audit = %#v", got)
}
audit := probeUnknownContextAudit(context.Background(), nil, RecoveryContext{}, Replay{}, RecoveryPlan{Category: "unknown"})
if audit == nil || !strings.Contains(audit.Summary, "no identifier") {
t.Fatalf("empty audit = %#v", audit)
}
fullAudit := probeUnknownContextAudit(
context.Background(),
nil,
RecoveryContext{
CommandPath: []string{"x"},
ArgsSummary: map[string]any{"empty_id": "", "name": "n", "record_id": "r1"},
},
Replay{ToolArgs: map[string]any{"uuid": "u1"}, RedactedCommand: "dws x"},
RecoveryPlan{
Category: "unknown",
DocSearch: DocSearch{Status: "success"},
KBHits: []KBHit{{Title: "hit"}},
DocActions: []DocAction{{Action: "act"}},
},
)
if fullAudit == nil || !strings.Contains(fullAudit.Summary, "found 2") {
t.Fatalf("full audit = %#v", fullAudit)
}
if got := probeAITableBaseCatalog(context.Background(), nil, RecoveryContext{}, Replay{}, RecoveryPlan{}); got != nil {
t.Fatalf("unrelated probe = %#v", got)
}
if got := probeAITableBaseCatalog(context.Background(), nil, RecoveryContext{CommandPath: []string{"aitable", "base", "list"}}, Replay{}, RecoveryPlan{}); got.Status != "skipped" {
t.Fatalf("unsupported aitable probe = %#v", got)
}
if got := probeAITableBaseCatalog(context.Background(), nil, last.Context, last.Replay, RecoveryPlan{}); got.Status != "skipped" {
t.Fatalf("nil invoker probe = %#v", got)
}
outputs := []struct {
result *transport.ToolCallResult
summary string
}{
{nil, "no output"},
{&transport.ToolCallResult{Blocks: []transport.ContentBlock{{Type: "image"}, {Type: "text", Text: " plain "}}}, "text payload"},
{&transport.ToolCallResult{Content: map[string]any{"x": 1}}, "content payload"},
{&transport.ToolCallResult{StructuredContent: map[string]any{"x": 1}}, "non-text payload"},
}
for _, tt := range outputs {
_, summary := summarizeProbeOutput(tt.result)
if !strings.Contains(summary, tt.summary) {
t.Errorf("summarizeProbeOutput = %q, want %q", summary, tt.summary)
}
}
if len(buildMustReadRefs(nil)) != 3 || productReferenceFor("unknown") != "" {
t.Fatal("must-read references failed")
}
for _, product := range []string{"aitable", "attendance", "calendar", "chat", "contact", "devdoc", "ding", "report", "todo", "workbench", "approval"} {
if productReferenceFor(product) == "" {
t.Errorf("missing product reference for %q", product)
}
}
if cloneProbeResults(nil) != nil {
t.Fatal("nil probe clone should remain nil")
}
}
func TestCrossPlatformCoverageStoreRuntimeAndPruningEdges(t *testing.T) {
ResetRuntimeState()
if LatestCapture() != nil {
t.Fatal("latest capture should start nil")
}
setLatestCapture(nil)
last := &LastError{
EventID: "evt",
Context: RecoveryContext{
CommandPath: []string{"x"},
ArgsSummary: map[string]any{"nested": map[string]any{"x": 1}},
},
Replay: Replay{
ToolArgs: map[string]any{"slice": []any{map[string]any{"x": 1}}, "strings": []string{"x"}},
RedactedArgv: []string{"dws"},
},
}
setLatestCapture(last)
got := LatestCapture()
if got == last || got.Context.ArgsSummary == nil || got.Replay.ToolArgs == nil {
t.Fatalf("LatestCapture clone = %#v", got)
}
ResetRuntimeState()
if cloneMap(nil) != nil || cloneSlice(nil) != nil {
t.Fatal("nil clones should remain nil")
}
if cloneValue(7) != 7 {
t.Fatal("scalar clone changed")
}
if !isEmptyReplay(Replay{}) || isEmptyReplay(Replay{ToolName: "x"}) {
t.Fatal("empty replay detection failed")
}
var disabled *Store
if disabled.Enabled() || NewStore("").Enabled() {
t.Fatal("disabled store reported enabled")
}
if captured, err := disabled.Capture(RecoveryContext{}); err != nil || captured != nil {
t.Fatalf("disabled capture = %#v, %v", captured, err)
}
for name, call := range map[string]func() error{
"load": func() error { _, err := disabled.LoadLastError(); return err },
"event": func() error { _, err := disabled.LoadErrorByEvent("evt"); return err },
"ensure": disabled.ensureDir,
} {
if err := call(); err == nil {
t.Errorf("disabled %s unexpectedly succeeded", name)
}
}
if err := disabled.SavePlan("evt", RecoveryPlan{}); err != nil {
t.Fatal(err)
}
if err := disabled.SaveAnalysis("evt", RecoveryPlan{}, RecoveryBundle{}); err != nil {
t.Fatal(err)
}
if err := disabled.Finalize("evt", "failed", nil); err != nil {
t.Fatal(err)
}
if err := disabled.pruneExpiredArtifacts(time.Now()); err != nil {
t.Fatal(err)
}
dir := t.TempDir()
store := NewStore(dir)
if _, err := store.LoadErrorByEvent(""); err == nil {
t.Fatal("empty event id succeeded")
}
if _, err := store.LoadErrorByEvent("missing"); err == nil {
t.Fatal("missing event id succeeded")
}
if _, err := store.LoadLastError(); err == nil {
t.Fatal("missing last error succeeded")
}
badLast := store.lastErrorPath()
if err := os.MkdirAll(store.dir(), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(badLast, []byte("{"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := store.LoadLastError(); err == nil {
t.Fatal("malformed last error succeeded")
}
cutoff := time.Now().UTC().Add(-recoveryMaxAge)
old := cutoff.Add(-time.Hour).Format(time.RFC3339Nano)
recent := cutoff.Add(time.Hour).Format(time.RFC3339Nano)
if _, ok := parseRecordedAt(""); ok {
t.Fatal("empty timestamp parsed")
}
if _, ok := parseRecordedAt("invalid"); ok {
t.Fatal("invalid timestamp parsed")
}
if _, ok := parseRecordedAt(time.Now().UTC().Format(time.RFC3339)); !ok {
t.Fatal("RFC3339 timestamp did not parse")
}
if err := os.WriteFile(badLast, []byte("{\"recorded_at\":\""+old+"\"}"), 0o600); err != nil {
t.Fatal(err)
}
if err := store.pruneLastError(cutoff); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(badLast); !os.IsNotExist(err) {
t.Fatalf("old last error remains: %v", err)
}
if err := os.WriteFile(badLast, []byte("{\"recorded_at\":\""+recent+"\"}"), 0o600); err != nil {
t.Fatal(err)
}
if err := store.pruneLastError(cutoff); err != nil {
t.Fatal(err)
}
events := strings.Join([]string{
"",
"{",
"{\"event_id\":\"old\",\"recorded_at\":\"" + old + "\"}",
"{\"event_id\":\"recent\",\"recorded_at\":\"" + recent + "\"}",
}, "\n") + "\n"
if err := os.WriteFile(store.eventsPath(), []byte(events), 0o600); err != nil {
t.Fatal(err)
}
if err := store.pruneEvents(cutoff); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(store.eventsPath())
if err != nil || strings.Contains(string(data), "old") || !strings.Contains(string(data), "recent") {
t.Fatalf("pruned events = %q, %v", data, err)
}
if err := os.WriteFile(store.eventsPath(), []byte("{\"event_id\":\"old\",\"recorded_at\":\""+old+"\"}\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := store.pruneEvents(cutoff); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(store.eventsPath()); !os.IsNotExist(err) {
t.Fatalf("empty events file remains: %v", err)
}
oldArtifact := filepath.Join(store.dir(), "old-artifact")
recentArtifact := filepath.Join(store.dir(), "recent-artifact")
if err := os.MkdirAll(oldArtifact, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(recentArtifact, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
oldTime := cutoff.Add(-time.Hour)
if err := os.Chtimes(oldArtifact, oldTime, oldTime); err != nil {
t.Fatal(err)
}
if err := store.pruneOtherArtifacts(cutoff); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(oldArtifact); !os.IsNotExist(err) {
t.Fatalf("old artifact remains: %v", err)
}
if _, err := os.Stat(recentArtifact); err != nil {
t.Fatalf("recent artifact removed: %v", err)
}
emptyReplay, err := store.Capture(RecoveryContext{RawError: "x"})
if err != nil || emptyReplay == nil {
t.Fatalf("empty replay capture = %#v, %v", emptyReplay, err)
}
if _, err := store.LoadErrorByEvent(emptyReplay.EventID); err != nil {
t.Fatal(err)
}
}
func TestCrossPlatformCoverageStoreMarshalErrors(t *testing.T) {
store := NewStore(t.TempDir())
if err := store.writeJSON(filepath.Join(store.dir(), "bad.json"), map[string]any{"bad": func() {}}); err == nil {
t.Fatal("expected writeJSON marshal error")
}
event := RecoveryEvent{
EventID: "evt",
Bundle: &RecoveryBundle{
ProbeResults: []ProbeResult{{Output: func() {}}},
},
}
if err := store.appendEvent(event); err == nil {
t.Fatal("expected appendEvent marshal error")
}
}
func TestCrossPlatformCoverageLoadErrorByEventFallback(t *testing.T) {
store := NewStore(t.TempDir())
last := LastError{EventID: "fallback", RecordedAt: time.Now().UTC().Format(time.RFC3339Nano)}
data, err := json.Marshal(last)
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(store.dir(), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(store.lastErrorPath(), data, 0o600); err != nil {
t.Fatal(err)
}
if got, err := store.LoadErrorByEvent("fallback"); err != nil || got.EventID != "fallback" {
t.Fatalf("fallback event = %#v, %v", got, err)
}
otherEvent := RecoveryEvent{
EventID: "other",
Phase: "planned",
RecordedAt: time.Now().UTC().Format(time.RFC3339Nano),
}
eventData, err := json.Marshal(otherEvent)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(store.eventsPath(), append(eventData, '\n'), 0o600); err != nil {
t.Fatal(err)
}
if got, err := store.LoadErrorByEvent("fallback"); err != nil || got.EventID != "fallback" {
t.Fatalf("EOF fallback event = %#v, %v", got, err)
}
}
func TestCrossPlatformCoverageStoreSystemCallEdges(t *testing.T) {
oldReadFile := recoveryReadFile
oldWriteFile := recoveryWriteFile
oldOpen := recoveryOpen
oldOpenFile := recoveryOpenFile
oldMkdirAll := recoveryMkdirAll
oldReadDir := recoveryReadDir
oldStat := recoveryStat
oldRemove := recoveryRemove
oldRemoveAll := recoveryRemoveAll
oldFileWrite := recoveryFileWrite
t.Cleanup(func() {
recoveryReadFile = oldReadFile
recoveryWriteFile = oldWriteFile
recoveryOpen = oldOpen
recoveryOpenFile = oldOpenFile
recoveryMkdirAll = oldMkdirAll
recoveryReadDir = oldReadDir
recoveryStat = oldStat
recoveryRemove = oldRemove
recoveryRemoveAll = oldRemoveAll
recoveryFileWrite = oldFileWrite
})
reset := func() {
recoveryReadFile = oldReadFile
recoveryWriteFile = oldWriteFile
recoveryOpen = oldOpen
recoveryOpenFile = oldOpenFile
recoveryMkdirAll = oldMkdirAll
recoveryReadDir = oldReadDir
recoveryStat = oldStat
recoveryRemove = oldRemove
recoveryRemoveAll = oldRemoveAll
recoveryFileWrite = oldFileWrite
}
store := NewStore(t.TempDir())
recoveryWriteFile = func(string, []byte, os.FileMode) error { return errors.New("write") }
if _, err := store.Capture(RecoveryContext{}); err == nil {
t.Fatal("expected capture snapshot write error")
}
reset()
recoveryOpenFile = func(string, int, os.FileMode) (*os.File, error) { return nil, errors.New("open event") }
if _, err := store.Capture(RecoveryContext{}); err == nil {
t.Fatal("expected capture event append error")
}
reset()
recoveryReadFile = func(string) ([]byte, error) { return nil, errors.New("prune") }
if _, err := store.LoadLastError(); err == nil {
t.Fatal("expected LoadLastError prune error")
}
if _, err := store.LoadErrorByEvent("evt"); err == nil {
t.Fatal("expected LoadErrorByEvent prune error")
}
if err := store.pruneExpiredArtifacts(time.Now()); err == nil {
t.Fatal("expected last-error prune failure")
}
reset()
recoveryReadFile = func(path string) ([]byte, error) {
if path == store.lastErrorPath() {
return nil, os.ErrNotExist
}
if path == store.eventsPath() {
return nil, errors.New("events")
}
return oldReadFile(path)
}
if err := store.pruneExpiredArtifacts(time.Now()); err == nil {
t.Fatal("expected event prune failure")
}
reset()
recoveryOpen = func(string) (*os.File, error) { return nil, errors.New("open") }
if _, err := store.LoadErrorByEvent("evt"); err == nil {
t.Fatal("expected event open error")
}
reset()
if err := os.MkdirAll(store.dir(), 0o755); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(store.eventsPath(), 0o755); err != nil {
t.Fatal(err)
}
recoveryReadFile = func(path string) ([]byte, error) {
if path == store.lastErrorPath() || path == store.eventsPath() {
return nil, os.ErrNotExist
}
return oldReadFile(path)
}
if _, err := store.LoadErrorByEvent("evt"); err == nil || os.IsNotExist(err) {
t.Fatalf("expected non-EOF reader error, got %v", err)
}
if err := os.RemoveAll(store.eventsPath()); err != nil {
t.Fatal(err)
}
reset()
recoveryMkdirAll = func(string, os.FileMode) error { return errors.New("mkdir") }
if err := store.writeJSON(store.lastErrorPath(), map[string]any{"x": 1}); err == nil {
t.Fatal("expected writeJSON ensure error")
}
if err := store.appendEvent(RecoveryEvent{}); err == nil {
t.Fatal("expected appendEvent ensure error")
}
if err := store.ensureDir(); err == nil {
t.Fatal("expected ensureDir mkdir error")
}
reset()
recoveryOpenFile = func(string, int, os.FileMode) (*os.File, error) { return nil, errors.New("open file") }
if err := store.appendEvent(RecoveryEvent{}); err == nil {
t.Fatal("expected appendEvent open error")
}
reset()
recoveryFileWrite = func(*os.File, []byte) (int, error) { return 0, errors.New("write event") }
if err := store.appendEvent(RecoveryEvent{}); err == nil {
t.Fatal("expected appendEvent write error")
}
reset()
cutoff := time.Now().UTC().Add(-recoveryMaxAge)
if err := os.MkdirAll(store.dir(), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(store.lastErrorPath(), []byte("{"), 0o600); err != nil {
t.Fatal(err)
}
oldTime := cutoff.Add(-time.Hour)
if err := os.Chtimes(store.lastErrorPath(), oldTime, oldTime); err != nil {
t.Fatal(err)
}
if err := store.pruneLastError(cutoff); err != nil {
t.Fatal(err)
}
reset()
if err := os.WriteFile(store.lastErrorPath(), []byte("{"), 0o600); err != nil {
t.Fatal(err)
}
recoveryStat = func(string) (os.FileInfo, error) { return nil, os.ErrNotExist }
if err := store.pruneLastError(cutoff); err != nil {
t.Fatal(err)
}
recoveryStat = func(string) (os.FileInfo, error) { return nil, errors.New("stat") }
if err := store.pruneLastError(cutoff); err == nil {
t.Fatal("expected last-error stat error")
}
reset()
recoveryReadDir = func(string) ([]os.DirEntry, error) { return nil, errors.New("read dir") }
if err := store.pruneOtherArtifacts(cutoff); err == nil {
t.Fatal("expected artifact read error")
}
reset()
artifact := filepath.Join(store.dir(), "artifact")
if err := os.WriteFile(artifact, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
recoveryStat = func(path string) (os.FileInfo, error) {
if path == artifact {
return nil, os.ErrNotExist
}
return oldStat(path)
}
if err := store.pruneOtherArtifacts(cutoff); err != nil {
t.Fatal(err)
}
recoveryStat = func(path string) (os.FileInfo, error) {
if path == artifact {
return nil, errors.New("stat")
}
return oldStat(path)
}
if err := store.pruneOtherArtifacts(cutoff); err == nil {
t.Fatal("expected artifact stat error")
}
reset()
if err := os.Chtimes(artifact, oldTime, oldTime); err != nil {
t.Fatal(err)
}
recoveryRemoveAll = func(string) error { return errors.New("remove") }
if err := store.pruneOtherArtifacts(cutoff); err == nil {
t.Fatal("expected artifact removal error")
}
}
+338
View File
@@ -0,0 +1,338 @@
package recovery
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
)
type ToolInvoker interface {
CallToolDirect(ctx context.Context, serverID, toolName string, args map[string]any) (*transport.ToolCallResult, error)
}
type Probe func(ctx context.Context, invoker ToolInvoker, rc RecoveryContext, replay Replay, plan RecoveryPlan) *ProbeResult
type Executor struct {
planner *Planner
invoker ToolInvoker
probes []Probe
}
func NewExecutor(planner *Planner, invoker ToolInvoker) *Executor {
return &Executor{
planner: planner,
invoker: invoker,
probes: defaultProbes(),
}
}
func (e *Executor) Execute(ctx context.Context, last LastError) RecoveryBundle {
bundle := RecoveryBundle{
Status: "needs_agent_action",
EventID: last.EventID,
Context: cloneRecoveryContext(last.Context),
Replay: cloneReplay(last.Replay),
}
if e == nil || e.planner == nil {
bundle.Status = "analysis_failed"
bundle.AnalysisError = "recovery executor unavailable"
return bundle
}
plan := e.planner.PlanWithOptions(ctx, last.Context, PlanOptions{
EventID: last.EventID,
EnableDocSearch: true,
})
HydratePlanForEvent(last.EventID, last.Context, last.Replay, &plan)
bundle.Plan = plan
bundle.DocSearch = cloneDocSearch(plan.DocSearch)
bundle.KBHits = cloneKBHits(plan.KBHits)
bundle.DocActions = cloneDocActions(plan.DocActions)
bundle.HumanActions = nonNilStrings(plan.HumanActions)
bundle.ProbeResults = e.runProbes(ctx, last.Context, last.Replay, plan)
if bundle.Plan.AgentRoute.Payload != nil {
bundle.Plan.AgentRoute.Payload.ProbeResults = cloneProbeResults(bundle.ProbeResults)
}
bundle.AgentTask = buildAgentTask(last.EventID, last.Context, last.Replay, plan, bundle.ProbeResults)
bundle.FinalizeHint = buildFinalizeHint(last.EventID)
analysisErrors := make([]string, 0, 3)
if isEmptyReplay(last.Replay) {
analysisErrors = append(analysisErrors, "replay data missing")
}
if plan.DocSearch.Status == "error" {
analysisErrors = append(analysisErrors, "doc search failed")
}
for _, probe := range bundle.ProbeResults {
if probe.Status == "error" {
analysisErrors = append(analysisErrors, fmt.Sprintf("probe %s failed", probe.Name))
}
}
if len(analysisErrors) > 0 {
bundle.Status = "analysis_failed"
bundle.AnalysisError = strings.Join(uniqueStrings(analysisErrors), "; ")
}
return bundle
}
func (e *Executor) runProbes(ctx context.Context, rc RecoveryContext, replay Replay, plan RecoveryPlan) []ProbeResult {
if len(e.probes) == 0 {
return nil
}
results := make([]ProbeResult, 0, len(e.probes))
for _, probe := range e.probes {
result := probe(ctx, e.invoker, rc, replay, plan)
if result == nil {
continue
}
results = append(results, *result)
}
return results
}
func defaultProbes() []Probe {
return []Probe{probeUnknownContextAudit, probeAITableBaseCatalog}
}
func probeUnknownContextAudit(ctx context.Context, invoker ToolInvoker, rc RecoveryContext, replay Replay, plan RecoveryPlan) *ProbeResult {
if normalizeDecisionOwner(plan) != DecisionOwnerAgent {
return nil
}
sourcesChecked := []string{
"recovery_context.command_path",
"recovery_context.args_summary",
"replay.tool_args",
"replay.redacted_command",
"plan.doc_search",
"plan.kb_hits",
"plan.doc_actions",
}
availableSources := make([]string, 0, len(sourcesChecked))
missingSources := make([]string, 0, len(sourcesChecked))
candidateIdentifiers := make([]map[string]any, 0)
appendCandidates := func(source string, values map[string]any) {
for key, value := range values {
text := strings.TrimSpace(fmt.Sprint(value))
if text == "" {
continue
}
lowerKey := strings.ToLower(key)
if !strings.Contains(lowerKey, "id") && !strings.Contains(lowerKey, "uuid") {
continue
}
candidateIdentifiers = append(candidateIdentifiers, map[string]any{
"source": source,
"field": key,
"value": text,
})
}
}
appendCandidates("context.args_summary", rc.ArgsSummary)
appendCandidates("replay.tool_args", replay.ToolArgs)
if len(rc.CommandPath) > 0 {
availableSources = append(availableSources, "recovery_context.command_path")
} else {
missingSources = append(missingSources, "recovery_context.command_path")
}
if len(rc.ArgsSummary) > 0 {
availableSources = append(availableSources, "recovery_context.args_summary")
} else {
missingSources = append(missingSources, "recovery_context.args_summary")
}
if len(replay.ToolArgs) > 0 {
availableSources = append(availableSources, "replay.tool_args")
} else {
missingSources = append(missingSources, "replay.tool_args")
}
if strings.TrimSpace(replay.RedactedCommand) != "" {
availableSources = append(availableSources, "replay.redacted_command")
} else {
missingSources = append(missingSources, "replay.redacted_command")
}
if plan.DocSearch.Status != "" && plan.DocSearch.Status != "skipped" {
availableSources = append(availableSources, "plan.doc_search")
} else {
missingSources = append(missingSources, "plan.doc_search")
}
if len(plan.KBHits) > 0 {
availableSources = append(availableSources, "plan.kb_hits")
} else {
missingSources = append(missingSources, "plan.kb_hits")
}
if len(plan.DocActions) > 0 {
availableSources = append(availableSources, "plan.doc_actions")
} else {
missingSources = append(missingSources, "plan.doc_actions")
}
summary := fmt.Sprintf("audited %d local recovery source(s)", len(sourcesChecked))
if len(candidateIdentifiers) == 0 {
summary += "; no identifier candidates found"
} else {
summary += fmt.Sprintf("; found %d identifier candidate(s)", len(candidateIdentifiers))
}
return &ProbeResult{
Name: "unknown_context_audit",
Status: "success",
Summary: summary,
Output: map[string]any{
"sources_checked": sourcesChecked,
"available_sources": availableSources,
"missing_sources": missingSources,
"candidate_identifiers": candidateIdentifiers,
"decision_owner": string(normalizeDecisionOwner(plan)),
},
}
}
func probeAITableBaseCatalog(ctx context.Context, invoker ToolInvoker, rc RecoveryContext, replay Replay, plan RecoveryPlan) *ProbeResult {
if len(rc.CommandPath) < 2 || rc.CommandPath[0] != "aitable" || rc.CommandPath[1] != "base" {
return nil
}
if len(rc.CommandPath) < 3 || rc.CommandPath[2] != "get" {
return &ProbeResult{Name: "aitable_base_catalog_probe", Status: "skipped", Summary: "no registered read-only probe for this aitable command"}
}
if invoker == nil {
return &ProbeResult{Name: "aitable_base_catalog_probe", Status: "skipped", Summary: "tool invoker unavailable"}
}
result, err := invoker.CallToolDirect(ctx, replay.ServerID, "list_bases", map[string]any{"limit": 5})
if err != nil {
return &ProbeResult{
Name: "aitable_base_catalog_probe",
Status: "error",
ServerID: replay.ServerID,
ToolName: "list_bases",
Error: err.Error(),
Summary: "failed to run read-only aitable base catalog probe",
}
}
payload, summary := summarizeProbeOutput(result)
return &ProbeResult{
Name: "aitable_base_catalog_probe",
Status: "success",
ServerID: replay.ServerID,
ToolName: "list_bases",
ArgsSummary: SummarizeArgs(map[string]any{"limit": 5}),
Summary: summary,
Output: payload,
}
}
func summarizeProbeOutput(result *transport.ToolCallResult) (any, string) {
if result == nil {
return nil, "probe returned no output"
}
for _, block := range result.Blocks {
if block.Type != "text" || strings.TrimSpace(block.Text) == "" {
continue
}
var parsed any
if err := json.Unmarshal([]byte(block.Text), &parsed); err == nil {
return parsed, "probe returned JSON payload"
}
return block.Text, "probe returned text payload"
}
if len(result.Content) > 0 {
return result.Content, "probe returned content payload"
}
return result, "probe returned non-text payload"
}
func buildAgentTask(eventID string, rc RecoveryContext, replay Replay, plan RecoveryPlan, probes []ProbeResult) AgentTask {
whyParts := []string{fmt.Sprintf("recovery category=%s", plan.Category)}
if len(probes) > 0 {
whyParts = append(whyParts, fmt.Sprintf("probe results=%d", len(probes)))
}
if plan.DocSearch.Status != "" && plan.DocSearch.Status != "skipped" {
whyParts = append(whyParts, "doc search results included")
}
if replay.RedactedCommand != "" {
whyParts = append(whyParts, "failed command="+replay.RedactedCommand)
}
return AgentTask{
Goal: "阅读完整 RecoveryBundle,并基于 dws skill 对 unknown 恢复场景做整体分析后再决定是否发起 grounded 的下一次 dws 尝试",
Why: strings.Join(whyParts, "; "),
MustReadRefs: buildMustReadRefs(rc.CommandPath),
AllowedActions: []string{
"读取完整 recovery bundle、recovery-guide 和对应产品参考文档",
"基于 doc_actions、kb_hits、probe_results 与 replay/context 整体判断下一步",
"仅在修复依据明确时重新发起新的 dws 命令",
},
ForbiddenActions: []string{
"编造 ID、UUID、token、URL 或其他业务参数",
"绕过 dws 直接调用 HTTP API、curl 或浏览器",
"未确认前把失败命令替换成另一套业务流程",
},
StopConditions: []string{
"新的 dws 命令已经成功并可回写 recovered",
"没有 grounded 修复依据,只能回写 handoff",
"继续尝试会要求猜测或扩大副作用边界",
},
FinalizeRequirement: buildFinalizeHint(eventID).Command,
}
}
func buildFinalizeHint(eventID string) FinalizeHint {
return FinalizeHint{
Required: true,
Command: fmt.Sprintf(
"dws recovery finalize --event-id %s --outcome <recovered|failed|handoff> --execution-file <file.json> --format json",
eventID,
),
ExecutionFileFields: []string{"actions", "attempts", "result", "error_summary"},
AllowedOutcomes: []string{"recovered", "failed", "handoff"},
}
}
func buildMustReadRefs(commandPath []string) []string {
refs := []string{
"skills/references/recovery-guide.md",
"skills/references/error-codes.md",
"skills/references/global-reference.md",
}
if len(commandPath) > 0 {
if ref := productReferenceFor(commandPath[0]); ref != "" {
refs = append(refs, ref)
}
}
return refs
}
func productReferenceFor(commandRoot string) string {
refs := map[string]string{
"aitable": "skills/references/products/aitable.md",
"attendance": "skills/references/products/attendance.md",
"calendar": "skills/references/products/calendar.md",
"chat": "skills/references/products/chat.md",
"contact": "skills/references/products/contact.md",
"devdoc": "skills/references/products/simple.md",
"ding": "skills/references/products/ding.md",
"report": "skills/references/products/report.md",
"todo": "skills/references/products/todo.md",
"workbench": "skills/references/products/workbench.md",
"approval": "skills/references/products/simple.md",
}
return refs[commandRoot]
}
func cloneProbeResults(results []ProbeResult) []ProbeResult {
if len(results) == 0 {
return nil
}
out := make([]ProbeResult, len(results))
for i, result := range results {
out[i] = result
out[i].ArgsSummary = cloneMap(result.ArgsSummary)
}
return out
}
+679
View File
@@ -0,0 +1,679 @@
package recovery
import (
"crypto/sha1"
"encoding/hex"
stderrors "errors"
"fmt"
"regexp"
"sort"
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
)
type OperationKind string
const (
OperationRead OperationKind = "read"
OperationWrite OperationKind = "write"
OperationUnknown OperationKind = "unknown"
)
type DecisionOwner string
const (
DecisionOwnerBuiltinRule DecisionOwner = "builtin_rule"
DecisionOwnerAgent DecisionOwner = "agent"
)
type RecoveryContext struct {
CommandPath []string `json:"command_path"`
ServerID string `json:"server_id,omitempty"`
ToolName string `json:"tool_name,omitempty"`
OperationKind OperationKind `json:"operation_kind"`
CLIErrorCode string `json:"cli_error_code,omitempty"`
RawError string `json:"raw_error"`
CallStage string `json:"call_stage,omitempty"`
HTTPStatus int `json:"http_status,omitempty"`
RetryAfter string `json:"retry_after,omitempty"`
TraceID string `json:"trace_id,omitempty"`
RequestID string `json:"request_id,omitempty"`
ArgsSummary map[string]any `json:"args_summary,omitempty"`
Fingerprint string `json:"fingerprint"`
}
type Replay struct {
ServerID string `json:"server_id,omitempty"`
ToolName string `json:"tool_name,omitempty"`
OperationKind OperationKind `json:"operation_kind"`
ToolArgs map[string]any `json:"tool_args,omitempty"`
RedactedArgv []string `json:"redacted_argv,omitempty"`
RedactedCommand string `json:"redacted_command,omitempty"`
}
type RecoveryPlan struct {
Category string `json:"category"`
DecisionOwner DecisionOwner `json:"decision_owner,omitempty"`
Confidence float64 `json:"confidence"`
AutoActions []string `json:"auto_actions"`
SafeActions []string `json:"safe_actions"`
DocActions []DocAction `json:"doc_actions,omitempty"`
DocSearch DocSearch `json:"doc_search"`
DecisionHints DecisionHints `json:"decision_hints"`
HumanActions []string `json:"human_actions"`
Evidence []string `json:"evidence"`
KBHits []KBHit `json:"kb_hits"`
ShouldRetry bool `json:"should_retry"`
ShouldStop bool `json:"should_stop"`
RuleHints RuleHints `json:"rule_hints"`
AgentRoute AgentRoute `json:"agent_route"`
}
type KBHit struct {
Source string `json:"source"`
Title string `json:"title"`
URL string `json:"url,omitempty"`
Snippet string `json:"snippet,omitempty"`
Score float64 `json:"score,omitempty"`
}
type DocAction struct {
Action string `json:"action"`
Reason string `json:"reason,omitempty"`
SourceTitle string `json:"source_title,omitempty"`
SourceURL string `json:"source_url,omitempty"`
}
type DocSearch struct {
Provider string `json:"provider,omitempty"`
Query string `json:"query,omitempty"`
Page int `json:"page,omitempty"`
Size int `json:"size,omitempty"`
CurrentPage int `json:"current_page,omitempty"`
TotalCount int `json:"total_count,omitempty"`
HasMore bool `json:"has_more"`
Status string `json:"status,omitempty"`
Error string `json:"error,omitempty"`
Request *ToolCallRecord `json:"request,omitempty"`
Response *ToolResponse `json:"response,omitempty"`
Items []DocSearchItem `json:"items,omitempty"`
}
type DocSearchItem struct {
Title string `json:"title"`
URL string `json:"url,omitempty"`
Desc string `json:"desc,omitempty"`
}
type ToolCallRecord struct {
ServerID string `json:"server_id,omitempty"`
ToolName string `json:"tool_name,omitempty"`
Arguments map[string]any `json:"arguments,omitempty"`
}
type ToolResponse struct {
IsError bool `json:"is_error,omitempty"`
Content []ToolResponseBlock `json:"content,omitempty"`
}
type ToolResponseBlock struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
}
type RuleHints struct {
Category string `json:"category"`
DecisionOwner DecisionOwner `json:"decision_owner,omitempty"`
Confidence float64 `json:"confidence"`
SafeActions []string `json:"safe_actions"`
HumanActions []string `json:"human_actions"`
Evidence []string `json:"evidence"`
ShouldRetry bool `json:"should_retry"`
ShouldStop bool `json:"should_stop"`
}
type DecisionHints struct {
Retryable bool `json:"retryable"`
PermissionSensitive bool `json:"permission_sensitive"`
AuthRelated bool `json:"auth_related"`
ResourceStateRelated bool `json:"resource_state_related"`
}
type AgentRoute struct {
Required bool `json:"required"`
Target string `json:"target,omitempty"`
Executor string `json:"executor,omitempty"`
Reasons []string `json:"reasons"`
Payload *AgentRoutePayload `json:"payload,omitempty"`
}
type AgentRoutePayload struct {
EventID string `json:"event_id"`
Context RecoveryContext `json:"context"`
Replay Replay `json:"replay"`
RawError string `json:"raw_error"`
Category string `json:"category"`
DecisionOwner DecisionOwner `json:"decision_owner,omitempty"`
Confidence float64 `json:"confidence"`
ShouldRetry bool `json:"should_retry"`
ShouldStop bool `json:"should_stop"`
SafeActions []string `json:"safe_actions"`
DocActions []DocAction `json:"doc_actions,omitempty"`
KBHits []KBHit `json:"kb_hits"`
DocSearch DocSearch `json:"doc_search"`
HumanActions []string `json:"human_actions"`
DecisionHints DecisionHints `json:"decision_hints"`
Evidence []string `json:"evidence"`
RuleHints RuleHints `json:"rule_hints"`
ProbeResults []ProbeResult `json:"probe_results,omitempty"`
}
type ProbeResult struct {
Name string `json:"name"`
Status string `json:"status"`
ServerID string `json:"server_id,omitempty"`
ToolName string `json:"tool_name,omitempty"`
ArgsSummary map[string]any `json:"args_summary,omitempty"`
Summary string `json:"summary,omitempty"`
Output any `json:"output,omitempty"`
Error string `json:"error,omitempty"`
}
type AgentTask struct {
Goal string `json:"goal"`
Why string `json:"why"`
MustReadRefs []string `json:"must_read_refs,omitempty"`
AllowedActions []string `json:"allowed_actions,omitempty"`
ForbiddenActions []string `json:"forbidden_actions,omitempty"`
StopConditions []string `json:"stop_conditions,omitempty"`
FinalizeRequirement string `json:"finalize_requirement,omitempty"`
}
type FinalizeHint struct {
Required bool `json:"required"`
Command string `json:"command,omitempty"`
ExecutionFileFields []string `json:"execution_file_fields,omitempty"`
AllowedOutcomes []string `json:"allowed_outcomes,omitempty"`
}
type RecoveryBundle struct {
Status string `json:"status"`
EventID string `json:"event_id"`
Context RecoveryContext `json:"context"`
Replay Replay `json:"replay"`
Plan RecoveryPlan `json:"plan"`
DocSearch DocSearch `json:"doc_search"`
KBHits []KBHit `json:"kb_hits,omitempty"`
DocActions []DocAction `json:"doc_actions,omitempty"`
HumanActions []string `json:"human_actions,omitempty"`
ProbeResults []ProbeResult `json:"probe_results,omitempty"`
AgentTask AgentTask `json:"agent_task"`
FinalizeHint FinalizeHint `json:"finalize_hint"`
AnalysisError string `json:"analysis_error,omitempty"`
}
type LastError struct {
EventID string `json:"event_id"`
RecordedAt string `json:"recorded_at"`
Context RecoveryContext `json:"context"`
Replay Replay `json:"replay,omitempty"`
}
type RecoveryEvent struct {
EventID string `json:"event_id"`
Phase string `json:"phase"`
RecordedAt string `json:"recorded_at"`
Context *RecoveryContext `json:"context,omitempty"`
Replay *Replay `json:"replay,omitempty"`
Plan *RecoveryPlan `json:"plan,omitempty"`
Bundle *RecoveryBundle `json:"bundle,omitempty"`
Execution *RecoveryExecution `json:"execution,omitempty"`
Outcome string `json:"outcome,omitempty"`
}
type RecoveryAttempt struct {
CommandSummary string `json:"command_summary,omitempty"`
Result string `json:"result,omitempty"`
ErrorSummary string `json:"error_summary,omitempty"`
Source string `json:"source,omitempty"`
}
type RecoveryExecution struct {
Actions []string `json:"actions,omitempty"`
Attempts []RecoveryAttempt `json:"attempts,omitempty"`
Result string `json:"result,omitempty"`
ErrorSummary string `json:"error_summary,omitempty"`
}
type CaptureInput struct {
CommandPath []string
ServerID string
ToolName string
OperationKind OperationKind
Args map[string]any
Argv []string
RawErr error
WrappedErr error
}
type KnowledgeRetrieval struct {
KBHits []KBHit `json:"kb_hits"`
DocSearch DocSearch `json:"doc_search"`
}
func BuildContext(input CaptureInput) RecoveryContext {
operationKind := input.OperationKind
if operationKind == "" || operationKind == OperationUnknown {
operationKind = InferOperationKind(input.ToolName)
}
rawError := ""
if input.RawErr != nil {
rawError = input.RawErr.Error()
}
ctx := RecoveryContext{
CommandPath: append([]string(nil), input.CommandPath...),
ServerID: input.ServerID,
ToolName: input.ToolName,
OperationKind: operationKind,
RawError: rawError,
ArgsSummary: SummarizeArgs(input.Args),
}
if code := canonicalCLIErrorCode(input.WrappedErr, rawError); code != "" {
ctx.CLIErrorCode = code
}
var callErr *transport.CallError
if stderrors.As(input.RawErr, &callErr) || stderrors.As(input.WrappedErr, &callErr) {
ctx.CallStage = string(callErr.Stage)
ctx.HTTPStatus = callErr.HTTPStatus
ctx.RetryAfter = callErr.RetryAfter
ctx.TraceID = callErr.TraceID
ctx.RequestID = callErr.RequestID
}
ctx.Fingerprint = ComputeFingerprint(ctx)
return ctx
}
func canonicalCLIErrorCode(err error, rawError string) string {
var typed *apperrors.Error
if stderrors.As(err, &typed) {
reason := strings.ToLower(strings.TrimSpace(typed.Reason))
message := strings.ToLower(strings.TrimSpace(typed.Message + " " + rawError))
switch typed.Category {
case apperrors.CategoryValidation:
if strings.Contains(message, "required") || strings.Contains(message, "missing") {
return cliMissingParamCode
}
return cliInvalidJSONCode
case apperrors.CategoryAuth:
if reason == "http_403" || strings.Contains(message, "forbidden") || strings.Contains(message, "permission") || strings.Contains(message, "无权限") {
return cliPermissionCode
}
return cliAuthExpiredCode
case apperrors.CategoryAPI, apperrors.CategoryDiscovery:
switch {
case reason == "http_429" || typed.Retryable && strings.Contains(message, "rate limit"):
return cliRateLimitCode
case strings.Contains(reason, "timeout") || strings.Contains(message, "timeout") || strings.Contains(message, "deadline exceeded") || strings.Contains(message, "connection refused") || strings.Contains(message, "connection reset"):
return cliTimeoutCode
case strings.Contains(reason, "invalid_params"):
return cliInvalidJSONCode
case strings.Contains(reason, "method_not_found"):
return cliResourceNotFoundCode
case strings.Contains(reason, "http_404"):
return cliResourceNotFoundCode
}
}
}
normalized := strings.ToLower(strings.TrimSpace(rawError))
switch {
case strings.Contains(normalized, "token验证失败") || strings.Contains(normalized, "user_token_illegal"):
return cliAuthExpiredCode
case strings.Contains(normalized, "forbidden") || strings.Contains(normalized, "permission") || strings.Contains(normalized, "无权限"):
return cliPermissionCode
case strings.Contains(normalized, "too many requests") || strings.Contains(normalized, "rate limit"):
return cliRateLimitCode
case strings.Contains(normalized, "timeout") || strings.Contains(normalized, "deadline exceeded") || strings.Contains(normalized, "connection refused") || strings.Contains(normalized, "connection reset"):
return cliTimeoutCode
case strings.Contains(normalized, "not found") || strings.Contains(normalized, "资源不存在") || strings.Contains(normalized, "base_not_found"):
return cliResourceNotFoundCode
}
return ""
}
func BuildReplay(input CaptureInput) Replay {
operationKind := input.OperationKind
if operationKind == "" || operationKind == OperationUnknown {
operationKind = InferOperationKind(input.ToolName)
}
replay := Replay{
ServerID: input.ServerID,
ToolName: input.ToolName,
OperationKind: operationKind,
ToolArgs: sanitizeReplayMap(input.Args),
}
if len(input.Argv) > 0 {
replay.RedactedArgv = sanitizeArgv(input.Argv)
replay.RedactedCommand = strings.Join(replay.RedactedArgv, " ")
}
return replay
}
func InferOperationKind(toolName string) OperationKind {
toolName = strings.ToLower(strings.TrimSpace(toolName))
if toolName == "" {
return OperationUnknown
}
readPrefixes := []string{"get_", "list_", "search_", "query_", "status_", "download_"}
for _, prefix := range readPrefixes {
if strings.HasPrefix(toolName, prefix) {
return OperationRead
}
}
writePrefixes := []string{
"create_", "update_", "delete_", "send_", "approve_", "reject_", "revoke_",
"upload_", "import_", "commit_", "add_", "remove_", "modify_", "set_",
"assign_", "insert_", "recall_", "batch_send_", "batch_recall_", "save_",
"generate", "edit", "upscale", "isolate",
}
for _, prefix := range writePrefixes {
if strings.HasPrefix(toolName, prefix) {
return OperationWrite
}
}
return OperationUnknown
}
func SummarizeArgs(args map[string]any) map[string]any {
if len(args) == 0 {
return nil
}
keys := make([]string, 0, len(args))
for key := range args {
keys = append(keys, key)
}
sort.Strings(keys)
summary := make(map[string]any, len(keys))
for _, key := range keys {
if isSensitiveKey(key) {
continue
}
summary[key] = summarizeValue(key, args[key])
}
return summary
}
func sanitizeReplayMap(args map[string]any) map[string]any {
if len(args) == 0 {
return nil
}
out := make(map[string]any, len(args))
for key, value := range args {
if isSensitiveKey(key) {
continue
}
out[key] = sanitizeReplayField(key, value)
}
return out
}
func sanitizeReplayField(key string, value any) any {
lowerKey := strings.ToLower(key)
if isContentKey(lowerKey) {
return summarizeValue(lowerKey, value)
}
switch v := value.(type) {
case string:
if len(v) > 120 {
return summarizeValue(lowerKey, v)
}
return v
case map[string]string:
return sanitizeReplayStringMap(v)
case []map[string]any:
out := make([]map[string]any, 0, len(v))
for _, item := range v {
out = append(out, sanitizeReplayMap(item))
}
return out
default:
return sanitizeReplayValue(v)
}
}
func sanitizeReplayValue(value any) any {
switch v := value.(type) {
case map[string]any:
return sanitizeReplayMap(v)
case map[string]string:
return sanitizeReplayStringMap(v)
case []any:
out := make([]any, 0, len(v))
for _, item := range v {
out = append(out, sanitizeReplayValue(item))
}
return out
case []string:
out := make([]string, len(v))
copy(out, v)
return out
case []map[string]any:
out := make([]map[string]any, 0, len(v))
for _, item := range v {
out = append(out, sanitizeReplayMap(item))
}
return out
default:
return v
}
}
func sanitizeReplayStringMap(args map[string]string) map[string]any {
if len(args) == 0 {
return nil
}
out := make(map[string]any, len(args))
for key, value := range args {
if isSensitiveKey(key) {
continue
}
out[key] = sanitizeReplayField(key, value)
}
return out
}
func sanitizeArgv(argv []string) []string {
if len(argv) == 0 {
return nil
}
sensitiveFlags := map[string]struct{}{
"--token": {},
"--auth-code": {},
"--refresh-token": {},
}
out := make([]string, 0, len(argv))
skipValue := false
for _, arg := range argv {
if skipValue {
out = append(out, "<redacted>")
skipValue = false
continue
}
if value, ok := redactSensitiveArg(arg); ok {
out = append(out, value)
continue
}
if _, ok := sensitiveFlags[arg]; ok {
out = append(out, arg)
skipValue = true
continue
}
out = append(out, arg)
}
return out
}
func redactSensitiveArg(arg string) (string, bool) {
for _, prefix := range []string{"--token=", "--auth-code=", "--refresh-token="} {
if strings.HasPrefix(arg, prefix) {
return prefix + "<redacted>", true
}
}
return "", false
}
func summarizeValue(key string, value any) any {
lowerKey := strings.ToLower(key)
switch v := value.(type) {
case string:
if isContentKey(lowerKey) || len(v) > 120 {
return map[string]any{"kind": "string", "length": float64(len(v))}
}
return v
case []string:
if isContentKey(lowerKey) {
return map[string]any{"kind": "array", "count": float64(len(v))}
}
out := make([]string, len(v))
copy(out, v)
return out
case []any:
return map[string]any{"kind": "array", "count": float64(len(v))}
case []map[string]any:
if isContentKey(lowerKey) {
return map[string]any{"kind": "array", "count": float64(len(v))}
}
out := make([]map[string]any, 0, len(v))
for _, item := range v {
nested := make(map[string]any, len(item))
for nestedKey, nestedValue := range item {
if isSensitiveKey(nestedKey) {
continue
}
nested[nestedKey] = summarizeValue(nestedKey, nestedValue)
}
out = append(out, nested)
}
return out
case map[string]any:
if isContentKey(lowerKey) {
return map[string]any{"kind": "object", "keys": sortedKeys(v)}
}
out := make(map[string]any, len(v))
for nestedKey, nestedValue := range v {
if isSensitiveKey(nestedKey) {
continue
}
out[nestedKey] = summarizeValue(nestedKey, nestedValue)
}
return out
case map[string]string:
if isContentKey(lowerKey) {
return map[string]any{"kind": "object", "keys": sortedStringKeys(v)}
}
out := make(map[string]any, len(v))
for nestedKey, nestedValue := range v {
if isSensitiveKey(nestedKey) {
continue
}
out[nestedKey] = summarizeValue(nestedKey, nestedValue)
}
return out
default:
return value
}
}
func ComputeFingerprint(ctx RecoveryContext) string {
base := strings.Join([]string{
ctx.ServerID,
ctx.ToolName,
ctx.CLIErrorCode,
fmt.Sprintf("%d", ctx.HTTPStatus),
normalizeRawError(ctx.RawError),
}, "|")
sum := sha1.Sum([]byte(base))
return hex.EncodeToString(sum[:])
}
var (
reURLQuery = regexp.MustCompile(`https?://[^\s?]+(?:\?[^\s]+)?`)
reUUID = regexp.MustCompile(`\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b`)
reLongDigits = regexp.MustCompile(`\b\d{6,}\b`)
reTimestamp = regexp.MustCompile(`\b\d{4}-\d{2}-\d{2}[t\s]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:z|[+-]\d{2}:?\d{2})?\b`)
reLongToken = regexp.MustCompile(`\b[a-z0-9_-]{18,}\b`)
reResourceID = regexp.MustCompile(`\b(?:base|tbl|fld|rec|conv|user|task)_[a-z0-9_-]+\b`)
reMultiWhitespace = regexp.MustCompile(`\s+`)
)
func normalizeRawError(raw string) string {
normalized := strings.ToLower(strings.TrimSpace(raw))
replacements := []struct {
re *regexp.Regexp
repl string
}{
{reURLQuery, "<url>"},
{reUUID, "<uuid>"},
{reTimestamp, "<ts>"},
{reResourceID, "<id>"},
{reLongDigits, "<num>"},
{reLongToken, "<token>"},
}
for _, item := range replacements {
normalized = item.re.ReplaceAllString(normalized, item.repl)
}
normalized = reMultiWhitespace.ReplaceAllString(normalized, " ")
return strings.TrimSpace(normalized)
}
func sortedKeys(m map[string]any) []string {
keys := make([]string, 0, len(m))
for key := range m {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
func sortedStringKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for key := range m {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
func isSensitiveKey(key string) bool {
key = strings.ToLower(key)
sensitive := []string{"token", "authcode", "refresh", "cookie", "header", "authorization", "x-user-access-token"}
for _, token := range sensitive {
if strings.Contains(key, token) {
return true
}
}
return false
}
func isContentKey(key string) bool {
key = strings.ToLower(key)
content := []string{"text", "body", "markdown", "json", "records", "fields"}
for _, token := range content {
if strings.Contains(key, token) {
return true
}
}
return false
}
+537
View File
@@ -0,0 +1,537 @@
package recovery
import (
"context"
"fmt"
"strings"
)
type KnowledgeRetriever interface {
Search(ctx context.Context, query string, rc RecoveryContext) (KnowledgeRetrieval, error)
}
type PlanOptions struct {
EventID string
EnableDocSearch bool
}
type Planner struct {
retriever KnowledgeRetriever
}
func NewPlanner(retriever KnowledgeRetriever) *Planner {
return &Planner{retriever: retriever}
}
func (p *Planner) Plan(ctx context.Context, rc RecoveryContext) RecoveryPlan {
return p.PlanWithOptions(ctx, rc, PlanOptions{EnableDocSearch: true})
}
func (p *Planner) PlanWithOptions(ctx context.Context, rc RecoveryContext, opts PlanOptions) RecoveryPlan {
plan, exact := planExact(rc)
if !exact {
plan = RecoveryPlan{
Category: "unknown",
DecisionOwner: DecisionOwnerAgent,
Confidence: 0.35,
Evidence: []string{"未命中内置高频恢复规则"},
}
}
routeReasons := buildAgentRouteReasons(plan)
if opts.EnableDocSearch && len(routeReasons) > 0 {
query := BuildFallbackQuery(rc)
retrieval := p.searchKnowledge(ctx, query, rc)
plan.DocSearch = retrieval.DocSearch
if query != "" {
plan.Evidence = append(plan.Evidence, fmt.Sprintf("fallback_query=%s", query))
}
switch plan.DocSearch.Status {
case "success":
plan.Evidence = append(plan.Evidence, "命中开放平台文档检索结果")
case "empty":
plan.Evidence = append(plan.Evidence, "开放平台文档检索无结果")
case "error":
if strings.TrimSpace(plan.DocSearch.Error) != "" {
plan.Evidence = append(plan.Evidence, fmt.Sprintf("开放平台文档检索失败=%s", plan.DocSearch.Error))
} else {
plan.Evidence = append(plan.Evidence, "开放平台文档检索失败")
}
}
if len(retrieval.KBHits) > 0 {
plan.KBHits = append(plan.KBHits, retrieval.KBHits...)
if plan.Category == "unknown" {
plan.Confidence = 0.55
}
}
} else {
plan.DocSearch = DocSearch{Status: "skipped"}
}
plan.KBHits = dedupeKBHits(plan.KBHits)
plan.DocActions = dedupeDocActions(extractDocActions(plan.KBHits))
plan.SafeActions = nonNilStrings(normalizeSafeActions(plan.AutoActions))
plan.AutoActions = nonNilStrings(plan.SafeActions)
plan.DecisionOwner = normalizeDecisionOwner(plan)
plan.DecisionHints = buildDecisionHints(plan, rc)
plan.HumanActions = nonNilStrings(uniqueStrings(plan.HumanActions))
plan.Evidence = nonNilStrings(uniqueStrings(plan.Evidence))
plan.RuleHints = buildRuleHints(plan)
plan.AgentRoute = buildAgentRoute(opts.EventID, rc, Replay{}, plan, routeReasons)
return plan
}
func (p *Planner) searchKnowledge(ctx context.Context, query string, rc RecoveryContext) KnowledgeRetrieval {
retrieval := KnowledgeRetrieval{DocSearch: DocSearch{Query: query, Status: "skipped"}}
if query == "" || p == nil || p.retriever == nil {
return retrieval
}
result, err := p.retriever.Search(ctx, query, rc)
if result.DocSearch.Query == "" {
result.DocSearch.Query = query
}
if err != nil {
if result.DocSearch.Status == "" {
result.DocSearch.Status = "error"
}
if strings.TrimSpace(result.DocSearch.Error) == "" {
result.DocSearch.Error = err.Error()
}
return result
}
if result.DocSearch.Status == "" {
if len(result.DocSearch.Items) > 0 {
result.DocSearch.Status = "success"
} else {
result.DocSearch.Status = "empty"
}
}
return result
}
func dedupeKBHits(hits []KBHit) []KBHit {
if len(hits) <= 1 {
return hits
}
seen := make(map[string]struct{}, len(hits))
out := make([]KBHit, 0, len(hits))
for _, hit := range hits {
key := strings.TrimSpace(hit.Source) + "|" + strings.TrimSpace(hit.Title) + "|" + strings.TrimSpace(hit.URL)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, hit)
}
return out
}
func planExact(rc RecoveryContext) (RecoveryPlan, bool) {
evidence := []string{}
if rc.CLIErrorCode != "" {
evidence = append(evidence, "cli_error_code="+rc.CLIErrorCode)
}
if rc.HTTPStatus != 0 {
evidence = append(evidence, fmt.Sprintf("http_status=%d", rc.HTTPStatus))
}
if rc.CallStage != "" {
evidence = append(evidence, "call_stage="+rc.CallStage)
}
switch {
case rc.CLIErrorCode == cliAuthExpiredCode || strings.Contains(strings.ToLower(rc.RawError), "token验证失败") || strings.Contains(strings.ToLower(rc.RawError), "user_token_illegal"):
shouldRetry := safeToRetry(rc, "auth")
plan := RecoveryPlan{
Category: "auth",
DecisionOwner: DecisionOwnerBuiltinRule,
Confidence: 0.98,
HumanActions: []string{"如果重复执行两次仍失败,再运行 dws auth login 重新登录"},
Evidence: append(evidence, "命中认证失效规则"),
KBHits: []KBHit{{Source: "builtin", Title: "认证失效恢复规则", Score: 1}},
ShouldRetry: shouldRetry,
ShouldStop: !shouldRetry,
}
if shouldRetry {
plan.AutoActions = []string{"rerun_original_command"}
}
return plan, true
case rc.CLIErrorCode == cliInvalidJSONCode || rc.CLIErrorCode == cliMissingParamCode || strings.Contains(strings.ToLower(rc.RawError), "json 解析失败"):
return RecoveryPlan{
Category: "input",
DecisionOwner: DecisionOwnerBuiltinRule,
Confidence: 0.96,
HumanActions: []string{"检查 JSON 结构、必填参数和字段名后再重试原命令"},
Evidence: append(evidence, "命中输入参数规则"),
KBHits: []KBHit{{Source: "builtin", Title: "输入参数修正规则", Score: 1}},
ShouldStop: true,
}, true
case strings.HasSuffix(rc.CLIErrorCode, "_NOT_FOUND") || rc.CLIErrorCode == cliResourceNotFoundCode || containsAny(rc.RawError, "not found", "资源不存在", "不存在", "does not exist", "has been deleted", "deleted", "base_not_found", "document.notfound", "resource.notfound", "specified base does not exist"):
return RecoveryPlan{
Category: "resource",
DecisionOwner: DecisionOwnerBuiltinRule,
Confidence: 0.92,
HumanActions: []string{"先确认资源是否仍存在、ID 是否正确,再决定是否重试原命令"},
Evidence: append(evidence, "命中资源不存在规则"),
KBHits: []KBHit{{Source: "builtin", Title: "资源不存在恢复规则", Score: 1}},
ShouldStop: true,
}, true
case rc.CLIErrorCode == cliPermissionCode || rc.HTTPStatus == 403 || containsAny(rc.RawError, "forbidden", "permission", "权限不足", "无权限", "operationillegal"):
return RecoveryPlan{
Category: "permission",
DecisionOwner: DecisionOwnerBuiltinRule,
Confidence: 0.95,
HumanActions: []string{"确认当前账号对目标资源有访问权限,必要时申请授权后再重试"},
Evidence: append(evidence, "命中权限规则"),
KBHits: []KBHit{{Source: "builtin", Title: "权限不足恢复规则", Score: 1}},
ShouldStop: true,
}, true
case rc.CLIErrorCode == cliRateLimitCode || rc.HTTPStatus == 429 || containsAny(rc.RawError, "too many requests", "rate limit"):
shouldRetry := safeToRetry(rc, "rate_limit")
plan := RecoveryPlan{
Category: "rate_limit",
DecisionOwner: DecisionOwnerBuiltinRule,
Confidence: 0.94,
HumanActions: []string{"如仍失败,请降低调用频率后再重试"},
Evidence: append(evidence, "命中限流规则"),
KBHits: []KBHit{{Source: "builtin", Title: "限流恢复规则", Score: 1}},
ShouldRetry: shouldRetry,
ShouldStop: !shouldRetry,
}
if shouldRetry {
plan.AutoActions = []string{"wait_and_retry"}
}
return plan, true
case rc.CLIErrorCode == cliTimeoutCode || rc.HTTPStatus >= 500 || containsAny(rc.RawError, "timeout", "deadline exceeded", "connection refused", "connection reset", "broken pipe", "502", "503", "504"):
shouldRetry := safeToRetry(rc, "network")
plan := RecoveryPlan{
Category: "network",
DecisionOwner: DecisionOwnerBuiltinRule,
Confidence: 0.9,
HumanActions: []string{"如果重试后仍失败,请检查网络或稍后再试"},
Evidence: append(evidence, "命中网络/服务异常规则"),
KBHits: []KBHit{{Source: "builtin", Title: "网络异常恢复规则", Score: 1}},
ShouldRetry: shouldRetry,
ShouldStop: !shouldRetry,
}
if shouldRetry {
plan.AutoActions = []string{"retry_original_command"}
}
return plan, true
}
return RecoveryPlan{}, false
}
func BuildFallbackQuery(rc RecoveryContext) string {
parts := make([]string, 0, 6)
if rc.CLIErrorCode != "" {
parts = append(parts, rc.CLIErrorCode)
}
if rc.ToolName != "" {
parts = append(parts, rc.ToolName)
}
if len(rc.CommandPath) > 0 {
parts = append(parts, strings.Join(rc.CommandPath, " "))
}
parts = append(parts, stableKeywords(rc.RawError)...)
return strings.Join(uniqueStrings(parts), " ")
}
func safeToRetry(rc RecoveryContext, category string) bool {
switch rc.OperationKind {
case OperationRead:
return category == "auth" || category == "rate_limit" || category == "network"
case OperationWrite:
if rc.CallStage != "http" {
return false
}
return category == "auth" || category == "rate_limit"
default:
return false
}
}
func normalizeDecisionOwner(plan RecoveryPlan) DecisionOwner {
if plan.DecisionOwner != "" {
return plan.DecisionOwner
}
if plan.Category == "unknown" {
return DecisionOwnerAgent
}
return DecisionOwnerBuiltinRule
}
func stableKeywords(raw string) []string {
normalized := normalizeRawError(raw)
if normalized == "" {
return nil
}
parts := strings.FieldsFunc(normalized, func(r rune) bool {
return !(r >= 'a' && r <= 'z') && !(r >= '0' && r <= '9') && r != '_' && r != '<' && r != '>'
})
out := make([]string, 0, len(parts))
for _, part := range parts {
if len(part) < 3 || strings.HasPrefix(part, "<") {
continue
}
out = append(out, part)
if len(out) >= 6 {
break
}
}
return uniqueStrings(out)
}
func uniqueStrings(values []string) []string {
seen := make(map[string]struct{}, len(values))
out := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
return out
}
func containsAny(value string, targets ...string) bool {
value = strings.ToLower(value)
for _, target := range targets {
if strings.Contains(value, strings.ToLower(target)) {
return true
}
}
return false
}
const (
cliAuthExpiredCode = "AUTH_TOKEN_EXPIRED"
cliInvalidJSONCode = "INPUT_INVALID_JSON"
cliMissingParamCode = "INPUT_MISSING_PARAM"
cliPermissionCode = "AUTH_PERMISSION_DENIED"
cliResourceNotFoundCode = "RESOURCE_NOT_FOUND"
cliRateLimitCode = "NETWORK_RATE_LIMITED"
cliTimeoutCode = "NETWORK_TIMEOUT"
)
func normalizeSafeActions(actions []string) []string {
allowed := make([]string, 0, len(actions))
for _, action := range actions {
switch action {
case "rerun_original_command", "retry_original_command", "wait_and_retry":
allowed = append(allowed, action)
}
}
return uniqueStrings(allowed)
}
func buildDecisionHints(plan RecoveryPlan, rc RecoveryContext) DecisionHints {
signals := []string{rc.CLIErrorCode, rc.RawError}
for _, hit := range plan.KBHits {
signals = append(signals, hit.Title, hit.Snippet)
}
for _, action := range plan.DocActions {
signals = append(signals, action.Action, action.Reason)
}
text := strings.Join(signals, " ")
return DecisionHints{
Retryable: plan.ShouldRetry || len(plan.SafeActions) > 0,
PermissionSensitive: plan.Category == "permission" || containsAny(text, "permission", "forbidden", "无权限", "operatorid has permission", "有访问权限", "inaccessible"),
AuthRelated: plan.Category == "auth" || containsAny(text, "auth_token_expired", "token验证失败", "user_token_illegal", "认证失效", "token 已过期"),
ResourceStateRelated: plan.Category == "resource" || containsAny(text, "not found", "does not exist", "deleted", "document.notfound", "resource.notfound", "资源不存在", "文档不存在", "彻底删除", "base_not_found"),
}
}
func buildRuleHints(plan RecoveryPlan) RuleHints {
return RuleHints{
Category: plan.Category,
DecisionOwner: normalizeDecisionOwner(plan),
Confidence: plan.Confidence,
SafeActions: nonNilStrings(plan.SafeActions),
HumanActions: nonNilStrings(plan.HumanActions),
Evidence: nonNilStrings(plan.Evidence),
ShouldRetry: plan.ShouldRetry,
ShouldStop: plan.ShouldStop,
}
}
func buildAgentRouteReasons(plan RecoveryPlan) []string {
reasons := make([]string, 0, 2)
if plan.Category == "unknown" {
reasons = append(reasons, "unknown_category")
}
return nonNilStrings(uniqueStrings(reasons))
}
func buildAgentRoute(eventID string, rc RecoveryContext, replay Replay, plan RecoveryPlan, reasons []string) AgentRoute {
required := len(reasons) > 0
var payload *AgentRoutePayload
decisionOwner := normalizeDecisionOwner(plan)
if required {
payload = &AgentRoutePayload{
EventID: eventID,
Context: cloneRecoveryContext(rc),
Replay: cloneReplay(replay),
RawError: rc.RawError,
Category: plan.Category,
DecisionOwner: decisionOwner,
Confidence: plan.Confidence,
ShouldRetry: plan.ShouldRetry,
ShouldStop: plan.ShouldStop,
SafeActions: nonNilStrings(plan.SafeActions),
DocActions: cloneDocActions(plan.DocActions),
KBHits: cloneKBHits(plan.KBHits),
DocSearch: cloneDocSearch(plan.DocSearch),
HumanActions: nonNilStrings(plan.HumanActions),
DecisionHints: plan.DecisionHints,
Evidence: nonNilStrings(plan.Evidence),
RuleHints: plan.RuleHints,
}
}
return AgentRoute{
Required: required,
Target: ternaryString(required, "agent_llm", ""),
Executor: ternaryString(required, "dingtalk-workspace", ""),
Reasons: nonNilStrings(reasons),
Payload: payload,
}
}
func HydratePlanForEvent(eventID string, rc RecoveryContext, replay Replay, plan *RecoveryPlan) {
if plan == nil {
return
}
plan.RuleHints = buildRuleHints(*plan)
plan.AgentRoute = buildAgentRoute(eventID, rc, replay, *plan, buildAgentRouteReasons(*plan))
}
func extractDocActions(hits []KBHit) []DocAction {
out := make([]DocAction, 0)
for _, hit := range hits {
if strings.TrimSpace(hit.Snippet) == "" {
continue
}
lines := strings.Split(hit.Snippet, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "|") {
continue
}
cells := splitTableRow(line)
if len(cells) < 5 {
continue
}
if strings.EqualFold(cells[0], "httpcode") || strings.HasPrefix(cells[0], "--") {
continue
}
action := strings.TrimSpace(cells[len(cells)-1])
if action == "" || action == "%s" {
continue
}
reasonParts := make([]string, 0, 2)
if len(cells) > 1 && cells[1] != "" {
reasonParts = append(reasonParts, cells[1])
}
if len(cells) > 3 && cells[3] != "" && cells[3] != "%s" {
reasonParts = append(reasonParts, cells[3])
}
out = append(out, DocAction{
Action: action,
Reason: strings.Join(reasonParts, ": "),
SourceTitle: hit.Title,
SourceURL: hit.URL,
})
}
}
return out
}
func splitTableRow(line string) []string {
parts := strings.Split(line, "|")
out := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
out = append(out, part)
}
return out
}
func dedupeDocActions(actions []DocAction) []DocAction {
if len(actions) <= 1 {
return actions
}
seen := make(map[string]struct{}, len(actions))
out := make([]DocAction, 0, len(actions))
for _, action := range actions {
key := strings.TrimSpace(action.Action) + "|" + strings.TrimSpace(action.Reason) + "|" + strings.TrimSpace(action.SourceTitle) + "|" + strings.TrimSpace(action.SourceURL)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, action)
}
return out
}
func cloneDocActions(actions []DocAction) []DocAction {
if len(actions) == 0 {
return nil
}
out := make([]DocAction, len(actions))
copy(out, actions)
return out
}
func cloneKBHits(hits []KBHit) []KBHit {
if len(hits) == 0 {
return nil
}
out := make([]KBHit, len(hits))
copy(out, hits)
return out
}
func cloneDocSearch(search DocSearch) DocSearch {
cloned := search
if search.Request != nil {
cloned.Request = &ToolCallRecord{
ServerID: search.Request.ServerID,
ToolName: search.Request.ToolName,
Arguments: cloneMap(search.Request.Arguments),
}
}
if search.Response != nil {
cloned.Response = &ToolResponse{IsError: search.Response.IsError}
if len(search.Response.Content) > 0 {
cloned.Response.Content = make([]ToolResponseBlock, len(search.Response.Content))
copy(cloned.Response.Content, search.Response.Content)
}
}
if len(search.Items) > 0 {
cloned.Items = make([]DocSearchItem, len(search.Items))
copy(cloned.Items, search.Items)
}
return cloned
}
func ternaryString(ok bool, yes, no string) string {
if ok {
return yes
}
return no
}
func nonNilStrings(values []string) []string {
if len(values) == 0 {
return []string{}
}
return append([]string{}, values...)
}
+310
View File
@@ -0,0 +1,310 @@
package recovery
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
)
func TestBuildContext_SanitizesArgsAndPreservesTypedFields(t *testing.T) {
rawErr := &transport.CallError{
Stage: transport.CallStageHTTP,
HTTPStatus: 429,
RetryAfter: "3",
TraceID: "trace-1",
RequestID: "req-1",
Cause: errors.New("HTTP 429: too many requests"),
}
wrapped := apperrors.NewAPI(
"request failed",
apperrors.WithReason("http_429"),
apperrors.WithRetryable(true),
apperrors.WithCause(rawErr),
)
ctx := BuildContext(CaptureInput{
CommandPath: []string{"chat", "message", "send"},
ServerID: "chat",
ToolName: "send_message_as_user",
Args: map[string]any{
"text": "very secret text body",
"token": "secret-token",
"payload": map[string]any{"header": "sensitive", "name": "demo"},
},
RawErr: rawErr,
WrappedErr: wrapped,
})
if ctx.CLIErrorCode != cliRateLimitCode {
t.Fatalf("expected CLI error code %q, got %q", cliRateLimitCode, ctx.CLIErrorCode)
}
if ctx.CallStage != string(transport.CallStageHTTP) || ctx.HTTPStatus != 429 {
t.Fatalf("expected typed call metadata, got stage=%q status=%d", ctx.CallStage, ctx.HTTPStatus)
}
if ctx.RetryAfter != "3" || ctx.TraceID != "trace-1" || ctx.RequestID != "req-1" {
t.Fatalf("expected transport metadata, got %#v", ctx)
}
if _, ok := ctx.ArgsSummary["token"]; ok {
t.Fatal("sensitive args must be dropped")
}
textSummary, ok := ctx.ArgsSummary["text"].(map[string]any)
if !ok {
t.Fatalf("expected text summary, got %#v", ctx.ArgsSummary["text"])
}
if int(textSummary["length"].(float64)) != len("very secret text body") {
t.Fatalf("expected text summary length, got %#v", textSummary)
}
if ctx.Fingerprint == "" {
t.Fatal("expected fingerprint to be computed")
}
}
func TestBuildReplay_SummarizesContentArgsAndSanitizesNestedValues(t *testing.T) {
replay := BuildReplay(CaptureInput{
CommandPath: []string{"chat", "message", "send"},
ServerID: "chat",
ToolName: "send_message_as_user",
Args: map[string]any{
"text": "very secret text body",
"records": []map[string]any{{
"title": "demo record",
"token": "secret-token",
}},
"payload": map[string]any{
"header": "sensitive",
"name": "demo",
"text": "nested body",
},
"baseId": "base_demo",
},
Argv: []string{"chat", "message", "send", "--token", "secret-token"},
})
textSummary, ok := replay.ToolArgs["text"].(map[string]any)
if !ok {
t.Fatalf("expected text summary in replay, got %#v", replay.ToolArgs["text"])
}
if int(textSummary["length"].(float64)) != len("very secret text body") {
t.Fatalf("expected text length summary, got %#v", textSummary)
}
recordsSummary, ok := replay.ToolArgs["records"].(map[string]any)
if !ok {
t.Fatalf("expected records summary in replay, got %#v", replay.ToolArgs["records"])
}
if recordsSummary["kind"] != "array" || int(recordsSummary["count"].(float64)) != 1 {
t.Fatalf("expected records count summary, got %#v", recordsSummary)
}
payload, ok := replay.ToolArgs["payload"].(map[string]any)
if !ok {
t.Fatalf("expected nested payload in replay, got %#v", replay.ToolArgs["payload"])
}
if _, ok := payload["header"]; ok {
t.Fatalf("expected nested sensitive field to be removed, got %#v", payload)
}
if payload["name"] != "demo" {
t.Fatalf("expected non-sensitive nested field to remain, got %#v", payload)
}
nestedText, ok := payload["text"].(map[string]any)
if !ok {
t.Fatalf("expected nested text summary, got %#v", payload["text"])
}
if int(nestedText["length"].(float64)) != len("nested body") {
t.Fatalf("expected nested text length summary, got %#v", nestedText)
}
if replay.ToolArgs["baseId"] != "base_demo" {
t.Fatalf("expected replayable identifier to remain, got %#v", replay.ToolArgs["baseId"])
}
if len(replay.RedactedArgv) != 5 || replay.RedactedArgv[4] != "<redacted>" {
t.Fatalf("expected argv token to be redacted, got %#v", replay.RedactedArgv)
}
}
func TestComputeFingerprint_NormalizesVolatileSegments(t *testing.T) {
ctxA := RecoveryContext{
ServerID: "aitable",
ToolName: "get_base",
CLIErrorCode: cliResourceNotFoundCode,
HTTPStatus: 404,
RawError: "resource base_abcd1234 not found at 2026-03-19T10:10:10Z trace=550e8400-e29b-41d4-a716-446655440000",
}
ctxB := RecoveryContext{
ServerID: "aitable",
ToolName: "get_base",
CLIErrorCode: cliResourceNotFoundCode,
HTTPStatus: 404,
RawError: "resource base_zzzz9999 not found at 2026-03-20T10:10:10Z trace=660e8400-e29b-41d4-a716-446655440000",
}
if gotA, gotB := ComputeFingerprint(ctxA), ComputeFingerprint(ctxB); gotA != gotB {
t.Fatalf("expected normalized fingerprint to stay stable, got %q vs %q", gotA, gotB)
}
}
func TestPlanner_ExactRulesAndFallback(t *testing.T) {
planner := NewPlanner(fakeRetriever{retrieval: KnowledgeRetrieval{
KBHits: []KBHit{{Source: "docs", Title: "doc"}},
DocSearch: DocSearch{
Provider: "open_platform_docs",
Query: "get_approval_instance approval approval get weird upstream issue",
Status: "success",
Items: []DocSearchItem{{
Title: "doc",
URL: "https://open.dingtalk.com/doc",
Desc: "审批接口文档",
}},
},
}})
authPlan := planner.Plan(context.Background(), RecoveryContext{
CLIErrorCode: cliAuthExpiredCode,
OperationKind: OperationRead,
})
if authPlan.Category != "auth" || !authPlan.ShouldRetry {
t.Fatalf("expected auth plan with retry, got %#v", authPlan)
}
if len(authPlan.SafeActions) != 1 || authPlan.SafeActions[0] != "rerun_original_command" {
t.Fatalf("expected safe action alias for auth plan, got %#v", authPlan.SafeActions)
}
if !authPlan.DecisionHints.AuthRelated || !authPlan.DecisionHints.Retryable {
t.Fatalf("expected auth decision hints, got %#v", authPlan.DecisionHints)
}
if authPlan.AgentRoute.Required {
t.Fatalf("expected auth retryable plan to stay on rule path, got %#v", authPlan.AgentRoute)
}
unknownPlan := planner.Plan(context.Background(), RecoveryContext{
CommandPath: []string{"approval", "approval", "get"},
ToolName: "get_approval_instance",
OperationKind: OperationUnknown,
RawError: "weird upstream issue",
})
if unknownPlan.Category != "unknown" {
t.Fatalf("expected unknown category, got %s", unknownPlan.Category)
}
if unknownPlan.DecisionOwner != DecisionOwnerAgent {
t.Fatalf("expected unknown plan to be agent-owned, got %#v", unknownPlan.DecisionOwner)
}
if len(unknownPlan.KBHits) == 0 {
t.Fatal("expected fallback retriever hits")
}
if !strings.Contains(strings.Join(unknownPlan.Evidence, " "), "fallback_query=") {
t.Fatalf("expected fallback query evidence, got %#v", unknownPlan.Evidence)
}
if unknownPlan.ShouldRetry || unknownPlan.ShouldStop {
t.Fatalf("expected unknown plan to stay undecided, got %#v", unknownPlan)
}
if len(unknownPlan.HumanActions) != 0 {
t.Fatalf("expected unknown plan to avoid built-in human actions, got %#v", unknownPlan.HumanActions)
}
if !unknownPlan.AgentRoute.Required {
t.Fatalf("expected unknown plan to force agent route, got %#v", unknownPlan.AgentRoute)
}
if len(unknownPlan.AgentRoute.Reasons) != 1 || unknownPlan.AgentRoute.Reasons[0] != "unknown_category" {
t.Fatalf("expected unknown_category route reason, got %#v", unknownPlan.AgentRoute.Reasons)
}
if unknownPlan.AgentRoute.Payload == nil || unknownPlan.AgentRoute.Payload.DecisionOwner != DecisionOwnerAgent {
t.Fatalf("expected unknown payload to include agent decision owner, got %#v", unknownPlan.AgentRoute.Payload)
}
if unknownPlan.DocSearch.Status != "success" || len(unknownPlan.DocSearch.Items) != 1 {
t.Fatalf("expected doc search payload to be preserved, got %#v", unknownPlan.DocSearch)
}
if unknownPlan.RuleHints.DecisionOwner != DecisionOwnerAgent || unknownPlan.RuleHints.Confidence != unknownPlan.Confidence {
t.Fatalf("expected rule hints to mirror final unknown plan state, got %#v", unknownPlan.RuleHints)
}
}
func TestStore_CaptureLoadAndFinalizeLifecycle(t *testing.T) {
dir := t.TempDir()
store := NewStore(dir)
ctx := RecoveryContext{
CommandPath: []string{"aitable", "base", "get"},
ServerID: "aitable",
ToolName: "get_base",
OperationKind: OperationRead,
CLIErrorCode: cliResourceNotFoundCode,
RawError: "base_not_found",
Fingerprint: "fp-1",
}
replay := Replay{
ServerID: "aitable",
ToolName: "get_base",
OperationKind: OperationRead,
ToolArgs: map[string]any{"baseId": "base_1"},
}
last, err := store.Capture(ctx, replay)
if err != nil {
t.Fatalf("Capture() error = %v", err)
}
if last == nil || last.EventID == "" {
t.Fatalf("Capture() = %#v, want event id", last)
}
loaded, err := store.LoadLastError()
if err != nil {
t.Fatalf("LoadLastError() error = %v", err)
}
if loaded.EventID != last.EventID {
t.Fatalf("LoadLastError().EventID = %q, want %q", loaded.EventID, last.EventID)
}
byEvent, err := store.LoadErrorByEvent(last.EventID)
if err != nil {
t.Fatalf("LoadErrorByEvent() error = %v", err)
}
if byEvent.EventID != last.EventID {
t.Fatalf("LoadErrorByEvent().EventID = %q, want %q", byEvent.EventID, last.EventID)
}
plan := RecoveryPlan{Category: "resource", Confidence: 0.92}
if err := store.SavePlan(last.EventID, plan); err != nil {
t.Fatalf("SavePlan() error = %v", err)
}
bundle := RecoveryBundle{EventID: last.EventID, Plan: plan, Status: "needs_agent_action"}
if err := store.SaveAnalysis(last.EventID, plan, bundle); err != nil {
t.Fatalf("SaveAnalysis() error = %v", err)
}
exec := &RecoveryExecution{
Actions: []string{"inspect_bundle"},
Result: "handoff",
}
if err := store.Finalize(last.EventID, "handoff", exec); err != nil {
t.Fatalf("Finalize() error = %v", err)
}
eventsPath := filepath.Join(dir, "recovery", "recovery_events.jsonl")
data, err := os.ReadFile(eventsPath)
if err != nil {
t.Fatalf("ReadFile(events) error = %v", err)
}
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
if len(lines) != 4 {
t.Fatalf("expected 4 recovery event lines, got %d", len(lines))
}
var lastEvent RecoveryEvent
if err := json.Unmarshal([]byte(lines[len(lines)-1]), &lastEvent); err != nil {
t.Fatalf("json.Unmarshal(final event) error = %v", err)
}
if lastEvent.Phase != "finalized" || lastEvent.Outcome != "handoff" {
t.Fatalf("unexpected final event %#v", lastEvent)
}
}
type fakeRetriever struct {
retrieval KnowledgeRetrieval
err error
}
func (f fakeRetriever) Search(ctx context.Context, query string, rc RecoveryContext) (KnowledgeRetrieval, error) {
return f.retrieval, f.err
}
+476
View File
@@ -0,0 +1,476 @@
package recovery
import (
"bufio"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
const (
recoveryDirName = "recovery"
lastErrorFile = "last_error.json"
eventsFile = "recovery_events.jsonl"
recoveryMaxAge = 30 * 24 * time.Hour
)
type Store struct {
baseDir string
}
var runtimeState struct {
mu sync.Mutex
lastCapture *LastError
}
var (
recoveryReadFile = os.ReadFile
recoveryWriteFile = os.WriteFile
recoveryOpen = os.Open
recoveryOpenFile = os.OpenFile
recoveryMkdirAll = os.MkdirAll
recoveryReadDir = os.ReadDir
recoveryStat = os.Stat
recoveryRemove = os.Remove
recoveryRemoveAll = os.RemoveAll
recoveryFileWrite = func(file *os.File, data []byte) (int, error) {
return file.Write(data)
}
)
func NewStore(configDir string) *Store {
return &Store{baseDir: configDir}
}
func (s *Store) Enabled() bool {
return s != nil && s.baseDir != ""
}
func (s *Store) Capture(ctx RecoveryContext, replay ...Replay) (*LastError, error) {
if !s.Enabled() {
return nil, nil
}
last := &LastError{
EventID: newEventID(),
RecordedAt: time.Now().UTC().Format(time.RFC3339Nano),
Context: ctx,
}
if len(replay) > 0 {
last.Replay = cloneReplay(replay[0])
}
if err := s.writeJSON(s.lastErrorPath(), last); err != nil {
return nil, err
}
var replayPtr *Replay
if !isEmptyReplay(last.Replay) {
copied := cloneReplay(last.Replay)
replayPtr = &copied
}
if err := s.appendEvent(RecoveryEvent{
EventID: last.EventID,
Phase: "captured",
RecordedAt: last.RecordedAt,
Context: &last.Context,
Replay: replayPtr,
}); err != nil {
return nil, err
}
setLatestCapture(last)
return last, nil
}
func (s *Store) LoadLastError() (*LastError, error) {
if !s.Enabled() {
return nil, fmt.Errorf("recovery store disabled")
}
if err := s.pruneExpiredArtifacts(time.Now().UTC()); err != nil {
return nil, err
}
data, err := recoveryReadFile(s.lastErrorPath())
if err != nil {
return nil, err
}
var last LastError
if err := json.Unmarshal(data, &last); err != nil {
return nil, err
}
return &last, nil
}
func (s *Store) LoadErrorByEvent(eventID string) (*LastError, error) {
if !s.Enabled() {
return nil, fmt.Errorf("recovery store disabled")
}
if eventID == "" {
return nil, fmt.Errorf("event id is required")
}
if err := s.pruneExpiredArtifacts(time.Now().UTC()); err != nil {
return nil, err
}
file, err := recoveryOpen(s.eventsPath())
if err != nil {
if !os.IsNotExist(err) {
return nil, err
}
file = nil
}
if file != nil {
defer file.Close()
reader := bufio.NewReader(file)
for {
line, err := reader.ReadBytes('\n')
if len(line) > 0 {
var event RecoveryEvent
if json.Unmarshal(line, &event) == nil && event.EventID == eventID && event.Phase == "captured" && event.Context != nil {
last := &LastError{
EventID: event.EventID,
RecordedAt: event.RecordedAt,
Context: cloneRecoveryContext(*event.Context),
}
if event.Replay != nil {
last.Replay = cloneReplay(*event.Replay)
}
return last, nil
}
}
if err != nil {
if err == io.EOF {
break
}
return nil, err
}
}
}
last, err := s.LoadLastError()
if err == nil && last != nil && last.EventID == eventID {
return last, nil
}
return nil, fmt.Errorf("未找到 event_id=%s 对应的失败快照", eventID)
}
func (s *Store) SavePlan(eventID string, plan RecoveryPlan) error {
if !s.Enabled() {
return nil
}
return s.appendEvent(RecoveryEvent{
EventID: eventID,
Phase: "planned",
RecordedAt: time.Now().UTC().Format(time.RFC3339Nano),
Plan: &plan,
})
}
func (s *Store) SaveAnalysis(eventID string, plan RecoveryPlan, bundle RecoveryBundle) error {
if !s.Enabled() {
return nil
}
return s.appendEvent(RecoveryEvent{
EventID: eventID,
Phase: "analyzed",
RecordedAt: time.Now().UTC().Format(time.RFC3339Nano),
Plan: &plan,
Bundle: &bundle,
})
}
func (s *Store) Finalize(eventID, outcome string, exec *RecoveryExecution) error {
if !s.Enabled() {
return nil
}
return s.appendEvent(RecoveryEvent{
EventID: eventID,
Phase: "finalized",
RecordedAt: time.Now().UTC().Format(time.RFC3339Nano),
Execution: exec,
Outcome: outcome,
})
}
func (s *Store) writeJSON(path string, payload any) error {
if err := s.ensureDir(); err != nil {
return err
}
data, err := json.MarshalIndent(payload, "", " ")
if err != nil {
return err
}
return recoveryWriteFile(path, append(data, '\n'), 0o600)
}
func (s *Store) appendEvent(event RecoveryEvent) error {
if err := s.ensureDir(); err != nil {
return err
}
file, err := recoveryOpenFile(s.eventsPath(), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
return err
}
defer file.Close()
data, err := json.Marshal(event)
if err != nil {
return err
}
if _, err := recoveryFileWrite(file, append(data, '\n')); err != nil {
return err
}
return nil
}
func (s *Store) ensureDir() error {
if !s.Enabled() {
return fmt.Errorf("recovery store disabled")
}
if err := recoveryMkdirAll(s.dir(), 0o700); err != nil {
return err
}
return s.pruneExpiredArtifacts(time.Now().UTC())
}
func (s *Store) dir() string {
return filepath.Join(s.baseDir, recoveryDirName)
}
func (s *Store) lastErrorPath() string {
return filepath.Join(s.dir(), lastErrorFile)
}
func (s *Store) eventsPath() string {
return filepath.Join(s.dir(), eventsFile)
}
func newEventID() string {
return fmt.Sprintf("evt_%d", time.Now().UTC().UnixNano())
}
func LatestCapture() *LastError {
runtimeState.mu.Lock()
defer runtimeState.mu.Unlock()
if runtimeState.lastCapture == nil {
return nil
}
copied := *runtimeState.lastCapture
copied.Context = cloneRecoveryContext(copied.Context)
copied.Replay = cloneReplay(copied.Replay)
return &copied
}
func ResetRuntimeState() {
runtimeState.mu.Lock()
defer runtimeState.mu.Unlock()
runtimeState.lastCapture = nil
}
func setLatestCapture(last *LastError) {
runtimeState.mu.Lock()
defer runtimeState.mu.Unlock()
if last == nil {
runtimeState.lastCapture = nil
return
}
copied := *last
copied.Context = cloneRecoveryContext(copied.Context)
copied.Replay = cloneReplay(copied.Replay)
runtimeState.lastCapture = &copied
}
func cloneRecoveryContext(ctx RecoveryContext) RecoveryContext {
cloned := ctx
if len(ctx.CommandPath) > 0 {
cloned.CommandPath = append([]string(nil), ctx.CommandPath...)
}
if len(ctx.ArgsSummary) > 0 {
cloned.ArgsSummary = cloneMap(ctx.ArgsSummary)
}
return cloned
}
func cloneReplay(replay Replay) Replay {
cloned := replay
if len(replay.ToolArgs) > 0 {
cloned.ToolArgs = cloneMap(replay.ToolArgs)
}
if len(replay.RedactedArgv) > 0 {
cloned.RedactedArgv = append([]string(nil), replay.RedactedArgv...)
}
return cloned
}
func isEmptyReplay(replay Replay) bool {
return replay.ServerID == "" &&
replay.ToolName == "" &&
replay.OperationKind == "" &&
len(replay.ToolArgs) == 0 &&
len(replay.RedactedArgv) == 0 &&
replay.RedactedCommand == ""
}
func cloneMap(src map[string]any) map[string]any {
if len(src) == 0 {
return nil
}
dst := make(map[string]any, len(src))
for key, value := range src {
dst[key] = cloneValue(value)
}
return dst
}
func cloneSlice(src []any) []any {
if len(src) == 0 {
return nil
}
dst := make([]any, len(src))
for i, value := range src {
dst[i] = cloneValue(value)
}
return dst
}
func cloneValue(value any) any {
switch v := value.(type) {
case map[string]any:
return cloneMap(v)
case []any:
return cloneSlice(v)
case []string:
out := make([]string, len(v))
copy(out, v)
return out
default:
return v
}
}
func (s *Store) pruneExpiredArtifacts(now time.Time) error {
if !s.Enabled() {
return nil
}
cutoff := now.Add(-recoveryMaxAge)
if err := s.pruneLastError(cutoff); err != nil {
return err
}
if err := s.pruneEvents(cutoff); err != nil {
return err
}
return s.pruneOtherArtifacts(cutoff)
}
func (s *Store) pruneLastError(cutoff time.Time) error {
path := s.lastErrorPath()
data, err := recoveryReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
var last LastError
if err := json.Unmarshal(data, &last); err == nil {
if recordedAt, ok := parseRecordedAt(last.RecordedAt); ok && recordedAt.Before(cutoff) {
return recoveryRemove(path)
}
return nil
}
info, statErr := recoveryStat(path)
if statErr != nil {
if os.IsNotExist(statErr) {
return nil
}
return statErr
}
if info.ModTime().Before(cutoff) {
return recoveryRemove(path)
}
return nil
}
func (s *Store) pruneEvents(cutoff time.Time) error {
path := s.eventsPath()
data, err := recoveryReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
kept := make([]string, 0, len(lines))
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
var event RecoveryEvent
if err := json.Unmarshal([]byte(line), &event); err != nil {
kept = append(kept, line)
continue
}
recordedAt, ok := parseRecordedAt(event.RecordedAt)
if ok && recordedAt.Before(cutoff) {
continue
}
kept = append(kept, line)
}
if len(kept) == 0 {
return recoveryRemove(path)
}
content := strings.Join(kept, "\n") + "\n"
return recoveryWriteFile(path, []byte(content), 0o600)
}
func (s *Store) pruneOtherArtifacts(cutoff time.Time) error {
entries, err := recoveryReadDir(s.dir())
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
for _, entry := range entries {
name := entry.Name()
if name == lastErrorFile || name == eventsFile {
continue
}
path := filepath.Join(s.dir(), name)
info, err := recoveryStat(path)
if err != nil {
if os.IsNotExist(err) {
continue
}
return err
}
if info.ModTime().Before(cutoff) {
if err := recoveryRemoveAll(path); err != nil && !os.IsNotExist(err) {
return err
}
}
}
return nil
}
func parseRecordedAt(value string) (time.Time, bool) {
value = strings.TrimSpace(value)
if value == "" {
return time.Time{}, false
}
for _, layout := range []string{time.RFC3339Nano, time.RFC3339} {
if parsed, err := time.Parse(layout, value); err == nil {
return parsed, true
}
}
return time.Time{}, false
}
+14 -7
View File
@@ -472,7 +472,7 @@ func (c *Client) callJSONRPC(ctx context.Context, endpoint string, request reque
fmt.Sprintf("JSON-RPC %s returned an empty result payload", request.Method),
apperrors.WithOperation(request.Method),
apperrors.WithReason(reasonForMethod(request.Method, "empty_result")),
apperrors.WithHint(i18n.T("服务返回了空结果;请稍后重试。")),
apperrors.WithHint(i18n.T("服务返回了空结果;请稍后重试,必要时查看 recovery snapshot。")),
apperrors.WithActions(discoveryActions(snapshotPath)...),
apperrors.WithSnapshot(snapshotPath),
)
@@ -896,7 +896,7 @@ func httpStatusError(method, endpoint string, statusCode int, snapshotPath, head
return apperrors.NewAPI(message, opts...)
default:
opts = append(opts,
apperrors.WithHint(i18n.T("上游服务异常;可稍后重试。")),
apperrors.WithHint(i18n.T("上游服务异常;可稍后重试,若持续失败请查看 recovery snapshot。")),
apperrors.WithActions(actionsForMethod(method, snapshotPath)...),
)
if method == "tools/call" {
@@ -1038,9 +1038,10 @@ func actionsForMethod(method, snapshotPath string) []string {
func authActions(snapshotPath string) []string {
actions := []string{
i18n.T("检查登录状态后重试"),
i18n.T("运行 dws auth status 确认凭证有效,必要时重新登录"),
}
_ = snapshotPath
if snapshotPath != "" {
actions = append(actions, fmt.Sprintf("dws recovery plan --snapshot %s", snapshotPath))
}
return actions
}
@@ -1048,7 +1049,9 @@ func runtimeActions(snapshotPath string) []string {
actions := []string{
i18n.T("检查认证、权限和参数后重试原命令"),
}
_ = snapshotPath
if snapshotPath != "" {
actions = append(actions, fmt.Sprintf("dws recovery plan --snapshot %s", snapshotPath))
}
return actions
}
@@ -1057,7 +1060,9 @@ func networkActions(snapshotPath string) []string {
i18n.T("检查网络、代理和 DNS 配置后重试原命令"),
i18n.T("确认 MCP 服务可访问;若持续失败请稍后重试"),
}
_ = snapshotPath
if snapshotPath != "" {
actions = append(actions, fmt.Sprintf("dws recovery plan --snapshot %s", snapshotPath))
}
return actions
}
@@ -1098,6 +1103,8 @@ func discoveryActions(snapshotPath string) []string {
i18n.T("运行 sync-oss 重新生成静态端点与路由后重试"),
i18n.T("检查服务连通性和协议版本后重试"),
}
_ = snapshotPath
if snapshotPath != "" {
actions = append(actions, fmt.Sprintf("dws recovery plan --snapshot %s", snapshotPath))
}
return actions
}
+1 -13
View File
@@ -397,21 +397,9 @@ func TestCrossPlatformCoverageHTTPStatusAndDiagnosticsEdges(t *testing.T) {
t.Fatalf("HTTP %d returned nil", tc.code)
}
}
if got := networkActions("snap"); len(got) != 2 {
if got := networkActions("snap"); len(got) != 3 {
t.Fatalf("network actions = %#v", got)
}
if got := authActions("snap"); len(got) != 2 {
t.Fatalf("auth actions = %#v", got)
}
if got := runtimeActions("snap"); len(got) != 1 {
t.Fatalf("runtime actions = %#v", got)
}
if got := actionsForMethod("tools/call", "snap"); len(got) == 0 {
t.Fatal("tools/call actions empty")
}
if got := actionsForMethod("tools/list", "snap"); len(got) == 0 {
t.Fatal("tools/list actions empty")
}
if err := jsonrpcEnvelopeError("tools/list", &RPCError{Code: -1, Message: "failed"}, "", "header-trace"); err == nil {
t.Fatal("JSON-RPC trace fallback returned nil")
}
+3 -3
View File
@@ -642,9 +642,9 @@ expect_contains "$MONO_HOME/.codex/skills/dws/SKILL.md" "corpId:userId"
expect_contains "$MONO_HOME/.codex/skills/dws/SKILL.md" "禁止选择第一项、最近登录或最近使用账号"
expect_contains "$MONO_HOME/.codex/skills/dws/references/global-reference.md" "userId/userName"
capture "$OUT_DIR/skill-multi-setup.txt" env HOME="$MULTI_HOME" "$BIN" skill setup --mode multi --target codex --source "$ROOT" --yes
expect_contains "$MULTI_HOME/.codex/skills/dingtalk-shared/SKILL.md" "禁止选择第一项、最近登录或最近使用账号"
expect_contains "$MULTI_HOME/.codex/skills/dingtalk-misc/references/profile.md" "corpId:userId"
expect_contains "$MULTI_HOME/.codex/skills/dingtalk-misc/references/profile.md" "isOrgCurrent"
expect_contains "$MULTI_HOME/.codex/skills/dws-shared/SKILL.md" "禁止选择第一项、最近登录或最近使用账号"
expect_contains "$MULTI_HOME/.codex/skills/dingtalk-profile/SKILL.md" "corpId:userId"
expect_contains "$MULTI_HOME/.codex/skills/dingtalk-profile/SKILL.md" "isOrgCurrent"
log "verifying empty profile list"
capture "$OUT_DIR/list-empty.json" "$BIN" profile list --format json
+4 -4
View File
@@ -24,9 +24,9 @@ import gen_shortcut_comparison as shortcut_source # noqa: E402
CATALOG_PATH = ROOT / "docs" / "shortcut-public-catalog.json"
MONO_SKILL = ROOT / "skills" / "mono" / "SKILL.md"
SHARED_SKILL = ROOT / "skills" / "multi" / "dingtalk-shared" / "SKILL.md"
SHARED_SKILL = ROOT / "skills" / "multi" / "dws-shared" / "SKILL.md"
RUNTIME_CONTRACT_SOURCE = (
ROOT / "skills" / "multi" / "dingtalk-shared" / "references" / "runtime-contract.md"
ROOT / "skills" / "multi" / "dws-shared" / "references" / "runtime-contract.md"
)
SERVICE_TO_SKILL = {
"aitable": ROOT / "skills" / "multi" / "dingtalk-aitable" / "SKILL.md",
@@ -34,7 +34,7 @@ SERVICE_TO_SKILL = {
"calendar": ROOT / "skills" / "multi" / "dingtalk-calendar" / "SKILL.md",
"chat": ROOT / "skills" / "multi" / "dingtalk-chat" / "SKILL.md",
"contact": ROOT / "skills" / "multi" / "dingtalk-contact" / "SKILL.md",
"devapp": ROOT / "skills" / "multi" / "dingtalk-misc" / "references" / "devapp.md",
"devapp": ROOT / "skills" / "multi" / "dingtalk-dev" / "SKILL.md",
"ding": ROOT / "skills" / "multi" / "dingtalk-misc" / "references" / "ding.md",
"doc": ROOT / "skills" / "multi" / "dingtalk-doc" / "SKILL.md",
"drive": ROOT / "skills" / "multi" / "dingtalk-drive" / "SKILL.md",
@@ -154,7 +154,7 @@ def product_section(service: str, rows: list[dict[str, Any]]) -> str:
return f"""{PRODUCT_START}
## Shortcuts(无专用脚本/recipe 时优先)
以下 shortcut 同时进入公开 catalog 与 Runtime Schema。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。命令已选中时直接执行;只在参数或安全语义不确定时读取 Agent leaf Schema(例如 `dws schema --cli-path "{service} +<shortcut>" --compact --format json`),在当前 Cobra flags 不确定时读取 `dws {service} <shortcut> --help`。只有参数映射、接口绑定或 provenance 审计才省略 `--compact`。仅当现有路由和 reference 都无法定位低频能力时,才用 `dws shortcut list --service {service} --format json` 批量发现。
以下 shortcut 同时进入公开 catalog 与 Runtime Schema。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。命令已选中时直接执行;只在参数或安全语义不确定时读取 leaf Schema(例如 `dws schema --cli-path "{service} +<shortcut>" --format json`),在当前 Cobra flags 不确定时读取 `dws {service} <shortcut> --help`。仅当现有路由和 reference 都无法定位低频能力时,才用 `dws shortcut list --service {service} --format json` 批量发现。
| Shortcut | 风险 | 适用场景 |
|---|---|---|
+4 -4
View File
@@ -2,7 +2,7 @@
# Licensed under the Apache License, Version 2.0
#
# One-command installer for dws dev on native Windows (PowerShell).
# Downloads the dev binary (dws.exe) + dingtalk-misc skill (hosts open-platform app docs) from the DingTalk-Real-AI GitHub Releases.
# Downloads the dev binary (dws.exe) + dingtalk-dev skill from the DingTalk-Real-AI GitHub Releases.
#
# Usage:
# irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-devapp.ps1 | iex
@@ -20,7 +20,7 @@ $Repo = if ($env:DEVAPP_REPO) { $env:DEVAPP_REPO } else { "DingTalk-Real-A
$Version = $env:DEVAPP_VERSION
$InstallDir = if ($env:DWS_INSTALL_DIR) { $env:DWS_INSTALL_DIR } else { Join-Path $HOME ".local\bin" }
$NoSkills = $env:DWS_NO_SKILLS -eq "1"
$SkillName = "dingtalk-misc"
$SkillName = "dingtalk-dev"
function Say($m) { Write-Host " $m" }
function Die($m) { Write-Host " X $m" -ForegroundColor Red; exit 1 }
@@ -116,9 +116,9 @@ if (-not $NoSkills) {
Copy-Item -Path "$src\*" -Destination $dest -Recurse -Force
$installed++; $idx++
}
Say "Skill dingtalk-misc -> $installed agent dir(s)"
Say "Skill dingtalk-dev -> $installed agent dir(s)"
} else {
Say "(dingtalk-misc not found in skills bundle; skipped)"
Say "(dingtalk-dev not found in skills bundle; skipped)"
}
} catch {
Say "(skill install skipped: $($_.Exception.Message))"
+4 -4
View File
@@ -3,7 +3,7 @@
# Licensed under the Apache License, Version 2.0
#
# One-command installer for dws dev — pre-built binary, no build tools.
# Downloads the dev binary + dingtalk-misc skill (hosts open-platform app docs) from the DingTalk-Real-AI GitHub Releases.
# Downloads the dev binary + dingtalk-dev skill from the DingTalk-Real-AI GitHub Releases.
# Requires only curl + tar (no go / make / git).
#
# Usage:
@@ -20,7 +20,7 @@ DEVAPP_REPO="${DEVAPP_REPO:-DingTalk-Real-AI/dingtalk-workspace-cli}"
DEVAPP_VERSION="${DEVAPP_VERSION:-}"
INSTALL_DIR="${DWS_INSTALL_DIR:-$HOME/.local/bin}"
NO_SKILLS="${DWS_NO_SKILLS:-0}"
SKILL_NAME="dingtalk-misc"
SKILL_NAME="dingtalk-dev"
say() { printf ' %s\n' "$@"; }
err() { printf ' ❌ %s\n' "$@" >&2; exit 1; }
@@ -77,7 +77,7 @@ install_skill() {
for c in "$bundle/multi/$SKILL_NAME" "$bundle/skills/multi/$SKILL_NAME" "$bundle/$SKILL_NAME"; do
[ -f "$c/SKILL.md" ] && src="$c" && break
done
[ -n "$src" ] || { say " (dingtalk-misc not found in skills bundle; skipped)"; return 0; }
[ -n "$src" ] || { say " (dingtalk-dev not found in skills bundle; skipped)"; return 0; }
# cache so `dws skill setup --mode multi` can find a source later
cache="$HOME/.dws/skills/multi/$SKILL_NAME"
@@ -95,7 +95,7 @@ install_skill() {
dest="$base/$SKILL_NAME"; rm -rf "$dest"; mkdir -p "$dest"; cp -R "$src/." "$dest/"
installed=$((installed + 1)); idx=$((idx + 1))
done
say "✅ Skill dingtalk-misc → ${installed} agent dir(s)"
say "✅ Skill dingtalk-dev → ${installed} agent dir(s)"
}
main() {
+2 -2
View File
@@ -4,7 +4,7 @@
#
# One-command installer for dws personal events.
# Downloads the official dws binary and installs:
# - multi skill: dingtalk-misc (hosts personal IM event docs)
# - multi skill: dingtalk-event
# - mono skill: dws
#
# Usage:
@@ -25,7 +25,7 @@ INSTALL_DIR="${DWS_INSTALL_DIR:-$HOME/.local/bin}"
NO_SKILLS="${DWS_NO_SKILLS:-0}"
SKILLS_ONLY="${DWS_SKILLS_ONLY:-0}"
BIN_NAME="dws"
EVENT_SKILL_NAME="dingtalk-misc"
EVENT_SKILL_NAME="dingtalk-event"
MONO_SKILL_NAME="dws"
if [ "$EVENT_VERSION" = "latest" ]; then
+8 -3
View File
@@ -311,8 +311,9 @@ function Resolve-SkillMode {
if ($isInteractive) {
Write-Host ""
Write-Say "Select skill installation mode:"
Write-Say " 1) mono — install one bundled dws skill (stable / recommended)"
Write-Say " 2) multi — split each product into its own skill (run 'dws skill setup --mode multi' afterwards)"
Write-Say " 1) mono — install one bundled dws skill (stable / recommended)"
Write-Say " 2) multi 🧪 EXPERIMENTAL — split each product into its own skill (preview; run 'dws skill setup --mode multi' afterwards)"
Write-Say " ⚠ multi is not yet stable — interface, naming and cross-skill references may change"
$choice = Read-Host " Choice [1]"
switch ($choice) {
"" { $script:SkillMode = "mono" }
@@ -334,7 +335,11 @@ function Resolve-SkillMode {
function Write-MultiModeNotice {
Write-Say ""
Write-Say "Skill mode: multi — automatic skill install skipped."
Write-Say "🧪 Skill mode: multi (EXPERIMENTAL / preview) — automatic skill install skipped."
Write-Say " ⚠ multi is not yet stable. All product-scoped skills pass dispatch verifier,"
Write-Say " but interface, naming and cross-skill references may change in future releases."
Write-Say " For production / shared environments, use mono mode (--mode mono)."
Write-Say ""
Write-Say " To install split skills, run:"
Write-Say " $BinName skill setup --mode multi"
Write-Say " (One skill per product family; requires the dws binary installed above.)"
+8 -3
View File
@@ -237,8 +237,9 @@ resolve_skill_mode() {
if [ -t 0 ] && [ -t 1 ]; then
printf '\n'
say "Select skill installation mode:"
say " 1) mono — install one bundled dws skill (stable / recommended)"
say " 2) multi — split each product into its own skill (run 'dws skill setup --mode multi' afterwards)"
say " 1) mono — install one bundled dws skill (stable / recommended)"
say " 2) multi 🧪 EXPERIMENTAL — split each product into its own skill (preview; run 'dws skill setup --mode multi' afterwards)"
say " ⚠ multi is not yet stable — interface, naming and cross-skill references may change"
printf ' Choice [1]: '
read choice || choice=""
case "$choice" in
@@ -258,7 +259,11 @@ resolve_skill_mode() {
print_multi_mode_notice() {
say ""
say "Skill mode: multi — automatic skill install skipped."
say "🧪 Skill mode: multi (EXPERIMENTAL / preview) — automatic skill install skipped."
say " ⚠ multi is not yet stable. All product-scoped skills pass dispatch verifier,"
say " but interface, naming and cross-skill references may change in future releases."
say " For production / shared environments, use mono mode (--mode mono)."
say ""
say " To install split skills, run:"
say " ${BIN_NAME} skill setup --mode multi"
say " (One skill per product family; requires the dws binary installed above.)"
@@ -1,8 +0,0 @@
#!/bin/sh
# Mono↔multi skill content QA (G1–G4). See docs/skill-mono-multi-qa.md
set -eu
ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
cd "$ROOT"
exec go test -count=1 ./test/unit -run 'TestMonoMultiSkillContent'
+4 -4
View File
@@ -7,9 +7,9 @@ cd "$ROOT"
python3 scripts/gen_skill_shortcut_sections.py --check
chat_skill="skills/multi/dingtalk-chat/SKILL.md"
event_skill="skills/multi/dingtalk-misc/references/event.md"
event_skill="skills/multi/dingtalk-event/SKILL.md"
mono_skill="skills/mono/SKILL.md"
runtime_contract="skills/multi/dingtalk-shared/references/runtime-contract.md"
runtime_contract="skills/multi/dws-shared/references/runtime-contract.md"
chat_max_bytes=10000
event_max_bytes=10000
runtime_contract_max_bytes=3000
@@ -93,9 +93,9 @@ do
fi
done
if grep -Fq "../dingtalk-shared/SKILL.md" "$chat_skill"; then
if grep -Fq "../dws-shared/SKILL.md" "$chat_skill"; then
printf '%s\n' \
"skill context regression: $chat_skill requires full dingtalk-shared cold-start loading" >&2
"skill context regression: $chat_skill requires full dws-shared cold-start loading" >&2
exit 1
fi
+1 -27
View File
@@ -132,10 +132,7 @@ func run(
fmt.Fprintln(stderr, baselineErr)
return 2
}
// Compare overall non-regression on the shared file set only.
// Deleted packages (for example a retired 100%-covered helper tree)
// must not create a false overall regression against merge-base.
baselineOverall = coveragePercent(filterBlocksByFiles(baseline, profileFiles(overall)))
baselineOverall = coveragePercent(baseline)
}
}
diff, err := readProfiles(diffPaths, modulePath)
@@ -486,29 +483,6 @@ func coveragePercent(blocks []coverageBlock) float64 {
return float64(covered) * 100 / float64(total)
}
func profileFiles(blocks []coverageBlock) map[string]bool {
files := map[string]bool{}
for _, block := range blocks {
if block.File != "" {
files[block.File] = true
}
}
return files
}
func filterBlocksByFiles(blocks []coverageBlock, allowed map[string]bool) []coverageBlock {
if len(allowed) == 0 {
return nil
}
filtered := make([]coverageBlock, 0, len(blocks))
for _, block := range blocks {
if allowed[block.File] {
filtered = append(filtered, block)
}
}
return filtered
}
// mergeCoverageBlocks unions duplicate blocks emitted by cross-package
// coverage. A block is covered when any instrumented test binary executed it;
// counting the same source block once per binary would both dilute the overall
+3 -32
View File
@@ -92,7 +92,7 @@ func TestRunDefaultsToFullChangedCodeCoverage(t *testing.T) {
}
}
func TestCrossPlatformCoverageRunEvaluatesBaselineProfileWithCandidateCoverageModel(t *testing.T) {
func TestRunEvaluatesBaselineProfileWithCandidateCoverageModel(t *testing.T) {
profile := filepath.Join(t.TempDir(), "coverage.out")
body := "mode: atomic\n" +
"example.com/project/internal/a.go:10.1,12.2 5 0\n" +
@@ -101,14 +101,9 @@ func TestCrossPlatformCoverageRunEvaluatesBaselineProfileWithCandidateCoverageMo
if err := os.WriteFile(profile, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
baseline := filepath.Join(t.TempDir(), "baseline.out")
baselineBody := body + "example.com/project/internal/deleted.go:1.1,2.2 100 1\n"
if err := os.WriteFile(baseline, []byte(baselineBody), 0o600); err != nil {
t.Fatal(err)
}
args := []string{
"--overall-profile", profile,
"--baseline-profile", baseline,
"--baseline-profile", profile,
"--diff-profile", profile,
"--base-ref", "base",
"--module", "example.com/project",
@@ -120,10 +115,8 @@ func TestCrossPlatformCoverageRunEvaluatesBaselineProfileWithCandidateCoverageMo
if code := run(args, &stdout, &stderr, loader, nil); code != 0 {
t.Fatalf("run code=%d stderr=%s", code, stderr.String())
}
// Candidate overall stays 50%; merge-base ignores the deleted 100%-covered
// package so non-regression compares the shared file set only.
if !strings.Contains(stdout.String(), "overall coverage: 50.0000% (merge-base 50.0000%") {
t.Fatalf("shared-file baseline comparison failed: %q", stdout.String())
t.Fatalf("baseline and candidate did not share one coverage model: %q", stdout.String())
}
}
@@ -443,28 +436,6 @@ func TestEvaluateAllowsMeasurementTolerance(t *testing.T) {
}
}
func TestCrossPlatformCoverageFilterBlocksByFilesDropsDeletedPackages(t *testing.T) {
if filtered := filterBlocksByFiles([]coverageBlock{{File: "internal/keep.go", Statements: 1, Count: 1}}, nil); filtered != nil {
t.Fatalf("empty allowlist = %#v, want nil", filtered)
}
baseline := []coverageBlock{
{File: "internal/keep.go", StartLine: 1, EndLine: 10, Statements: 50, Count: 1},
{File: "internal/keep.go", StartLine: 11, EndLine: 20, Statements: 50, Count: 0},
{File: "internal/deleted.go", StartLine: 1, EndLine: 40, Statements: 100, Count: 1},
}
currentFiles := profileFiles([]coverageBlock{
{File: "internal/keep.go", StartLine: 1, EndLine: 8, Statements: 40, Count: 1},
})
filtered := filterBlocksByFiles(baseline, currentFiles)
got := coveragePercent(filtered)
if got != 50 {
t.Fatalf("shared-file baseline = %.4f%%, want 50%% (deleted 100%%-covered package excluded)", got)
}
if len(profileFiles(filtered)) != 1 || !profileFiles(filtered)["internal/keep.go"] {
t.Fatalf("filtered files = %#v, want only keep.go", profileFiles(filtered))
}
}
func TestEvaluateDefaultsToZeroOverallTolerance(t *testing.T) {
result := evaluate(gateInput{
Overall: []coverageBlock{{Statements: 403, Count: 1}, {Statements: 597, Count: 0}},
@@ -96,13 +96,13 @@ func TestCrossPlatformCoverageValidateManifestAcceptsExactReviewedRoute(t *testi
root := t.TempDir()
writeTestFile(t, root, "skills/multi/dingtalk-chat/SKILL.md", "| 发消息 | `dws chat +send` | <!-- dws-intent: chat.send -->\n")
writeTypedContractReference(t, root, "skills/multi/dingtalk-chat/references/contracts.md")
writeEventHandoffReference(t, root, "skills/multi/dingtalk-misc/references/event-im-output.md")
writeEventHandoffReference(t, root, "skills/multi/dingtalk-event/references/event-im-output.md")
manifest := routeManifest{
Version: 3,
MarkerRoots: []string{"skills/multi/dingtalk-chat"},
RetiredScripts: []string{"skills/multi/dingtalk-chat/scripts/retired.py"},
ContractReference: "skills/multi/dingtalk-chat/references/contracts.md",
HandoffReference: "skills/multi/dingtalk-misc/references/event-im-output.md",
HandoffReference: "skills/multi/dingtalk-event/references/event-im-output.md",
Intents: []intentRoute{{
ID: "chat.send",
PreferredTool: "chat.shortcut_send",
@@ -123,13 +123,13 @@ func TestCrossPlatformCoverageValidateManifestRejectsWrongMarkerAndSafetyDowngra
root := t.TempDir()
writeTestFile(t, root, "skills/multi/dingtalk-chat/SKILL.md", "| 发消息 | `dws chat message send` | <!-- dws-intent: chat.send -->\n")
writeTypedContractReference(t, root, "skills/multi/dingtalk-chat/references/contracts.md")
writeEventHandoffReference(t, root, "skills/multi/dingtalk-misc/references/event-im-output.md")
writeEventHandoffReference(t, root, "skills/multi/dingtalk-event/references/event-im-output.md")
manifest := routeManifest{
Version: 3,
MarkerRoots: []string{"skills/multi/dingtalk-chat"},
RetiredScripts: []string{"skills/multi/dingtalk-chat/scripts/retired.py"},
ContractReference: "skills/multi/dingtalk-chat/references/contracts.md",
HandoffReference: "skills/multi/dingtalk-misc/references/event-im-output.md",
HandoffReference: "skills/multi/dingtalk-event/references/event-im-output.md",
Intents: []intentRoute{{
ID: "chat.send",
PreferredTool: "chat.shortcut_send",
@@ -2,7 +2,7 @@
"version": 3,
"marker_roots": [
"skills/multi/dingtalk-chat",
"skills/multi/dingtalk-misc/references"
"skills/multi/dingtalk-event"
],
"retired_scripts": [
"skills/multi/dingtalk-chat/scripts/_chat_message_export_common.py",
@@ -15,7 +15,7 @@
"skills/mono/scripts/bot_broadcast.py"
],
"contract_reference": "skills/multi/dingtalk-chat/references/contracts.md",
"handoff_reference": "skills/multi/dingtalk-misc/references/event-im-output.md",
"handoff_reference": "skills/multi/dingtalk-event/references/event-im-output.md",
"intents": [
{
"id": "chat.send.dm",
@@ -148,8 +148,8 @@
"event.consume"
],
"references": [
"skills/multi/dingtalk-misc/references/event.md",
"skills/multi/dingtalk-misc/references/event-im.md"
"skills/multi/dingtalk-event/SKILL.md",
"skills/multi/dingtalk-event/references/event-im.md"
]
}
]
@@ -77,10 +77,6 @@ func run(rootPath string, root *cobra.Command, stdout, stderr io.Writer) int {
if skip || path == "" || antiCommands[path] {
continue
}
if issue := schemaProjectionIssue(ref.Text); issue != "" {
failures = append(failures, formatFailure(rootPath, ref, issue))
continue
}
if checked[path] {
continue
}
@@ -108,57 +104,6 @@ func run(rootPath string, root *cobra.Command, stdout, stderr io.Writer) int {
return 0
}
// schemaProjectionIssue keeps published Agent instructions on the bounded
// Schema projection. A targeted full leaf is intentionally reserved for
// mapping/provenance audits; those callers must select the exact fields they
// need with --jq/--fields instead of loading the full payload into context.
func schemaProjectionIssue(raw string) string {
tokens := shellFields(raw)
if len(tokens) < 2 || tokens[0] != "dws" || tokens[1] != "schema" {
return ""
}
var targeted, compact, selected, all bool
for i := 2; i < len(tokens); i++ {
token := tokens[i]
switch {
case token == "--compact":
compact = true
case token == "--all":
all = true
case token == "--cli-path":
targeted = true
if i+1 < len(tokens) {
i++
}
case strings.HasPrefix(token, "--cli-path="):
targeted = true
case token == "--jq" || token == "--fields":
selected = true
if i+1 < len(tokens) {
i++
}
case strings.HasPrefix(token, "--jq="):
selected = true
case strings.HasPrefix(token, "--fields="):
selected = true
case token == "--format" || token == "-f":
if i+1 < len(tokens) {
i++
}
case strings.HasPrefix(token, "--format="):
// Output encoding does not bound the Schema fields.
case strings.HasPrefix(token, "-"):
// Other global flags do not affect projection size.
default:
targeted = true
}
}
if targeted && !all && !compact && !selected {
return "targeted Schema queries in Agent instructions must use --compact (or an explicit --jq/--fields projection)"
}
return ""
}
func extractReferences(root string) ([]commandRef, error) {
var refs []commandRef
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
@@ -169,59 +169,3 @@ func TestIsPlaceholder(t *testing.T) {
}
}
}
func TestSchemaProjectionIssue(t *testing.T) {
testCrossPlatformCoverageSchemaProjectionIssue(t)
}
func TestCrossPlatformCoverageSchemaProjectionIssue(t *testing.T) {
testCrossPlatformCoverageSchemaProjectionIssue(t)
root := testCommandRoot()
directory := t.TempDir()
skills := filepath.Join(directory, "skills")
if err := os.MkdirAll(skills, 0o755); err != nil {
t.Fatal(err)
}
path := filepath.Join(skills, "SKILL.md")
body := "Use `dws schema calendar`.\n"
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
var stdout, stderr bytes.Buffer
if code := run(directory, root, &stdout, &stderr); code != 1 {
t.Fatalf("schema projection failure code=%d stderr=%s", code, stderr.String())
}
if !strings.Contains(stderr.String(), "--compact") {
t.Fatalf("expected compact guidance, got %q", stderr.String())
}
}
func testCrossPlatformCoverageSchemaProjectionIssue(t *testing.T) {
t.Helper()
tests := []struct {
command string
wantErr bool
}{
{command: `dws schema`, wantErr: false},
{command: `dws schema --all --format json`, wantErr: false},
{command: `dws schema calendar`, wantErr: true},
{command: `dws schema "calendar event create" -f json`, wantErr: true},
{command: `dws schema "calendar event create" --compact -f json`, wantErr: false},
{command: `dws schema --cli-path "chat +messages-send" --compact --format json`, wantErr: false},
{command: `dws schema --cli-path="chat +messages-send" --jq '.parameters'`, wantErr: false},
{command: `dws schema --cli-path="chat +messages-send" --jq='.parameters'`, wantErr: false},
{command: `dws schema "ding message send" --fields parameters`, wantErr: false},
{command: `dws schema "ding message send" --fields=parameters`, wantErr: false},
{command: `dws schema calendar --format=json`, wantErr: true},
{command: `dws schema calendar -v`, wantErr: true},
{command: `dws doc read --node <DOC_ID>`, wantErr: false},
}
for _, test := range tests {
t.Run(test.command, func(t *testing.T) {
if got := schemaProjectionIssue(test.command); (got != "") != test.wantErr {
t.Fatalf("schemaProjectionIssue() = %q, wantErr=%v", got, test.wantErr)
}
})
}
}
-165
View File
@@ -1,165 +0,0 @@
# Mono↔multi content QA contract (machine-readable).
# Spec: docs/skill-mono-multi-qa.md
# schema_version bumps only when field semantics change.
schema_version: 1
# Every top-level stem under skills/mono/references/products must appear
# in coverage or omit_coverage (file stem without .md, or directory name).
# Duplicate stems (chat.md + chat/ dir) share one row.
coverage:
- mono: aisearch
multi_skill: dingtalk-aisearch
- mono: aitable
multi_skill: dingtalk-aitable
multi_refs:
- references/aitable.md
- mono: aitable-record-ops
multi_skill: dingtalk-aitable
- mono: calendar
multi_skill: dingtalk-calendar
- mono: chat
multi_skill: dingtalk-chat
multi_refs:
- references/chat.md
- mono: chat-emoji-list
multi_skill: dingtalk-chat
multi_refs:
- references/chat-emoji-list.md
- mono: contact
multi_skill: dingtalk-contact
- mono: dev
multi_skill: dingtalk-misc
multi_refs:
- references/devapp.md
- mono: doc
multi_skill: dingtalk-doc
multi_refs:
- references/doc.md
- mono: drive
multi_skill: dingtalk-drive
- mono: event
multi_skill: dingtalk-misc
multi_refs:
- references/event.md
- mono: hrbrain
multi_skill: dingtalk-misc
multi_refs:
- references/hrbrain.md
- mono: mail
multi_skill: dingtalk-mail
- mono: markdown
multi_skill: dingtalk-misc
multi_refs:
- references/markdown.md
- mono: minutes
multi_skill: dingtalk-minutes
- mono: pat
multi_skill: dingtalk-misc
multi_refs:
- references/pat.md
- mono: todo
multi_skill: dingtalk-todo
- mono: wiki
multi_skill: dingtalk-wiki
- mono: agoal
multi_skill: dingtalk-misc
multi_refs:
- references/agoal.md
- mono: attendance
multi_skill: dingtalk-misc
multi_refs:
- references/attendance.md
- mono: attendance-report
multi_skill: dingtalk-misc
multi_refs:
- references/attendance-report.md
- mono: attendance-schedule
multi_skill: dingtalk-misc
multi_refs:
- references/attendance-schedule.md
- mono: attendance-vacation
multi_skill: dingtalk-misc
multi_refs:
- references/attendance-vacation.md
- mono: ding
multi_skill: dingtalk-misc
multi_refs:
- references/ding.md
- mono: devdoc
multi_skill: dingtalk-misc
multi_refs:
- references/devdoc.md
- mono: oa
multi_skill: dingtalk-misc
multi_refs:
- references/oa.md
- mono: report
multi_skill: dingtalk-misc
multi_refs:
- references/report.md
- mono: sheet
multi_skill: dingtalk-misc
multi_refs:
- references/sheet.md
- mono: whiteboard
multi_skill: dingtalk-misc
multi_refs:
- references/whiteboard.md
omit_coverage:
- mono: simple
disposition: covered_by
via: dingtalk-misc
reason: "mono simple.md 为 oa/devdoc 合集;已拆入 dingtalk-misc references/oa.md 与 devdoc.md"
# Global protocol files: multi path must exist unless omit_global.
global_protocols:
- id: error-codes
multi_path: dingtalk-shared/references/error-codes.md
- id: url-patterns
multi_path: dingtalk-shared/references/url-patterns.md
- id: capability-limits
multi_path: dingtalk-shared/references/capability-limits.md
- id: channel-login
multi_path: dingtalk-shared/references/channel-login.md
- id: global-reference
multi_path: dingtalk-shared/references/global-reference.md
- id: intent-guide
multi_path: dingtalk-shared/references/intent-guide.md
- id: runtime-contract
multi_path: dingtalk-shared/references/runtime-contract.md
- id: confirmation-protocol
multi_path: dingtalk-shared/references/confirmation.md
- id: schema-usage
multi_path: dingtalk-shared/references/schema-usage.md
omit_global:
- id: field-rules-global
mono_path: references/field-rules.md
expected_multi: dingtalk-aitable/references/field-rules.md
disposition: covered_by
reason: "AI 表格字段规则已下沉到 dingtalk-aitable;G3/coverage 不强制全局同名"
# Byte-identical pairs (content surfaces that must stay in sync).
paired_files:
- mono: references/products/whiteboard/recipes.md
multi: dingtalk-misc/references/whiteboard/recipes.md
mode: identical
# Scripts under skills/multi/*/scripts not referenced by any .md in that skill tree.
# yida/finance/aiapp are referenced via dingtalk-misc/references/unsupported-scripts.md
orphan_scripts_allowlist:
- path: dingtalk-aitable/scripts/aitable_import_via_task.py
disposition: defer
reason: "helper referenced indirectly / pending doc"
- path: dingtalk-minutes/scripts/minutes_list_parse.py
disposition: defer
reason: "pending doc reference"
- path: dingtalk-misc/scripts/report_received_today.py
disposition: defer
reason: "pending report.md reference"
# Skills allowed to omit references/ directory.
skills_without_references_allowlist: []
+1 -1
View File
@@ -23,7 +23,7 @@ import "embed"
// FS holds the peer mono and multi installable skill trees. Schema-generation
// inputs live under internal/cli and are intentionally not embedded. The all:
// prefix keeps underscore-prefixed directories (for example,
// mono/references/best_practices/_common) that plain embed patterns would skip.
// references/best_practices/_common) that plain embed patterns would skip.
//
//go:embed all:mono all:multi
var FS embed.FS
+24 -19
View File
@@ -9,7 +9,7 @@ cli_version: ">=1.0.15"
通过 `dws` 命令管理钉钉产品能力。
> **命令可用性以当前 dws 二进制为准**。本文档随内置 skill 发布,可能滞后于二进制;如果 `dws <cmd> --help` 不存在,说明当前版本未暴露该命令。`--help` 决定 Cobra 实际接受的 flags;公开基础命令和内建 `+` shortcut 的 leaf Schema(常规用 `--compact`)决定 Agent 选择、参数/约束和安全确认语义。实际调用前可用 `dws <cmd> --help` 或 `--dry-run` 验证。
> ⚠️ **命令可用性以当前 dws 二进制为准**。服务发现已下线,本文档随内置 skill 发布;如果 `dws <cmd> --help` 不存在,说明当前版本未暴露该命令。`--help` 决定 Cobra 实际接受的 flags;公开基础命令和内建 `+` shortcut 的 leaf Schema 决定 Agent 选择、参数映射/约束和安全确认语义。若命令存在但调用失败,请按错误中的 endpoint 或 tool 提示确认静态端点目录和后端工具注册。实际调用前可用 `dws <cmd> --help` 或 `--dry-run` 验证。
## 严格禁止 (NEVER DO)
- 不要使用 dws 命令以外的方式操作(禁止 curl、HTTP API、浏览器)
@@ -29,7 +29,7 @@ cli_version: ">=1.0.15"
`shortcut` 是对常用操作的高层封装,适合优先承担用户意图;产品参考文档和本 skill 负责判断意图、风险、跨产品流程和复杂参数,CLI 帮助负责声明当前版本真正可调用的命令。
- 先按产品参考、意图表和 recipe 路由。用户意图可由可见 Shortcut 满足时,优先使用 `dws <service> +<verb> ... --format json`,不要手写等价的多步原子命令。只有脚本明确补足 Shortcut 未覆盖的复合交付物且其安全/完整性契约仍适用时才选择脚本。
- 公开内建 shortcut 同时进入 Runtime Schema。用 `dws schema --cli-path "<service> +<verb>" --compact --format json` 读取 Agent 选择、参数、跨参数约束和 risk/confirmation;只有参数映射、接口绑定或 provenance 审计才通过 `--jq` 精确读取 full leaf;`dws shortcut list --service <service> --format json` 只作为轻量批量发现入口。
- 公开内建 shortcut 同时进入 Runtime Schema。用 `dws schema --cli-path "<service> +<verb>" --format json` 读取 Agent 选择、参数、跨参数约束、risk/confirmation 与接口语义;`dws shortcut list --service <service> --format json` 只作为轻量批量发现入口。
- 真正组装参数前用叶子帮助 `dws <service> +<verb> --help` 核对当前 Cobra 接受的 flags。父级 `dws <service> --help` 只能发现子命令,不能替代叶子参数帮助。
- shortcut catalog 中 `confirmation=user_required` 时,必须先获得用户确认,确认后才加 `--yes`;`not_required` 不额外确认。
- 如果 shortcut 不在 help / list 中,改用产品参考里的原子命令、脚本或标准流程;不要猜测未展示的 `+` 命令。
@@ -48,7 +48,7 @@ cli_version: ">=1.0.15"
| `calendar` | 20 | `dingtalk-calendar` |
| `chat` | 98 | `dingtalk-chat` |
| `contact` | 14 | `dingtalk-contact` |
| `devapp` | 19 | `dingtalk-misc` |
| `devapp` | 19 | `dingtalk-dev` |
| `ding` | 4 | `dingtalk-misc` |
| `doc` | 41 | `dingtalk-doc` |
| `drive` | 7 | `dingtalk-drive` |
@@ -73,6 +73,7 @@ cli_version: ">=1.0.15"
| 产品 | 用途 | 参考文件 |
|-------------------|------------------------------------------------------|----------------------------------------------------------------|
| `aiapp` | AI应用:创建/查询/修改AI应用 | [aiapp.md](./references/products/aiapp.md) |
| `aisearch` | AI搜问(通用找人首选):按姓名/部门/职位/职责/上级/下级/手机号/工号维度找人,"谁负责 XX/XX 的负责人/某事项/某项目的人"统一走本产品;不含人才池/绩效/职业历程等专项 HR 场景(那些去 `hrbrain`) | [aisearch.md](./references/products/aisearch.md) |
| `aitable` | AI表格:Base/数据表/字段/记录/视图/附件/图表/仪表盘/导入导出/模板搜索 | [aitable.md](./references/products/aitable.md) |
| `attendance` | 考勤:打卡结果/打卡流水/考勤组查询/考勤规则/汇总统计/假期类型/假期余额(P0 已落地,部分管理类命令仍属 P1) | [attendance.md](./references/products/attendance.md) |
@@ -98,7 +99,7 @@ cli_version: ">=1.0.15"
## 意图判断决策树
用户提到"AI应用/创建应用/生成系统/做工具/管理后台/低代码/宜搭" → **当前无稳定产品参考**(勿猜 `aiapp` 命令);向用户说明能力未以产品文档发布,multi 布局见 `dingtalk-misc` 的 `unsupported-scripts.md`
用户提到"AI应用/创建应用/生成系统/做工具/管理后台/低代码" → `aiapp`
用户提到"目标管理/Agoal/战略解码/经营合约/计分卡/目标模板/周月报提交统计" → `agoal`
用户提到"找人/搜人/谁负责 XX/某事项的负责人/某项目的人/团队成员/上级/下级/按工号找人/按手机号找人" → `aisearch`(通用语义找人;若明确涉及人才池/绩效/职业历程/结构化高级条件,去 `hrbrain`)
用户提到"表格/多维表/AI表格/记录/数据/视图/图表/仪表盘" → `aitable`
@@ -206,19 +207,22 @@ Step 3 → 加 --yes 执行命令
**已知命令路径例外**:当本 Skill、产品意图表或任务 reference 已经给出精确 CLI path 时,不要再查询产品级/分组级 Schema,也不要调用完整 Shortcut Catalog;可直接执行。只有参数、约束或安全语义不确定时才读取该命令的 leaf Schema,只有当前 Cobra flags 不确定时才补读 leaf Help。
稳定 command identity、主 CLI path 和 alias 由 leaf `ContractFinal.Identity` 与真实 Cobra tree 精确绑定。Agent 不应读取 Catalog 文件、native annotation 或其他生成 JSON 来重新推断命令;所有运行时查询都以当前二进制交付的 Schema 投影为准。
稳定 command identity、主 CLI path 和 alias 已在构建时由 reviewed registry 与真实 Cobra tree 精确绑定。Agent 不应读取 Catalog 文件、native annotation 或其他生成 JSON 来重新推断命令;所有运行时查询都以当前二进制交付的 Schema 投影为准。
```bash
# 第 1 层:产品概览(~4.5KB,列出全部产品 + 工具数 + 用途摘要)
dws schema
# 第 2 层:产品级(列出该产品下全部工具的 cli_path + description + effect/risk)
dws schema calendar --compact
dws schema calendar
# 第 3 层:分组级(按命令分组列出工具摘要)
dws schema "calendar event" --compact
dws schema "calendar event"
# 第 4 层:Agent leaf(参数契约:type/required/description/constraints/examples)
# 第 4 层:完整 leaf(参数契约:type/required/description/constraints/examples)
dws schema "calendar event create"
# --compact:当前支持;去除 provenance/debug 字段,仅保留 Agent 选参所需
dws schema "calendar event create" --compact
# --all:导出所有工具的完整 leaf Schema,仅用于 CI / 审计 / 参数 baseline
@@ -227,9 +231,9 @@ dws schema --all --format json
**`--all` 使用边界(强制)**:`--all` 会返回每个工具的完整参数、约束和安全语义,输出体积很大。仅在用户明确要求全量导出,或执行 CI、Catalog 审计、参数防丢 baseline 时使用。普通业务任务严禁使用 `--all` 做命令发现,也不要把全量结果直接注入 Agent 上下文;必须按“产品概览 → 产品/分组 → leaf”渐进查询。完整兼容性 baseline 必须使用未裁剪的 `schema --all`;`schema --all --compact` 会移除 provenance 和接口映射字段,不得作为完整 baseline。
同一个工具省略 `--compact` 的 full leaf 与 `--all` 条目是同一份 `ToolSpec` 契约;compact leaf 只做展示投影,不重新解析语义。Alias 查询不得根据 alias 重写或补猜参数。若同一视图观察到内容差异,应作为契约漂移报告,而不是选择其中一份继续执行。
同一个工具的 leaf 查询与 `--all` 条目是同一份 `ToolSpec` 契约的投影,参数、安全和接口语义必须一致。Alias 查询只改变路径视图;不得根据 alias 重写或补猜参数。若观察到内容差异,应作为契约漂移报告,而不是选择其中一份继续执行。
**`--compact` Agent 模式**采用正向字段白名单。保留 `cli_path`、`canonical_path`、`description`、`effect`、`risk`、`confirmation`、`interface_mode`、`availability`、`interface_reason`、`parameters`(含 `type`/`required`/`description`/`default`/`enum`)、`constraints`、`examples`、`use_when`、`avoid_when`;新增 full/audit 字段不会自动泄漏进 Agent 上下文。它有意不返回 `interface_ref`、参数 `property/interface_type` 和 provenance;检查这些映射事实时,用 full leaf 配合 `--jq` 精确投影。
**--compact 模式**去掉的字段:`agent_metadata_source`、`agent_source_refs`、`agent_summary_source`、`effect_source`、`metadata_source`、`interface_ref`、`interface_description`、`property`、`primary_cli_path`、`parameter_count` 等 provenance/debug 字段。保留的字段:`cli_path`、`canonical_path`、`description`、`effect`、`risk`、`confirmation`、`interface_mode`、`availability`、`interface_reason`、`parameters`(含 `type`/`required`/`description`/`default`/`enum`)、`constraints`、`examples`、`use_when`、`avoid_when`。
`--compact` 是 Schema 展示能力。当前版本支持;若兼容旧二进制时收到 `unknown_flag: --compact`,仅去掉 `--compact` 重跑同一个 Schema 查询。不要因此判定 leaf 不存在,也不要改用 Schema 查询业务数据。
@@ -268,8 +272,7 @@ dws schema --all --format json
| 信息 | 事实源 |
|------|--------|
| 命令是否存在、当前 Cobra 接受哪些 flags | `dws <cli_path> --help` |
| Agent 选择、CLI 参数/required/组合约束、risk/confirmation(原子/基础命令) | `dws schema "<cli_path>" --compact` |
| CLI↔RPC 参数映射、接口绑定或 provenance 审计 | full leaf 配合 `--jq` / `--fields` 精确投影;不要把整个 full leaf 注入 Agent 上下文 |
| Agent 选择、参数映射/required/组合约束、risk/confirmation(原子/基础命令) | `dws schema "<cli_path>"`(按需加 `--compact`) |
| shortcut 的参数、组合约束、risk/confirmation、示例 | 已知路径优先 `dws schema --cli-path "<service> +<shortcut>" --compact --format json`;完整 `shortcut list` 仅用于无法定位低频能力时的最后回退 |
| 人类可读用法 | `dws <cli_path> --help` |
| 钉钉中的文档、文件、日程、消息等实际数据 | 真正执行对应的 `read` / `search` / `list` 命令 |
@@ -284,18 +287,19 @@ Schema 与 Help 冲突是**契约漂移**,不得静默猜测或把两边字段
### Helper-only 与本地 Cobra 命令
`dev.*` 包含 helper-only 执行面,其中远端 helper 未进入 pinned metadata 时标记为 `composite`,不能伪装成 `local`。`event list` / `event schema` 读取内置目录和 payload 定义,属于 `local`;`event consume` / `event status` / `event stop` 同时编排远端个人订阅控制面与本地 bus/consume,属于 `composite`。实现来源不同,不改变统一查询边界:进入全局 `dws schema` 的命令须由 leaf `ContractFinal.Identity` 声明收集,并由同一 `ToolSpec` 投影到 leaf、产品/分组、`--all` 与 Catalog。不得把 Cobra 临时合成结果作为第二条 Schema 数据路径。
`dev.*` 包含 helper-only 执行面,其中远端 helper 未进入 pinned metadata 时标记为 `composite`,不能伪装成 `local`。`event list` / `event schema` 读取内置目录和 payload 定义,属于 `local`;`event consume` / `event status` / `event stop` 同时编排远端个人订阅控制面与本地 bus/consume,属于 `composite`。实现来源不同,不改变统一查询边界:进入全局 `dws schema` 的命令必须先进入 reviewed CommandRegistry,并由同一 `ToolSpec` 投影到 leaf、产品/分组、`--all` 与 Catalog。不得在查询时重新调用 MCP `tools/list`,也不得把 Cobra 临时合成结果作为第二条 Schema 数据路径。
事件需要区分两种 Schema:`dws event schema <event_key> --flatten` 查询 Agent 要消费的顶层业务字段;`dws schema "event consume" --compact` 查询 CLI 命令参数。前者是真实业务命令,后者只读取最终内嵌 SchemaRegistry;不能相互替代。
事件需要区分两种 Schema:`dws event schema <event_key> --flatten` 查询 Agent 要消费的顶层业务字段;`dws schema "event consume"` 查询 CLI 命令参数。前者是真实业务命令,后者只读取最终内嵌 SchemaRegistry;不能相互替代。
`source` 表示最终命令 identity 的来源,不表示运行时 backing;helper/local/MCP 实现机制读取 `interface_mode`、`availability` 和 provenance,不要假定 `dev.*` 必然是 `source=mcp:<server>`,也不要假定本地命令必然是 `source=cobra`。
## 错误处理
1. 先读取 JSON 错误的 `retryable`、`retry_after_seconds`、`next_retry_at`、`hint` 和 `actions`;只有明确 `retryable=true` 时才按服务端节奏做一次有界重试。缺少重试语义时加 `--verbose` 收集诊断后停止
2. 仍然失败,报告完整错误信息给用户,禁止自行尝试替代方案
3. 认证失败时,参考 [global-reference.md](./references/global-reference.md) 中的认证章节处理
4. 各产品高频错误及排查流程见 [error-codes.md](./references/error-codes.md)
5. 遇到 [capability-limits.md](./references/capability-limits.md) 中列出的「已知不支持操作」时,**直接告知用户不支持并建议在钉钉客户端操作**,不要重试或变通
2. 若 stderr 出现 `RECOVERY_EVENT_ID=<event_id>`,优先按 [recovery-guide.md](./references/recovery-guide.md) 执行 recovery 闭环
3. 仍然失败,报告完整错误信息给用户,禁止自行尝试替代方案
4. 认证失败时,参考 [global-reference.md](./references/global-reference.md) 中的认证章节处理
5. 各产品高频错误及排查流程见 [error-codes.md](./references/error-codes.md)
6. 遇到 [capability-limits.md](./references/capability-limits.md) 中列出的「已知不支持操作」时,**直接告知用户不支持并建议在钉钉客户端操作**,不要重试或变通
## 详细参考 (按需读取)
@@ -306,7 +310,8 @@ Schema 与 Help 冲突是**契约漂移**,不得静默猜测或把两边字段
- [references/global-reference.md](./references/global-reference.md) — 全局标志、认证、输出格式
- [references/field-rules.md](./references/field-rules.md) — AI表格字段类型规则
- [references/error-codes.md](./references/error-codes.md) — 错误码 + 调试流程
- [scripts/](./scripts/) — 各产品批量/复合操作脚本(AI表格批量导入导出、日历、机器人消息、通讯录、考勤、日志、待办、文档创建并写入、钉盘目录树等)
- [references/recovery-guide.md](./references/recovery-guide.md) — recovery 闭环、`RECOVERY_EVENT_ID`、`execute/finalize` 规范
- [scripts/](./scripts/) — 各产品批量/复合操作脚本(AI表格批量导入导出、AI应用创建轮询、日历、机器人消息、通讯录、考勤、日志、待办、文档创建并写入、钉盘目录树等)
- [references/products/aitable/](./references/products/aitable/) — AI表格细分章节(单元格值/字段属性/公式/筛选排序/导入导出/仪表盘/记录增删改查/错误恢复/最佳实践)
- [references/products/aitable-record-ops.md](./references/products/aitable-record-ops.md) — AI表格记录操作专项说明
- [references/products/pat.md](./references/products/pat.md) — PAT 浏览器策略、行为 scope 预览与授权安全要求

Some files were not shown because too many files have changed in this diff Show More