Compare commits

...
Author SHA1 Message Date
chichuan 10fe258e4b ci: shard helper changes through full suite 2026-08-11 10:54:42 +08:00
github-actions[bot] 22ab166c9b Merge pull request #905 from wxianfeng/feat/dws-event-oa
feat(event): support personal OA approval events
2026-08-11 02:46:51 +00:00
炳昱 4a4a1e0407 Merge branch 'main' of https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli into feat/dws-event-oa 2026-08-11 10:18:55 +08:00
修雨 730d3fa27f Merge pull request #941 from DingTalk-Real-AI/codex/issue-940-stdio-idempotency-race-budget
test(transport): widen stdio idempotency race budget
2026-08-11 09:06:58 +08:00
修雨 c295027e84 Merge main into test/transport race budget candidate 2026-08-10 23:47:12 +08:00
github-actions[bot] 24437fc1a5 Merge pull request #921 from DingTalk-Real-AI/codex/interface-migration-governance
ci: govern exact CLI flag migrations
2026-08-10 23:43:19 +08:00
chichuan e9850a2e49 fix(ci): enforce stable Schema compatibility 2026-08-10 23:06:13 +08:00
chichuan 8097943e3a Merge branch 'main' into codex/interface-migration-governance 2026-08-10 22:31:16 +08:00
github-actions[bot] d5a9a72fa6 Merge pull request #931 from DingTalk-Real-AI/fix/schema-compat-policy-int
ci(schema): allow reviewed parameter type migrations
2026-08-10 22:22:35 +08:00
炳昱 6f73e5187a Merge official main into feat/dws-event-oa 2026-08-10 22:21:25 +08:00
chichuan b7918be6f3 fix(ci): require stable flag migration reference 2026-08-10 22:16:00 +08:00
chichuan b84b56d9f8 Merge origin/main into interface migration governance 2026-08-10 22:03:18 +08:00
chichuan e66cd95c51 Merge remote-tracking branch 'origin/main' into fix/schema-compat-policy-int 2026-08-10 21:59:55 +08:00
chichuan 7e8b216e07 ci(schema): compare the full parameter contract for reviewed type migrations
Auto-CR (P1) correctly flagged that the carve-out's "nothing else changed"
guard was keyed on len(otherFailures) == 0, which only observes changes the
gate already judges incompatible. Several parameter contract changes are
individually compatible and so produce no failure at all: relaxing required or
cli_required, clearing required_when, widening enum, clearing interface_type,
and clearing property through a reviewed mapping exclusion. Any of those could
have ridden along with a reviewed type migration, leaving the exemption wider
than both its documentation and what the entry actually reviewed.

Replace the failure-list heuristic with a real equality check over every
published field except Type. Comparing the struct also means a field added to
parameterSchema later is covered automatically, instead of silently widening
every existing entry. The type check moves back ahead of the field loop because
it no longer needs to observe the other findings.

Add a rejection case for each individually-compatible direction. Each case first
asserts that the drift alone really is compatible, so it keeps exercising the
equality guard instead of quietly duplicating one of the incompatible-bundle
cases.

Verified against the previous implementation: with the old guard all six new
cases fail while the nine incompatible-bundle cases still pass, which is exactly
the gap that was reported.
2026-08-10 21:52:58 +08:00
github-actions[bot] 5501c9f1a5 Merge pull request #933 from pengzhihan47-star/codex/doc-shortcut_and_skill_opt
feat(doc): harden dingtalk-doc shortcuts, contracts, and verification
2026-08-10 21:11:23 +08:00
修雨 af71efd253 test(transport): widen stdio idempotency race budget
Refs #940

Authority: https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/issues/940

Assignment: https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/issues/940#issuecomment-5239203628
2026-08-10 18:58:21 +08:00
炳昱 a9c0e0409c Merge remote-tracking branch 'official/main' into feat/dws-event-oa 2026-08-10 17:58:42 +08:00
炳昱 9616441e54 fix(skill): migrate retired shared skill 2026-08-10 17:58:33 +08:00
柏智 eefe6f05e1 fix(schema): review doc import constraint transition 2026-08-10 17:49:27 +08:00
柏智 6598292b1b fix(doc): allow import to default root 2026-08-10 17:32:23 +08:00
柏智 dea637228d fix(doc): preserve schema compatibility after review 2026-08-10 17:32:23 +08:00
柏智 a09467f1eb fix(doc): address PR 906 review feedback 2026-08-10 17:32:23 +08:00
柏智 34feb348af feat(doc): harden shortcuts and skill routing 2026-08-10 17:32:23 +08:00
chichuan 6b1a1a6201 Merge branch 'main' into fix/schema-compat-policy-int 2026-08-10 17:19:54 +08:00
github-actions[bot] 9f60cdeef1 Merge pull request #920 from Anonymity-0/feat/card-reply-mentions
feat(chat): support mentions in streaming card creation
2026-08-10 17:06:24 +08:00
前津 258caa5906 fix(chat): fail safely when card mention tag is missing 2026-08-10 16:39:51 +08:00
前津 a0d59a79aa feat(chat): prepend card mention tag to content 2026-08-10 16:39:51 +08:00
前津 c7148f3ebb docs(chat): clarify streaming card mention content 2026-08-10 16:39:51 +08:00
前津 ef29b48a55 fix(chat): keep skill within context budget 2026-08-10 16:39:51 +08:00
前津 5c33ea526e feat(chat): support mentions in streaming card creation 2026-08-10 16:39:51 +08:00
chichuan 6d3b54b25f Merge branch 'main' into fix/schema-compat-policy-int 2026-08-10 16:37:45 +08:00
chichuan fd0c3350f3 ci(schema): allow reviewed parameter type migrations
schema-compatibility is the third check in the same Interface Integrity job,
after the two CLI interface gates. It also rejected every published parameter
type change outright. Because the earlier gates failed first and `set -e`
stopped the step from ever running, this one never surfaced in CI, so the
previous exemption only covered two thirds of the problem.

checkParameterCompatibility now consults a precise allowlist: the tool path,
parameter name and both type values must match exactly, making it
direction-sensitive by construction, and it applies only when nothing else the
gate checks about the parameter moved (default, interface_default, format,
property, interface_type, required, cli_required, required_when, enum). The type
check moved to the end of the function so the carve-out can see those findings;
ordering is unobservable because the result is sorted.

The only entry is "minutes/minutes.apply_minutes_permission" parameter "policy"
migrating from "string" to "integer" (for #912). That type is projected from the
Cobra flag type (provenance cobra_flag_type), so it describes how the CLI accepts
a value. Consumers build a command line from it, and "--policy 4" is the same
argv under either declaration — a quoted "--policy \"4\"" still reaches pflag as
4 — while RunE keeps enforcing the same [2,4] domain. The parameter maps to
property "policyId", which the command has always sent as a number, so "integer"
is closer to the actual request than "string" was.

Table values must be the canonical form schemaType emits: the JSON encoding of
the type keyword, so `"string"` with its quotes rather than a bare string. The
guard test recomputes both through schemaType and checks the decoded name
against the closed JSON Schema type set — reviewedInterfaceRefRedirect was
silently disabled twice by exactly this class of spelling mistake.
2026-08-10 15:57:15 +08:00
炳昱 0e14f69aae Merge commit '6575301a3a7fef264f0550185a0bee13087be729' of https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli into feat/dws-event-oa 2026-08-10 15:43:37 +08:00
炳昱 9f14035483 test(event): cover subscription and migration failures 2026-08-10 15:42:44 +08:00
炳昱 8daf5c71cd Merge commit '93a20718372f434f9eda84850df816a7c29c34fc' of https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli into feat/dws-event-oa 2026-08-10 15:03:39 +08:00
chichuan 64e1dcc150 test: make temp failure portable on Windows 2026-08-10 11:36:53 +08:00
chichuan f3ecac1ad1 ci: satisfy workflow shell lint 2026-08-10 11:14:23 +08:00
chichuan 46ae1c50fe ci: govern exact CLI flag migrations 2026-08-10 10:46:20 +08:00
wxianfeng 1ee37ec4c2 fix(event): harden subscription reuse and skill migration 2026-08-07 18:46:25 +08:00
wxianfeng 7e27fa384a Merge remote-tracking branch 'upstream/main' into feat/dws-event-oa 2026-08-07 16:47:17 +08:00
wxianfeng 8bf6c15fad feat(event): restore standalone event skill 2026-08-07 16:38:30 +08:00
炳昱 c803cf7eeb fix(event): validate reused OA subscriptions in dry-run 2026-08-07 15:30:03 +08:00
炳昱 832d3ab886 test(event): cover OA validation branches 2026-08-07 14:58:52 +08:00
wxianfeng 47f303d3fc Merge remote-tracking branch 'origin/feat/dws-event-oa' into feat/dws-event-oa 2026-08-07 14:56:28 +08:00
wxianfeng 1199240a36 Merge remote-tracking branch 'upstream/main' into feat/dws-event-oa
# Conflicts:
#	skills/mono/references/products/event.md
#	skills/multi/dingtalk-misc/references/event-oa.md
#	skills/multi/dingtalk-misc/references/event.md
2026-08-07 14:48:37 +08:00
炳昱 354d39a6f1 Merge branch 'main' of https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli into feat/dws-event-oa
# Conflicts:
#	skills/mono/references/products/event.md
#	skills/multi/dingtalk-misc/references/event-oa.md
#	skills/multi/dingtalk-misc/references/event.md
2026-08-07 14:23:12 +08:00
炳昱 6704eda83a fix(event): switch personal event defaults to production 2026-08-07 10:22:32 +08:00
wxianfeng b581426488 Merge remote-tracking branch 'upstream/main' into feat/dws-event-oa
# Conflicts:
#	internal/app/event_personal_command.go
#	internal/cli/schema_agent_metadata/index.json
#	internal/cli/schema_agent_metadata_audit.json
#	internal/cli/schema_catalog.json
#	internal/cli/schema_hints/index.json
#	internal/cli/schema_hints/reference-review.json
#	skills/mono/SKILL.md
#	skills/mono/references/products/event.md
#	skills/multi/dingtalk-event/SKILL.md
2026-08-05 22:43:36 +08:00
炳昱 703406df13 feat(event): publish typed OA approval schemas 2026-07-29 22:23:13 +08:00
炳昱 753d538140 feat(event): complete personal OA approval events 2026-07-28 21:38:17 +08:00
wxianfeng f890dda7e7 feat(event): add personal OA approval events
Use the Event-specific pre-release control and stream ticket endpoints by default.
2026-07-28 16:15:18 +08:00
wxianfeng c870d2ebdc Merge remote-tracking branch 'upstream/main' 2026-07-28 10:52:41 +08:00
130 changed files with 18212 additions and 3449 deletions
+29 -17
View File
@@ -148,6 +148,7 @@ jobs:
filename === '.github/actionlint.yaml' ||
filename.startsWith('scripts/') ||
filename.startsWith('verify/') ||
filename.startsWith('internal/helpers/') ||
filename.startsWith('internal/generator/') ||
filename.startsWith('internal/cli/schema') ||
filename.startsWith('internal/interfacesnapshot/') ||
@@ -1370,36 +1371,47 @@ jobs:
if [ -z "$base_ref" ] || [ "$base_ref" = "0000000000000000000000000000000000000000" ]; then
base_ref="$(git rev-parse HEAD^)"
fi
candidate_ref="$(git rev-parse 'HEAD^{commit}')"
if [ "$GITHUB_EVENT_NAME" = "pull_request" ] && [ "$candidate_ref" != "$PR_HEAD_SHA" ]; then
echo "Compatibility checkout $candidate_ref does not match PR head $PR_HEAD_SHA" >&2
exit 1
fi
git rev-parse --verify "${base_ref}^{commit}" >/dev/null
stable_ref="$(git tag --merged "$base_ref" --list 'v[0-9]*' --sort=-version:refname | awk 'index($0, "-") == 0 { print; exit }')"
. ./scripts/release/release-lib.sh
stable_ref=""
for tag in $(git tag --merged "$base_ref" --list 'v*' --sort=-version:refname); do
release_is_stable_version "$tag" || continue
if git rev-parse --verify --quiet "refs/tags/withdrawn/$tag" >/dev/null; then
continue
fi
stable_ref="$tag"
break
done
if [ -z "$stable_ref" ]; then
echo "No stable release tag is reachable from compatibility base $base_ref" >&2
exit 1
fi
git rev-parse --verify "${stable_ref}^{commit}" >/dev/null
echo "COMPATIBILITY_BASE_REF=$base_ref" >> "$GITHUB_ENV"
echo "COMPATIBILITY_STABLE_REF=$stable_ref" >> "$GITHUB_ENV"
printf '%s\n' \
"COMPATIBILITY_BASE_REF=$base_ref" \
"COMPATIBILITY_STABLE_REF=$stable_ref" \
"COMPATIBILITY_CANDIDATE_REF=$candidate_ref" >> "$GITHUB_ENV"
- name: Check historical commands and help compatibility
- name: Check historical commands, help, and complete CLI compatibility
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
run: |
make authoritative-interface-integrity \
BASE_REF="$COMPATIBILITY_BASE_REF"
if [ "$(git rev-parse "${COMPATIBILITY_BASE_REF}^{commit}")" != "$(git rev-parse "${COMPATIBILITY_STABLE_REF}^{commit}")" ]; then
make authoritative-interface-integrity \
BASE_REF="$COMPATIBILITY_STABLE_REF"
fi
- name: Check complete CLI command compatibility
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
run: |
./scripts/policy/check-command-compatibility.sh \
--base-ref "$COMPATIBILITY_BASE_REF" \
--stable-ref "$COMPATIBILITY_STABLE_REF"
BASE_REF="$COMPATIBILITY_BASE_REF" \
STABLE_REF="$COMPATIBILITY_STABLE_REF" \
CANDIDATE_REF="$COMPATIBILITY_CANDIDATE_REF"
- name: Check complete Schema compatibility
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
run: make schema-compatibility BASE_REF="$COMPATIBILITY_BASE_REF"
run: |
make schema-compatibility \
BASE_REF="$COMPATIBILITY_BASE_REF" \
STABLE_REF="$COMPATIBILITY_STABLE_REF" \
CANDIDATE_REF="$COMPATIBILITY_CANDIDATE_REF"
- name: Check skill command references
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
+8
View File
@@ -6,6 +6,13 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
### Added
- **Streaming-card mentions** — `chat +messages-send-card` now accepts
`--at-open-dingtalk-ids` and `--at-all` for group cards, passing mention
targets to the initial card-creation request and prepending its returned
`atTag` to the automatic streaming update.
## [1.0.58-beta.2] - 2026-08-10
### Added
@@ -32,6 +39,7 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
- **`sheet range batch-clear` / `batch-set-style` 的 `--ranges` 拒绝空白工作表前缀**(用户可见行为变更)— 此前只按原始串里 `!` 的位置判断,`" !A1:B2"` 修剪后工作表名成了空串,操作却照样带着 `sheetId: ""` 提交:服务端要么让整批 `batch_update` 失败,要么更糟——落到默认工作表而不是用户指定的那张表,且命令报成功。现在工作表名与范围都必须在修剪之后仍非空,否则在发起任何请求之前报错。`batch-set-style --batch` 的纯空白 `sheetId` / `range` 同样拒绝(此前只挡空字符串);`--batch` 下发仍用原值不替用户修剪,因为 `sheetId` 可以是允许带首尾空格的工作表**名**。两条 `--ranges` 路径现在共用同一个拆分器。
- **`sheet insert-dimension` / `delete-dimension` / `update-dimension` 的 `--length` 严格校验**(用户可见行为变更)— 解析由 `fmt.Sscanf("%d")` 改为 `strconv.Atoi`。此前只消费前缀数字,`--length 2x` / `3foo` 会被静默当成 `2` / `3` 并对错误的行列数执行操作(删除方向不可回滚);现在整个值必须是合法正整数,否则报错「`--length` 必须为正整数(>= 1)」且不发起任何请求。**升级影响**:原先依赖这种宽松解析、在传畸形 `--length` 的脚本会开始报错,请把参数修正为纯数字。合法数字值行为不变,上限仍为 5000。`add-dimension` 的 `--length` 是 `Int` 类型 flag,一直由 cobra 严格校验,不受影响。
- **CLI 接口兼容门禁支持 reviewed flag 类型豁免**(无用户可见变更)— `authoritative-interface-integrity` 与 `check-command-compatibility.sh` 此前一律拒绝历史命令的 flag 类型变更,即使新类型只是把同一套校验从 RunE 前移到解析期,也没有任何评审通道。现在两道门禁各带一张精确豁免表:命令路径 + flag 名 + 旧类型 → 新类型四元组全等才命中、方向敏感(`string`→`int` 与 `int`→`string` 是两个不同的键,只有被评审的方向可用),且仅当该 flag 的其他契约(shorthand / required / hidden / no-opt / scope)纹丝不动时才放行,因此豁免夹带不了别的破坏。首条也是目前唯一一条登记的是 `dws minutes permission apply --policy` 的 `string` → `int`(配合 #912):旧实现在 RunE 里做 `strconv.ParseInt(v, 10, 64)` 再校验 `[2,4]`,新实现由 pflag 以 `strconv.ParseInt(s, 0, 64)` 解析后仍校验 `[2,4]`,**历史上能成功的调用集是新调用集的子集**(base 0 额外接受 `0x3` 这类写法,只放宽不收紧),非法值依然失败、只是报错文案与时机前移;flag 默认值由 `""` 变 `"0"` 是类型的必然结果,两道门禁都不比较默认值,且该 flag 必须显式给出、默认值不可达。两张表必须逐字一致并有守卫测试锚定漂移——重复是被迫的而非选择:`check-authoritative-interface-baselines.sh` 会把整个 `scripts/policy/interface-baseline` 目录复制进检出历史版本的 worktree 再编译,那份拷贝不能 import 本分支新增的包。
- **Schema 兼容门禁支持 reviewed 参数类型豁免**(无用户可见变更)— 接上一条。`schema-compatibility` 是同一个 `Interface Integrity` job 里排在两道 CLI 接口门禁之后的第三道检查,此前也一律拒绝已发布参数的 `type` 变更。由于前两道先失败、`set -e` 让它从未在 CI 上暴露,上一条豁免只解决了三分之二。现在 `checkParameterCompatibility` 也带一张精确豁免表:`<product>/<tool id>` + 参数名 + 旧类型 + 新类型四元组全等才命中、方向敏感,且仅当该参数**除 `type` 外的全部已发布字段逐字段相等**时才放行。这里刻意用相等性比较而非「没有产生其他兼容性错误」:放宽 `required` / `cli_required`、清空 `required_when`、扩宽 `enum`、清空 `interface_type`、经 reviewed mapping exclusion 清空 `property`——这些变化单独看都是兼容的、根本不产生错误,若以错误列表代替相等性检查,它们就能搭着一次已评审的类型迁移一起蒙混过关。结构体整体比较还意味着将来给 `parameterSchema` 新增字段时会自动纳入守卫,而不是悄悄放宽每一条既有条目。唯一条目是 `minutes/minutes.apply_minutes_permission` 的 `policy` 由 `"string"` 迁移到 `"integer"`(配合 #912):该 `type` 由 Cobra flag 类型投影而来(provenance `cobra_flag_type`),描述的是 CLI 如何接受取值;消费方据此拼装的是命令行,而 `--policy 4` 在两种声明下是同一个 argv,加引号的 `--policy "4"` 到 pflag 仍是 4,RunE 也仍校验 `[2,4]`——而且该参数映射的 property `policyId` 一直以数字上报,新声明比旧声明更贴近真实请求。表里的类型值必须是 `schemaType` 实际产出的带引号形态(`"string"` 而非裸 `string`),守卫测试用 `schemaType` 复算并校验类型名属于 JSON Schema 的封闭取值集合——`reviewedInterfaceRefRedirect` 曾因键的书写形态错误两次静默失效,这里不重犯。
## [1.0.58-beta.1] - 2026-08-07
+11 -3
View File
@@ -68,9 +68,17 @@ coverage is additionally selected for platform-sensitive code.
3. Include both the commands/results and user-visible or contract-level
behavior evidence in the PR description.
4. Run `./scripts/policy/check-command-surface.sh --strict` when command
paths/flags change. CI also runs
`./scripts/policy/check-command-compatibility.sh --base-ref <main-ref> --stable-ref <latest-GA-tag>`
against both the target branch and latest stable release.
paths/flags change. CI resolves the exact merge-base, latest reachable
non-withdrawn stable GA tag, and committed candidate SHA, then enters the single compatibility
decision seam through
`make authoritative-interface-integrity BASE_REF=<merge-base> STABLE_REF=<latest-GA-tag> CANDIDATE_REF=<candidate-sha>`.
The Make target delegates to the authoritative wrapper; CI does not invoke a
second comparator or the legacy fixture checker. See
[CLI flag compatibility migration governance](docs/cli-interface-flag-migrations.md)
for the reviewed two-stage `pending` → `consumed` lifecycle.
Agent-visible flag migrations must also run
`make schema-compatibility BASE_REF=<merge-base> STABLE_REF=<latest-GA-tag> CANDIDATE_REF=<candidate-sha>`;
it consumes the same base-owned ledger rather than a second exception list.
5. Run `./scripts/policy/check-generated-drift.sh` when generated artifacts may
change.
6. Run `./scripts/release/verify-package-managers.sh` when packaging or
+25 -8
View File
@@ -24,13 +24,13 @@ help:
@printf " make format-check - Check all repository Go source files with gofmt\n"
@printf " make fmt - Format all repository Go source files\n"
@printf " make policy - Check the built dws plus open-source and Schema policies\n"
@printf " make interface-integrity - Check historical commands and help contracts still work\n"
@printf " make authoritative-interface-integrity BASE_REF=<ref> - Check the Git-owned PR merge-base\n"
@printf " make interface-integrity [BASE_REF=<ref>] [STABLE_REF=<tag>] [CANDIDATE_REF=<ref>] - Check authoritative CLI history\n"
@printf " make authoritative-interface-integrity BASE_REF=<ref> [STABLE_REF=<tag>] [CANDIDATE_REF=<ref>] - Check Git-owned CLI history\n"
@printf " make coverage-gate BASE_REF=<ref> - Enforce overall non-regression and 100%% changed-code coverage\n"
@printf " make coverage-gate-platform BASE_REF=<ref> PROFILE=<file> - Enforce 100%% native changed-code coverage\n"
@printf " make update-interface-baseline - Add new CLI contracts without removing history\n"
@printf " make reset-interface-baseline - DANGEROUS: replace all CLI compatibility history\n"
@printf " make schema-compatibility BASE_REF=<ref> - Check the complete Schema contract against the PR merge-base\n"
@printf " make update-interface-baseline - Update the non-authoritative CLI smoke fixture\n"
@printf " make reset-interface-baseline - DANGEROUS: replace the non-authoritative CLI smoke fixture\n"
@printf " make schema-compatibility BASE_REF=<ref> [STABLE_REF=<tag>] [CANDIDATE_REF=<ref>] - Check the authoritative Schema history\n"
@printf " make skill-command-integrity - Check dws commands referenced by skills exist\n"
@printf " make skill-context-budget - Check generated Skill drift and common-path context budgets\n"
@printf " make multi-im-skill-chain-integrity - Check reviewed IM intents keep one default Skill route\n"
@@ -103,10 +103,22 @@ edition-test:
$(GO) test -v -count=1 ./pkg/editiontest/...
interface-integrity:
@./scripts/policy/check-interface-baseline.sh
@base_ref="$(BASE_REF)"; \
candidate_ref="$(CANDIDATE_REF)"; \
if [ -z "$$base_ref" ]; then base_ref="origin/main"; fi; \
if [ -z "$$candidate_ref" ]; then candidate_ref="HEAD"; fi; \
./scripts/policy/check-authoritative-interface-baselines.sh \
--base-ref "$$base_ref" \
--stable-ref "$(STABLE_REF)" \
--candidate-ref "$$candidate_ref"
authoritative-interface-integrity:
@./scripts/policy/check-authoritative-interface-baselines.sh --base-ref "$(BASE_REF)"
@candidate_ref="$(CANDIDATE_REF)"; \
if [ -z "$$candidate_ref" ]; then candidate_ref="HEAD"; fi; \
./scripts/policy/check-authoritative-interface-baselines.sh \
--base-ref "$(BASE_REF)" \
--stable-ref "$(STABLE_REF)" \
--candidate-ref "$$candidate_ref"
coverage-gate:
@./scripts/policy/check-coverage-gate.sh --base-ref "$(BASE_REF)" --scope-buildable
@@ -121,7 +133,12 @@ reset-interface-baseline:
@./scripts/policy/check-interface-baseline.sh --reset
schema-compatibility:
@./scripts/policy/check-authoritative-schema-compatibility.sh --base-ref "$(BASE_REF)"
@candidate_ref="$(CANDIDATE_REF)"; \
if [ -z "$$candidate_ref" ]; then candidate_ref="HEAD"; fi; \
./scripts/policy/check-authoritative-schema-compatibility.sh \
--base-ref "$(BASE_REF)" \
--stable-ref "$(STABLE_REF)" \
--candidate-ref "$$candidate_ref"
skill-command-integrity:
@./scripts/policy/check-skill-commands.sh
+26 -16
View File
@@ -471,7 +471,7 @@ Env vars: `DWS_SKILL_MODE=mono|multi` (also honored by `install.sh` / `install.p
<details>
<summary><strong>Personal Event Subscription</strong> — real-time DingTalk messages for event-driven agents</summary>
`dws event consume` subscribes as the currently logged-in user over a managed Stream WebSocket and emits each event as one NDJSON line on stdout. The public catalog covers scoped and all one-to-one/group messages, specified senders, read/recall/reaction events, and group title/disband lifecycle events.
`dws event consume` subscribes as the currently logged-in user over a managed Stream WebSocket and emits each event as one NDJSON line on stdout. The public catalog covers scoped and all one-to-one/group messages, specified senders, read/recall/reaction events, group lifecycle events, and six OA approval task/instance events.
The default `ndjson`, `json`, and `pretty` output preserves the transport envelope (`type`, `event_type`, string `data`, and `headers`) for existing scripts; `compact` retains its existing processor. Add `--flatten` to emit the stable top-level business fields used by Agent workflows. `--format` controls JSON serialization; `--flatten` controls the data structure and cannot be combined with `-f raw` or `--debug-raw-events`.
@@ -481,28 +481,33 @@ For an event-focused installation, use the official convenience installer:
```bash
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-event.sh | sh
# Or install the standalone multi skill from an existing dws installation
dws skill setup --mode multi -s event
```
```bash
# Inspect the public personal event catalog and schema
dws event list
dws event schema user_im_message_receive_o2o --flatten
dws event list --category oa
dws event schema user_oa_approval_task_created --flatten
# Listen for messages that mention the current user
dws event consume user_im_message_receive_at --flatten -f ndjson
dws event +listen-im --kind at-me -f ndjson
# Listen for one-to-one messages with a specified user
dws event consume user_im_message_receive_o2o --user <userId> --flatten -f ndjson
# Listen for messages from a specified sender
dws event +listen-im --kind sender --user <userId> -f ndjson
# Listen by openDingtalkId (external contact, bot, or cross-organization identity)
dws event consume user_im_message_receive_o2o --open-dingtalk-id <openDingtalkId> --flatten -f ndjson
dws event +listen-im --kind sender --open-dingtalk-id <openDingtalkId> -f ndjson
# Listen for messages in a specified group
dws event consume user_im_message_receive_group --group <openConversationId> --flatten -f ndjson
dws event +listen-im --kind group --chat-id <openConversationId> -f ndjson
# Listen for all one-to-one or all group messages
dws event consume user_im_message_receive_o2o_all --flatten -f ndjson
dws event consume user_im_message_receive_group_all --flatten -f ndjson
dws event +listen-im --kind all-direct -f ndjson
dws event +listen-im --kind all-group -f ndjson
# Listen for a specified group's title changes, member changes, or disband event
dws event consume user_im_group_updated --group <openConversationId> --flatten -f ndjson
@@ -510,14 +515,19 @@ dws event consume user_im_group_member_added --group <openConversationId> --flat
dws event consume user_im_group_member_exited --group <openConversationId> --flatten -f ndjson
dws event consume user_im_group_disbanded --group <openConversationId> --flatten -f ndjson
# Listen for multiple events for the same user in one process
# Listen for messages, reads, and recalls from the same sender in one process
dws event +listen-im --kind sender --user <userId> \
--events message,read,recall -f ndjson
# Listen for all six public OA approval events in one process
dws event consume \
user_im_message_receive_o2o \
user_im_message_read_o2o \
user_im_message_recall_o2o \
--user <userId> \
--flatten \
-f ndjson
user_oa_approval_task_created \
user_oa_approval_task_finished \
user_oa_approval_task_redirected \
user_oa_approval_instance_started \
user_oa_approval_instance_terminated \
user_oa_approval_instance_finished \
--flatten -f ndjson
# Inspect local consumers and cancel a subscription
dws event status
@@ -536,7 +546,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>
+26 -16
View File
@@ -465,7 +465,7 @@ DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
<details>
<summary><strong>个人事件订阅</strong> — 实时接收钉钉消息,驱动事件触发的 Agent</summary>
`dws event consume` 使用当前 OAuth 登录用户建立托管的 Stream WebSocket 长连接,并把每条事件以 NDJSON 一行输出到 stdout。当前公开目录覆盖指定范围和全量单聊/群消息、指定发送人、已读/撤回/表情回应,以及群标题变更和群解散事件。
`dws event consume` 使用当前 OAuth 登录用户建立托管的 Stream WebSocket 长连接,并把每条事件以 NDJSON 一行输出到 stdout。当前公开目录覆盖指定范围和全量单聊/群消息、指定发送人、已读/撤回/表情回应、群生命周期,以及六个 OA 审批任务/实例事件。
默认 `ndjson`、`json`、`pretty` 输出保留兼容 transport envelope(`type`、`event_type`、字符串 `data`、`headers`),`compact` 继续沿用原 processor。Agent 或新脚本显式加 `--flatten` 后,输出稳定的顶层业务字段。`--format` 控制 JSON 序列化,`--flatten` 控制数据结构,且不能与 `-f raw` 或 `--debug-raw-events` 同时使用。
@@ -475,28 +475,33 @@ DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
```bash
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-event.sh | sh
# 或在已有 dws 环境中安装独立的 multi skill
dws skill setup --mode multi -s event
```
```bash
# 查看公开个人事件目录和 schema
dws event list
dws event schema user_im_message_receive_o2o --flatten
dws event list --category oa
dws event schema user_oa_approval_task_created --flatten
# 监听当前用户被 @ 的消息
dws event consume user_im_message_receive_at --flatten -f ndjson
dws event +listen-im --kind at-me -f ndjson
# 监听与指定用户的单聊消息
dws event consume user_im_message_receive_o2o --user <userId> --flatten -f ndjson
# 监听指定发送人的消息
dws event +listen-im --kind sender --user <userId> -f ndjson
# 使用 openDingtalkId 监听外部联系人、机器人或跨组织身份
dws event consume user_im_message_receive_o2o --open-dingtalk-id <openDingtalkId> --flatten -f ndjson
dws event +listen-im --kind sender --open-dingtalk-id <openDingtalkId> -f ndjson
# 监听指定群的消息
dws event consume user_im_message_receive_group --group <openConversationId> --flatten -f ndjson
dws event +listen-im --kind group --chat-id <openConversationId> -f ndjson
# 监听所有单聊或所有群消息
dws event consume user_im_message_receive_o2o_all --flatten -f ndjson
dws event consume user_im_message_receive_group_all --flatten -f ndjson
dws event +listen-im --kind all-direct -f ndjson
dws event +listen-im --kind all-group -f ndjson
# 监听指定群标题变更、成员进退群或群解散
dws event consume user_im_group_updated --group <openConversationId> --flatten -f ndjson
@@ -504,14 +509,19 @@ dws event consume user_im_group_member_added --group <openConversationId> --flat
dws event consume user_im_group_member_exited --group <openConversationId> --flatten -f ndjson
dws event consume user_im_group_disbanded --group <openConversationId> --flatten -f ndjson
# 一个进程监听同一用户的多个事件
# 一个进程监听同一发送人的消息、已读和撤回
dws event +listen-im --kind sender --user <userId> \
--events message,read,recall -f ndjson
# 一个进程监听全部六个公开 OA 审批事件
dws event consume \
user_im_message_receive_o2o \
user_im_message_read_o2o \
user_im_message_recall_o2o \
--user <userId> \
--flatten \
-f ndjson
user_oa_approval_task_created \
user_oa_approval_task_finished \
user_oa_approval_task_redirected \
user_oa_approval_instance_started \
user_oa_approval_instance_terminated \
user_oa_approval_instance_finished \
--flatten -f ndjson
# 查看本地 consume,并取消指定订阅
dws event status
@@ -530,7 +540,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>
+91 -2
View File
@@ -17,18 +17,23 @@
package main
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/interfacesnapshot"
"github.com/spf13/cobra"
)
var newRootCommand = func() *cobra.Command { return app.NewRootCommand() }
func main() {
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
}
@@ -112,7 +117,11 @@ func runGenerate(args []string, stdout, stderr io.Writer) error {
defer i18n.SetLang(previousLang)
i18n.SetLang("en")
snapshot := interfacesnapshot.Capture(app.NewRootCommand())
root := newRootCommand()
snapshot := interfacesnapshot.Capture(root)
if err := validateHelpRendering(root, snapshot); err != nil {
return err
}
if *output == "-" {
return interfacesnapshot.Write(stdout, snapshot)
}
@@ -138,6 +147,16 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) {
currentPath := flags.String("current", "", "candidate snapshot path")
basePath := flags.String("base", "", "target main/development baseline snapshot path")
stablePath := flags.String("stable", "", "latest stable GA snapshot path")
approvedMigrationsPath := flags.String(
"approved-flag-migrations",
"",
"merge-base-owned approved flag migration manifest",
)
candidateMigrationsPath := flags.String(
"candidate-flag-migrations",
"",
"candidate flag migration manifest",
)
if err := flags.Parse(args); err != nil {
return false, err
}
@@ -150,6 +169,14 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) {
if *basePath == "" && *stablePath == "" {
return false, fmt.Errorf("compare requires --base, --stable, or both")
}
if (*approvedMigrationsPath == "") != (*candidateMigrationsPath == "") {
return false, fmt.Errorf(
"--approved-flag-migrations and --candidate-flag-migrations must be provided together",
)
}
if *approvedMigrationsPath != "" && (*basePath == "" || *stablePath == "") {
return false, fmt.Errorf("flag migration compare requires both --base and --stable")
}
current, err := readSnapshot(*currentPath)
if err != nil {
@@ -170,6 +197,25 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) {
}
report := interfacesnapshot.CompareAll(current, references)
if *approvedMigrationsPath != "" {
approved, readErr := readFlagMigrationManifest(*approvedMigrationsPath)
if readErr != nil {
return false, fmt.Errorf("read approved flag migrations: %w", readErr)
}
candidate, readErr := readFlagMigrationManifest(*candidateMigrationsPath)
if readErr != nil {
return false, fmt.Errorf("read candidate flag migrations: %w", readErr)
}
report, err = interfacesnapshot.CompareAllWithFlagMigrations(
current,
references,
approved,
candidate,
)
if err != nil {
return false, fmt.Errorf("validate flag migration lifecycle: %w", err)
}
}
encoder := json.NewEncoder(stdout)
encoder.SetEscapeHTML(false)
encoder.SetIndent("", " ")
@@ -179,6 +225,49 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) {
return report.Compatible, nil
}
func readFlagMigrationManifest(path string) (interfacesnapshot.FlagMigrationManifest, error) {
file, err := os.Open(filepath.Clean(path))
if err != nil {
return interfacesnapshot.FlagMigrationManifest{}, err
}
defer file.Close()
return interfacesnapshot.ReadFlagMigrationManifest(file)
}
func validateHelpRendering(root *cobra.Command, snapshot interfacesnapshot.Snapshot) error {
for _, command := range snapshot.Commands {
path := strings.TrimPrefix(command.Path, "dws")
resolved, remaining, err := root.Find(strings.Fields(path))
if err != nil || len(remaining) != 0 || resolved == nil {
return fmt.Errorf("resolve %q before help rendering: remaining=%v error=%v", command.Path, remaining, err)
}
if err := renderCommandHelp(resolved); err != nil {
return fmt.Errorf("render %q help: %w", command.Path, err)
}
}
return nil
}
func renderCommandHelp(command *cobra.Command) (err error) {
var stdout, stderr bytes.Buffer
command.InitDefaultHelpFlag()
command.SetOut(&stdout)
command.SetErr(&stderr)
defer func() {
if recovered := recover(); recovered != nil {
err = fmt.Errorf("help renderer panicked: %v", recovered)
}
}()
command.HelpFunc()(command, []string{})
if stderr.Len() > 0 {
return fmt.Errorf("help renderer wrote an error: %s", strings.TrimSpace(stderr.String()))
}
if stdout.Len() == 0 {
return fmt.Errorf("help renderer produced empty output")
}
return nil
}
func readSnapshot(path string) (interfacesnapshot.Snapshot, error) {
file, err := os.Open(filepath.Clean(path))
if err != nil {
@@ -191,5 +280,5 @@ func readSnapshot(path string) (interfacesnapshot.Snapshot, error) {
func printUsage(w io.Writer) {
fmt.Fprintln(w, "usage:")
fmt.Fprintln(w, " interface-snapshot generate [--output FILE]")
fmt.Fprintln(w, " interface-snapshot compare --current FILE [--base FILE] [--stable FILE]")
fmt.Fprintln(w, " interface-snapshot compare --current FILE [--base FILE] [--stable FILE] [--approved-flag-migrations FILE --candidate-flag-migrations FILE]")
}
+447
View File
@@ -15,11 +15,16 @@ package main
import (
"bytes"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/interfacesnapshot"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageRunGenerateCapturesActualRootOffline(t *testing.T) {
@@ -56,6 +61,24 @@ func TestCrossPlatformCoverageRunGenerateCapturesActualRootOffline(t *testing.T)
}
}
func TestCrossPlatformCoverageRunGenerateRejectsHelpRenderingFailure(t *testing.T) {
testseam.Swap(t, &newRootCommand, func() *cobra.Command {
root := &cobra.Command{Use: "dws"}
root.SetHelpFunc(func(command *cobra.Command, _ []string) {
_, _ = io.WriteString(command.ErrOrStderr(), "injected help failure")
})
return root
})
var stdout, stderr bytes.Buffer
if exitCode := run([]string{"generate"}, &stdout, &stderr); exitCode != 2 {
t.Fatalf("run(generate) exit=%d stderr=%s", exitCode, stderr.String())
}
if !strings.Contains(stderr.String(), "injected help failure") {
t.Fatalf("run(generate) stderr=%q", stderr.String())
}
}
func TestCrossPlatformCoverageRunCompareUsesBothSnapshotInputsAndExitCode(t *testing.T) {
current := commandSnapshot("dws")
mergeBase := commandSnapshot("dws")
@@ -83,6 +106,368 @@ func TestCrossPlatformCoverageRunCompareUsesBothSnapshotInputsAndExitCode(t *tes
}
}
func TestCrossPlatformCoverageRunCompareEnforcesBaseOwnedFlagMigrationLifecycle(t *testing.T) {
dir := t.TempDir()
before := flagMigrationSnapshot(false)
after := flagMigrationSnapshot(true)
currentPath := writeSnapshot(t, dir, "current.json", after)
basePath := writeSnapshot(t, dir, "base.json", before)
stablePath := writeSnapshot(t, dir, "stable.json", before)
approvedPath := writeManifest(t, dir, "approved.json", flagMigrationManifestJSON("pending"))
candidatePath := writeManifest(t, dir, "candidate.json", flagMigrationManifestJSON("consumed"))
var stdout, stderr bytes.Buffer
exitCode := run([]string{
"compare",
"--current", currentPath,
"--base", basePath,
"--stable", stablePath,
"--approved-flag-migrations", approvedPath,
"--candidate-flag-migrations", candidatePath,
}, &stdout, &stderr)
if exitCode != 0 {
t.Fatalf("exact base-owned migration exit=%d stderr=%s", exitCode, stderr.String())
}
if !bytes.Contains(stdout.Bytes(), []byte(`"compatible": true`)) {
t.Fatalf("exact migration report is not compatible:\n%s", stdout.String())
}
stdout.Reset()
stderr.Reset()
emptyApproved := writeManifest(t, dir, "empty-approved.json", `{"version":1,"migrations":[]}`)
exitCode = run([]string{
"compare",
"--current", currentPath,
"--base", basePath,
"--stable", stablePath,
"--approved-flag-migrations", emptyApproved,
"--candidate-flag-migrations", candidatePath,
}, &stdout, &stderr)
if exitCode != 2 || !strings.Contains(stderr.String(), "must start pending") {
t.Fatalf("candidate self-approval exit=%d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
}
}
func TestCrossPlatformCoverageRunCompareRequiresBothFlagMigrationInputs(t *testing.T) {
dir := t.TempDir()
currentPath := writeSnapshot(t, dir, "current.json", commandSnapshot("dws"))
basePath := writeSnapshot(t, dir, "base.json", commandSnapshot("dws"))
approvedPath := writeManifest(t, dir, "approved.json", `{"version":1,"migrations":[]}`)
var stdout, stderr bytes.Buffer
exitCode := run([]string{
"compare",
"--current", currentPath,
"--base", basePath,
"--approved-flag-migrations", approvedPath,
}, &stdout, &stderr)
if exitCode != 2 || !strings.Contains(stderr.String(), "must be provided together") {
t.Fatalf("one-sided migration input exit=%d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
}
}
func TestCrossPlatformCoverageRunCompareRequiresBothReferencesForFlagMigrations(t *testing.T) {
dir := t.TempDir()
currentPath := writeSnapshot(t, dir, "current.json", commandSnapshot("dws"))
basePath := writeSnapshot(t, dir, "base.json", commandSnapshot("dws"))
stablePath := writeSnapshot(t, dir, "stable.json", commandSnapshot("dws"))
approvedPath := writeManifest(t, dir, "approved.json", `{"version":1,"migrations":[]}`)
candidatePath := writeManifest(t, dir, "candidate.json", `{"version":1,"migrations":[]}`)
tests := []struct {
name string
args []string
}{
{
name: "missing stable",
args: []string{"--base", basePath},
},
{
name: "missing base",
args: []string{"--stable", stablePath},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
args := []string{"compare", "--current", currentPath}
args = append(args, test.args...)
args = append(args,
"--approved-flag-migrations", approvedPath,
"--candidate-flag-migrations", candidatePath,
)
var stdout, stderr bytes.Buffer
exitCode := run(args, &stdout, &stderr)
if exitCode != 2 || !strings.Contains(stderr.String(), "requires both --base and --stable") {
t.Fatalf("one-reference migration compare exit=%d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
}
})
}
}
func TestCrossPlatformCoverageRunPrintsUsageForMissingAndUnknownCommands(t *testing.T) {
tests := []struct {
name string
args []string
wantStderr []string
}{
{
name: "missing command",
args: nil,
wantStderr: []string{"usage:", "interface-snapshot generate", "--approved-flag-migrations"},
},
{
name: "unknown command",
args: []string{"unknown"},
wantStderr: []string{`unknown command "unknown"`, "usage:", "interface-snapshot compare"},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var stdout, stderr bytes.Buffer
if exitCode := run(test.args, &stdout, &stderr); exitCode != 2 {
t.Fatalf("run(%v) exit=%d, want 2", test.args, exitCode)
}
if stdout.Len() != 0 {
t.Fatalf("run(%v) unexpectedly wrote stdout: %s", test.args, stdout.String())
}
for _, want := range test.wantStderr {
if !strings.Contains(stderr.String(), want) {
t.Errorf("run(%v) stderr missing %q:\n%s", test.args, want, stderr.String())
}
}
})
}
}
func TestCrossPlatformCoverageRunRejectsInvalidSubcommandArguments(t *testing.T) {
tests := []struct {
name string
args []string
want string
}{
{name: "generate unknown flag", args: []string{"generate", "--unknown"}, want: "flag provided but not defined"},
{name: "generate positional", args: []string{"generate", "unexpected"}, want: "generate accepts no positional arguments"},
{name: "compare unknown flag", args: []string{"compare", "--unknown"}, want: "flag provided but not defined"},
{name: "compare positional", args: []string{"compare", "unexpected"}, want: "compare accepts no positional arguments"},
{name: "compare missing current", args: []string{"compare", "--base", "base.json"}, want: "compare requires --current"},
{name: "compare missing reference", args: []string{"compare", "--current", "current.json"}, want: "compare requires --base, --stable, or both"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var stdout, stderr bytes.Buffer
if exitCode := run(test.args, &stdout, &stderr); exitCode != 2 {
t.Fatalf("run(%v) exit=%d, want 2", test.args, exitCode)
}
if !strings.Contains(stderr.String(), test.want) {
t.Fatalf("run(%v) stderr missing %q:\n%s", test.args, test.want, stderr.String())
}
})
}
}
func TestCrossPlatformCoverageRunGenerateRejectsUnsafeOutputPath(t *testing.T) {
outputDirectory := t.TempDir()
var stdout, stderr bytes.Buffer
exitCode := run([]string{"generate", "--output", outputDirectory}, &stdout, &stderr)
if exitCode != 2 || !strings.Contains(stderr.String(), "create snapshot") {
t.Fatalf("directory output exit=%d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
}
}
func TestCrossPlatformCoverageRunGenerateReportsTemporaryDirectoryFailure(t *testing.T) {
missingTempRoot := filepath.Join(t.TempDir(), "missing")
for _, name := range []string{"TMPDIR", "TMP", "TEMP"} {
t.Setenv(name, missingTempRoot)
}
var stdout, stderr bytes.Buffer
exitCode := run([]string{"generate"}, &stdout, &stderr)
if exitCode != 2 || !strings.Contains(stderr.String(), "create isolated home") {
t.Fatalf("invalid temporary root exit=%d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
}
}
func TestCrossPlatformCoverageRunCompareReportsSnapshotReadFailures(t *testing.T) {
dir := t.TempDir()
validPath := writeSnapshot(t, dir, "valid.json", commandSnapshot("dws"))
missingPath := filepath.Join(dir, "missing.json")
tests := []struct {
name string
args []string
want string
}{
{
name: "current",
args: []string{"compare", "--current", missingPath, "--base", validPath},
want: "read current snapshot",
},
{
name: "main",
args: []string{"compare", "--current", validPath, "--base", missingPath},
want: "read main/development baseline snapshot",
},
{
name: "stable",
args: []string{"compare", "--current", validPath, "--stable", missingPath},
want: "read stable snapshot",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var stdout, stderr bytes.Buffer
if exitCode := run(test.args, &stdout, &stderr); exitCode != 2 {
t.Fatalf("run(compare) exit=%d, want 2", exitCode)
}
if !strings.Contains(stderr.String(), test.want) {
t.Fatalf("stderr missing %q:\n%s", test.want, stderr.String())
}
})
}
}
func TestCrossPlatformCoverageRunCompareReportsEachManifestReadFailure(t *testing.T) {
dir := t.TempDir()
snapshotPath := writeSnapshot(t, dir, "snapshot.json", commandSnapshot("dws"))
validManifest := writeManifest(t, dir, "valid-manifest.json", `{"version":1,"migrations":[]}`)
invalidManifest := writeManifest(t, dir, "invalid-manifest.json", `{`)
tests := []struct {
name string
approved string
candidate string
want string
}{
{
name: "approved manifest",
approved: invalidManifest,
candidate: validManifest,
want: "read approved flag migrations",
},
{
name: "candidate manifest",
approved: validManifest,
candidate: invalidManifest,
want: "read candidate flag migrations",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var stdout, stderr bytes.Buffer
exitCode := run([]string{
"compare",
"--current", snapshotPath,
"--base", snapshotPath,
"--stable", snapshotPath,
"--approved-flag-migrations", test.approved,
"--candidate-flag-migrations", test.candidate,
}, &stdout, &stderr)
if exitCode != 2 || !strings.Contains(stderr.String(), test.want) {
t.Fatalf("%s exit=%d stdout=%s stderr=%s", test.name, exitCode, stdout.String(), stderr.String())
}
})
}
}
func TestCrossPlatformCoverageRunCompareReportsOutputFailure(t *testing.T) {
dir := t.TempDir()
snapshotPath := writeSnapshot(t, dir, "snapshot.json", commandSnapshot("dws"))
var stderr bytes.Buffer
exitCode := run([]string{
"compare",
"--current", snapshotPath,
"--base", snapshotPath,
}, failingWriter{}, &stderr)
if exitCode != 2 || !strings.Contains(stderr.String(), "write comparison report") {
t.Fatalf("comparison output failure exit=%d stderr=%s", exitCode, stderr.String())
}
}
func TestCrossPlatformCoverageReadHelpersRejectMissingAndInvalidInputs(t *testing.T) {
dir := t.TempDir()
missingPath := filepath.Join(dir, "missing.json")
invalidPath := filepath.Join(dir, "invalid.json")
if err := os.WriteFile(invalidPath, []byte(`{`), 0o600); err != nil {
t.Fatalf("write invalid fixture: %v", err)
}
if _, err := readSnapshot(missingPath); err == nil {
t.Fatal("readSnapshot(missing) unexpectedly succeeded")
}
if _, err := readSnapshot(invalidPath); err == nil {
t.Fatal("readSnapshot(invalid) unexpectedly succeeded")
}
if _, err := readFlagMigrationManifest(missingPath); err == nil {
t.Fatal("readFlagMigrationManifest(missing) unexpectedly succeeded")
}
if _, err := readFlagMigrationManifest(invalidPath); err == nil {
t.Fatal("readFlagMigrationManifest(invalid) unexpectedly succeeded")
}
}
func TestCrossPlatformCoverageValidateHelpRenderingReportsResolveError(t *testing.T) {
root := &cobra.Command{Use: "dws"}
err := validateHelpRendering(root, commandSnapshot("dws missing"))
if err == nil || !strings.Contains(err.Error(), `resolve "dws missing" before help rendering`) {
t.Fatalf("validateHelpRendering resolve error = %v", err)
}
}
func TestCrossPlatformCoverageValidateHelpRenderingReportsTemplateError(t *testing.T) {
root := &cobra.Command{Use: "dws"}
root.SetHelpTemplate(`{{index .Commands 99}}`)
err := validateHelpRendering(root, commandSnapshot("dws"))
if err == nil || !strings.Contains(err.Error(), `render "dws" help`) {
t.Fatalf("validateHelpRendering template error = %v", err)
}
}
func TestCrossPlatformCoverageValidateHelpRenderingRecoversTemplatePanic(t *testing.T) {
root := &cobra.Command{Use: "dws"}
root.SetHelpTemplate("{{")
err := validateHelpRendering(root, commandSnapshot("dws"))
if err == nil || !strings.Contains(err.Error(), `render "dws" help`) {
t.Fatalf("validateHelpRendering template panic = %v", err)
}
}
func TestCrossPlatformCoverageValidateHelpRenderingRejectsCustomHelpStderr(t *testing.T) {
root := &cobra.Command{Use: "dws"}
root.SetHelpFunc(func(command *cobra.Command, _ []string) {
_, _ = io.WriteString(command.ErrOrStderr(), "injected help failure")
})
err := validateHelpRendering(root, commandSnapshot("dws"))
if err == nil || !strings.Contains(err.Error(), "injected help failure") {
t.Fatalf("validateHelpRendering custom stderr = %v", err)
}
}
func TestCrossPlatformCoverageValidateHelpRenderingRejectsEmptyOutput(t *testing.T) {
root := &cobra.Command{Use: "dws"}
root.SetHelpFunc(func(*cobra.Command, []string) {})
err := validateHelpRendering(root, commandSnapshot("dws"))
if err == nil || !strings.Contains(err.Error(), "empty") {
t.Fatalf("validateHelpRendering empty output = %v", err)
}
}
func TestCrossPlatformCoverageValidateHelpRenderingAcceptsNormalOutput(t *testing.T) {
root := &cobra.Command{Use: "dws", Short: "root command"}
if err := validateHelpRendering(root, commandSnapshot("dws")); err != nil {
t.Fatalf("validateHelpRendering normal output: %v", err)
}
}
type failingWriter struct{}
func (failingWriter) Write([]byte) (int, error) {
return 0, errors.New("injected write failure")
}
var _ io.Writer = failingWriter{}
func commandSnapshot(paths ...string) interfacesnapshot.Snapshot {
commands := make([]interfacesnapshot.Command, 0, len(paths))
for _, path := range paths {
@@ -120,6 +505,68 @@ func writeSnapshot(t *testing.T, dir, name string, snapshot interfacesnapshot.Sn
return path
}
func writeManifest(t *testing.T, dir, name, contents string) string {
t.Helper()
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
t.Fatalf("write %s: %v", path, err)
}
return path
}
func flagMigrationSnapshot(after bool) interfacesnapshot.Snapshot {
legacy := interfacesnapshot.Flag{
Name: "legacy-id",
Shorthand: "l",
Type: "string",
Default: "",
NoOpt: "auto",
Required: true,
}
flags := []interfacesnapshot.Flag{legacy}
if after {
legacy.Required = false
legacy.Hidden = true
legacy.AliasOf = "message-id"
flags = []interfacesnapshot.Flag{
legacy,
{Name: "message-id", Type: "string", Default: "", Required: true},
}
}
return interfacesnapshot.Snapshot{
SchemaVersion: interfacesnapshot.SchemaVersion,
Rules: interfacesnapshot.Rules{
ExcludedCommandSubtrees: []string{},
ExcludedFlags: []string{},
},
Commands: []interfacesnapshot.Command{
{Path: "dws", Runnable: true, Aliases: []string{}, LocalFlags: []interfacesnapshot.Flag{}, InheritedFlags: []interfacesnapshot.Flag{}},
{Path: "dws chat send", Runnable: true, Aliases: []string{}, LocalFlags: flags, InheritedFlags: []interfacesnapshot.Flag{}},
},
}
}
func flagMigrationManifestJSON(state string) string {
return strings.Replace(`{
"version": 1,
"migrations": [{
"command": "dws chat send",
"legacy": {
"name": "legacy-id",
"before": {"present": true, "type": "string", "required": true, "shorthand": "l", "no_opt": "auto", "scope": "local"},
"after": {"present": true, "type": "string", "hidden": true, "shorthand": "l", "no_opt": "auto", "scope": "local", "alias_of": "message-id"}
},
"canonical": {
"name": "message-id",
"before": {"present": false},
"after": {"present": true, "type": "string", "required": true, "scope": "local"}
},
"state": "STATE",
"reason": "reviewed exact migration"
}]
}`, "STATE", state, 1)
}
func hasFlag(flags []interfacesnapshot.Flag, name, flagType string) bool {
for _, flag := range flags {
if flag.Name == name && flag.Type == flagType {
+39 -8
View File
@@ -157,15 +157,33 @@ expected to repeat every CI job locally:
```sh
make build
make policy
make interface-integrity
make authoritative-interface-integrity BASE_REF=<merge-base>
make schema-compatibility BASE_REF=<merge-base>
make interface-integrity BASE_REF=<merge-base> STABLE_REF=<stable-tag> CANDIDATE_REF=<candidate-sha>
make schema-compatibility BASE_REF=<merge-base> STABLE_REF=<stable-tag> CANDIDATE_REF=<candidate-sha>
make skill-command-integrity
make cli-smoke
make mock-mcp-smoke
go test -v -count=1 ./pkg/editiontest/...
```
CI 先解析并核对精确的 merge-base、最近可达且未撤回的 stable GA tag 和已提交的 candidate
SHA,再调用 `make authoritative-interface-integrity`。本地 `make interface-integrity`
与该 CI target 都只委托给同一个 modern authoritative wrapper,不存在第二个比较
入口。省略 `BASE_REF` 时本地 target 默认比较 `origin/main`,省略 `STABLE_REF` 时自动
选择该 base 可达且未撤回的最近 stable GA tag,省略 `CANDIDATE_REF` 时比较已提交的 `HEAD`。
需要逐字复现某次 CI 时,应显式传入该次运行记录的 merge-base、stable tag 和
candidate SHA。
`make update-interface-baseline` / `make reset-interface-baseline` 只维护
`test/fixtures/cli-interface-baseline.txt` 这一份非权威 CLI Smoke fixture。底层旧
`check-interface-baseline.sh` 不再作为本地或 CI 的兼容性审批入口,也不能用于批准
flag 迁移。
Schema compatibility 使用同一组 base、stable、candidate refs 和同一份 base-owned flag
migration ledger。merge-base-owned checker 分别规范化 merge-base 与 stable 的完整
Schema,并让 candidate 对两份历史 contract 独立执行检查;它只把已通过 Interface
lifecycle 的 exact rename 规范化到当前历史副本,不会维护第二份 allowlist,也不会
放宽其他 Schema 历史字段。
For an exact CHANGELOG-only branch:
```sh
@@ -186,11 +204,24 @@ are evaluated by the same block-deduplicating checker; supporting policy and
shortcut profiles contribute to changed-code coverage only. The checked-in
badge is presentation only and is never read as a gate input.
Compatibility checks derive authoritative Interface snapshots from the PR
merge-base and the latest reachable stable release. The candidate cannot bless
a breaking change by editing a fixture. Schema additions are allowed;
historical products, tools, parameters, mappings, positional execution fields,
constraints, and safety semantics remain protected.
CLI 兼容检查只使用 modern Interface Snapshot 这一处权威比较 seam,并从 PR
merge-base 和最近的可达 stable release 生成权威快照。本治理机制合入后,
merge-base 拥有生成器、比较器和已审批迁移清单,因此 candidate 不能通过修改
helper、fixture 或在同一 PR 新增 self-approval 记录来放行 breaking change。首次
bootstrap 仍由 merge-base 已有的 modern helper 做无豁免比较,并只接受 candidate
提交中的规范空清单;完整边界见下方治理文档。
精确的两阶段 flag 迁移生命周期见
[CLI flag 兼容迁移治理](cli-interface-flag-migrations.md)。治理 PR 只能在
surface 未变化时新增 `pending`;后续产品 PR 达到审批的精确 surface 后,才能
消费 base-owned 记录并改为 `consumed`。在 main 与 stable 都达到 after 状态前
必须保留该回执,之后再由单独 PR 清理。机制只放行记录中的 legacy
visible-to-hidden,以及 canonical required 新增或提升;删除、type、scope、
shorthand、no-opt 和任何无关漂移仍然阻塞。Schema 可以新增;历史 product、
tool、parameter、mapping、positional execution、constraint 与 safety 语义继续
受保护。`alias_of` 只是一项由 `FlagSpec.Aliases` 产生的框架关系证据,不是 payload
等价证明;产品 PR 仍须证明 canonical 与 legacy 的最终运行 payload 等价并在 transport
前拒绝冲突输入。当前迁移清单为空,不授权 PR #904。
## Required GitHub repository settings
+176
View File
@@ -0,0 +1,176 @@
# CLI flag 兼容迁移治理
本文定义一种受控迁移:保留旧 flag 的可执行兼容性,但把它从 Help 与 Agent Schema 中隐藏,并将新的规范 flag 提升为必填。它只解决这一种精确变更,不是通用 breaking-change 豁免。
同名 flag 的精确类型迁移属于另一类评审机制,只能进入
`internal/interfacesnapshot/reviewed.go` 与 legacy smoke helper 的镜像表;flag rename
只能进入本文的 JSON lifecycle ledger。一项迁移不得跨两种机制组合授权。
## 唯一比较入口与信任边界
PR 与本地兼容性审批的唯一权威比较入口是 modern Interface Snapshot:
- `cmd/interface-snapshot` 生成和比较快照;
- `internal/interfacesnapshot` 实现兼容规则和迁移生命周期;
- `scripts/policy/check-command-compatibility.sh` 组装 candidate、PR merge-base 和最近可达且未撤回的 stable GA 三份快照;
- `scripts/policy/check-authoritative-interface-baselines.sh` 只保留为 Makefile 的兼容包装,不再维护第二套判断逻辑。
紧随其后的 Schema compatibility 不是第二份审批清单。它从同一 merge-base-owned
ledger 和同一组三方 Interface Snapshot 取得已经完成 lifecycle 校验的
authorization。merge-base-owned checker 会分别规范化 merge-base 与 stable 的完整
Schema,并让 candidate 对两份历史 contract 独立执行检查;授权的 flag rename 只会
精确投影到当前被检查的历史副本。candidate 不能为 CLI 与 Schema 分别提供两套例外。
`make interface-integrity` 也调用上述 authoritative wrapper;默认 base 为
`origin/main`,stable 可由包装脚本自动解析,candidate 默认为已提交的 `HEAD`。旧
`scripts/policy/check-interface-baseline.sh` 只供
`make update-interface-baseline` / `make reset-interface-baseline` 维护非权威 CLI
Smoke fixture,不参与迁移审批。
直接调用 `interface-snapshot compare` 时,只要提供 migration manifest 参数,就必须
同时提供 `--base` 与 `--stable`;核心 lifecycle 也拒绝缺失 stable 的非空清单,避免
调用方因漏传历史参考而提前清理 consumed receipt。
PR merge-base 同时拥有快照生成器、比较器和已审批清单。门禁用这套 base-owned helper 检查同一个已提交 candidate revision、merge-base 与 stable,candidate 不能通过修改自己的 Go 比较 helper 来放宽规则。candidate 中的清单只参与迁移状态流转,不能批准同一个 PR 引入的接口变化。首次引入本机制时,merge-base 尚无迁移解析器;bootstrap 会用 merge-base 已有的 modern Interface Snapshot 做不带豁免的普通比较,并只接受 candidate 中逐字匹配的空清单,不会让 candidate 新增的 comparator 决定本 PR 是否兼容。bootstrap 无法让旧 helper 证明新治理实现本身正确,因此本治理 PR 的新 parser、lifecycle、launcher 与 hostile tests 仍是必须由真人评审的受保护策略变更;它们合入后才成为后续 PR 的 base-owned authority。
这条边界保护比较规则和审批数据,不是任意代码沙箱。GitHub workflow / launcher 的变更仍由仓库保护规则和真人评审负责;candidate Cobra 构建也会执行 candidate 代码,因此对同一 runner 上的主动恶意代码,需要独立进程或文件系统隔离,不能把本门禁描述成已经解决。
已审批清单固定为:
```text
scripts/policy/interface-migrations/approved-flag-migrations-v1.json
```
清单使用严格 JSON 解析:版本、字段名大小写、JSON 值类型、命令路径和 flag 名都必须精确;拒绝重复键、未知键、scalar `null` 与尾随 JSON 值,`reason` 不能为空;禁止 `*`、`?`、前缀规则或其他 wildcard。当前清单为空,因此本治理 PR **不授权 PR #904 或任何产品接口变化**。
## 两阶段迁移与回执清理
每条迁移以 `(command, legacy flag, canonical flag)` 为唯一精确键,并经历以下生命周期:
| 阶段 | PR 可以做什么 | 必须满足的快照状态 |
|---|---|---|
| 1. 治理审批 | 新增 `state: pending` 的精确记录;不得在同一个 PR 修改产品 surface | candidate 和 merge-base 都与记录中的 `before` 完全一致;该记录不改变 stable 的判断 |
| 2. 产品迁移 | merge-base 已拥有 `pending` 后,按记录一次性切到精确 `after`,并把记录改为 `state: consumed` | legacy 仍存在但由 visible 变 hidden,且声明 `alias_of`;canonical 达到记录的必填状态 |
| 3. 保留回执 | 产品 PR 合入后,如果 stable 仍是 `before`,继续保留 `consumed` | merge-base 或 stable 仍有任一份尚未达到 `after` |
| 4. 单独清理 | 当 merge-base 和 stable 都已经是 `after`,在后续 PR 删除该记录 | 两份参考快照均精确匹配 `after`;继续保留过期回执会被门禁拒绝 |
因此,新增 `pending` 和修改产品 surface 不能发生在同一个 PR;candidate 自己新增的记录不能 self-approve。迁移也不能部分执行:legacy、canonical、`alias_of` 或状态只要有一项不匹配,门禁即失败。
下面只是清单结构示例,不代表已审批命令;实际字段必须从 Interface Snapshot 核对:
```json
{
"version": 1,
"migrations": [
{
"command": "dws chat message recall",
"legacy": {
"name": "msg-id",
"before": {
"present": true,
"type": "string",
"required": true,
"scope": "local"
},
"after": {
"present": true,
"type": "string",
"hidden": true,
"scope": "local",
"alias_of": "message-id"
}
},
"canonical": {
"name": "message-id",
"before": { "present": false },
"after": {
"present": true,
"type": "string",
"required": true,
"scope": "local"
}
},
"state": "pending",
"reason": "保留旧 argv 兼容性,并将规范 flag 设为唯一可见入口"
}
]
}
```
产品迁移 PR 必须保持同一条记录的命令、flag、before/after 和 reason 不变,只把 `pending` 改成 `consumed`。
## `alias_of` 是框架来源的受评审关系证据
`alias_of` 不是 Schema 同义词、参数概念词典或任意文字声明。它只能由 `FlagSpec.Aliases` 写入,并与内部 origin `corecmd.flag_spec_aliases.v1` 成对出现;每次 Interface Integrity 都会在已提交的 detached candidate 上执行源码门禁,禁止其他生产文件写入或复刻这些 evidence token。Interface Snapshot 会验证:
- legacy 与 canonical 位于同一个可执行命令;
- canonical flag 确实存在;
- legacy 与 canonical 类型一致;
- legacy 不是指向自身,也不存在 alias chain;
- legacy 的 after 状态精确指向该记录中的 canonical flag。
通过命令框架声明 `FlagSpec.Aliases` 时,框架会自动注册隐藏的兼容 flag,并写入两项 relation annotation;仅手写 `alias_of`、伪造 origin、重复值或不精确值都会让快照生成失败。不要用 Schema overlay、迁移清单或手写 Cobra annotation 伪造关系。
这项关系证据只证明 legacy/canonical 经过受控框架路径建立关系,不证明最终 transport payload 等价,也不会替产品代码实现命令特有的值同步。当前框架还禁止把
`MarkRequired` 与 `FlagSpec.Aliases` 直接组合,因为 Cobra 的 hard-required
校验只识别 canonical spelling。若产品迁移同时需要 canonical 的 Cobra required
标记和 legacy spelling,产品 PR 必须提供明确的运行时方案,并通过 canonical / legacy
最终 payload 等价、同值输入一致、冲突输入在 transport 前失败、legacy 仍可调用但 Help 隐藏等测试;迁移清单和 relation evidence 都不能替代这些证明。
## 豁免边界
一条 base-owned、状态正确且前后快照精确匹配的记录,只会从普通兼容报告中移除以下两类预期 finding:
1. legacy flag 的 `flag_became_hidden`(visible → hidden);
2. canonical flag 的 `required_flag_added`(新增时即必填)或 `flag_became_required`(已有 flag 从可选变必填)。
以下变化仍按普通兼容规则阻塞,不能被迁移记录掩盖:
- 删除 legacy、canonical、命令或其他 flag;
- flag 类型或迁移记录中的 scope、shorthand、`no_opt` 漂移;
- `alias_of` 缺失、指向变化或 alias chain;
- 命令路径及任何无关的阻塞性接口变化;
- 不精确、部分完成、超出记录范围的 surface 变化。
## Schema 投影边界
Agent-visible command 会把 visible Cobra flag 投影为 Schema parameter,因此合法的
legacy hidden 迁移会同时表现为历史 parameter 消失,constraint member 也可能从
legacy 名改为 canonical 名。Schema adapter 只接受已经由三方 Interface Snapshot
判定为 authorized 的迁移,并按 tool 的精确 `primary_cli_path` 绑定:
- reference 仍处于 `before` 且该 flag 有 Schema surface 时,baseline legacy parameter
必须存在,candidate legacy parameter 必须消失,candidate canonical parameter 必须存在;
如果 baseline 只有 canonical、没有 legacy,则 adapter 不得借 CLI ledger 提升
`required` / `cli_required` 或重写 constraint;
- rename 前后的 `type`、`property`、`interface_type`、default、format、enum 与
`required_when` 必须完全一致;
- `required` / `cli_required` 只能保持不变或按审批从 `false` 提升为 `true`,禁止降低;
- constraint 只允许在同一 tool 内按已枚举的 legacy → canonical map 做 member 替换、
排序与去重;group kind、非迁移 member 或 group 增删仍然阻塞;
- 多个 legacy 指向同一 canonical 时,所有历史 parameter signature 必须一致,否则
fail closed。
adapter 先构造经过上述验证的历史 contract 副本,再调用原 Schema checker;它不会按
错误字符串删除 finding。这样既能处理纯 rename,也能阻止“旧 required 参数改名后意外
变为 optional”或 property 漂移等伪兼容。`consumed` 回执在 merge-base Schema 已经处于
canonical-only `after` 状态时不需要再次投影;adapter 保持 baseline 不变,由原 checker
验证 candidate 是否仍与该 canonical contract 兼容。
## 本地验证
先确保 merge-base 和 stable tag 已在本地,然后运行与 CI 相同的权威门禁:
```sh
make interface-integrity \
BASE_REF=<merge-base> \
STABLE_REF=<stable-tag> \
CANDIDATE_REF=<candidate-sha>
make schema-compatibility \
BASE_REF=<merge-base> \
STABLE_REF=<stable-tag> \
CANDIDATE_REF=<candidate-sha>
```
`STABLE_REF` 必须解析到从该 merge-base 可达的最高未撤回 stable GA tag;primary checker 会按 release contract 独立核对,不能用任意 after commit 或已撤回版本提前清理回执。包装脚本可以在省略时自动解析。`CANDIDATE_REF` 省略时固定为命令启动时的已提交 `HEAD`;评审和复现 CI 时应显式传入 candidate SHA,避免 surface 与清单来自不同 revision。
File diff suppressed because it is too large Load Diff
+11 -12
View File
@@ -80,12 +80,12 @@ func newEventCommand() *cobra.Command {
contract.RegisterProductDecl(contract.ProductDecl{
ID: "event",
Selection: contract.ProductSelectionDecl{
AgentSummary: "订阅/消费个人消息、动作与群生命周期事件,并管理订阅生命周期",
AgentSummary: "实时监听当前用户相关的个人 IM 与 OA 审批事件,并管理订阅生命周期",
UseWhen: []string{
"需要实时监听个人消息接收、全量消息、已读、撤回、表情回应或群生命周期事件,或管理个人事件订阅生命周期",
"需要实时监听未来发生的个人消息、消息动作、群生命周期或 OA 审批任务/实例事件,或管理个人事件订阅生命周期",
},
AvoidWhen: []string{
"查历史聊天或主动发消息分别用 chat 查询/发送命令",
"查历史聊天或主动发消息用 chat;查询或处理审批实例/任务用 oa;配置开放平台应用事件回调用 dev app event",
},
},
})
@@ -333,7 +333,7 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
f.BoolVar(&force, "force", false,
"仅 --foreground 模式生效:跳过单实例锁 (慎用:会让云事件被随机切分)")
f.BoolVar(&dryRun, "dry-run", false,
"仅打印解析后的配置,不连接 bus / 云端")
"仅打印解析后的配置;不创建订阅、不连接 bus;复用 --subscribe-id 时会只读查询控制面")
f.BoolVar(&foreground, "foreground", false,
"当前进程直接跑 bus 服务、不 fork、不打印事件(给 systemd/k8s 托管用);读事件不要用它")
f.StringVar(&personalOpts.SubscribeID, "subscribe-id", "",
@@ -398,22 +398,21 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
Reason: "Reviewed composite workflow: the command creates or reuses a remote personal-event subscription and coordinates the local event bus and Stream consumer; no single pinned RPC represents the workflow.",
},
Selection: contract.SelectionSpec{
AgentSummary: "订阅并持续消费一个或多个兼容的个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON",
AgentSummary: "消费 OA、群生命周期或需要底层控制的个人事件流;Agent 通常使用 --flatten 输出 NDJSON",
UseWhen: []string{
"需要实时监听 @我、指定单聊、指定群或指定发送人的后续消息事件",
"用户明确要求监听当前身份的所有单聊或所有群消息",
"需要监听指定单聊或群聊中的消息已读、撤回或表情回应事件",
"需要监听六个公开 OA 审批任务/实例 EventKey 中的一个或多个事件",
"需要监听指定群的标题变更、成员进退群或群解散事件",
"监听机器人、外部联系人等以 openDingtalkId 标识的单聊目标",
"同一目标、同一过滤条件需要同时监听多个兼容事件",
"用户显式给出原始 EventKey、Filter DSL、subscribe_id,要求原始 transport envelope,或需要普通 IM facade 不提供的高级多事件控制",
},
AvoidWhen: []string{
"普通 @我、指定发送人/群、全部单聊/群聊及 message/reaction/read/recall 监听优先使用 event +listen-im",
"只查历史聊天记录时用 chat 查询命令",
"查询、同意、拒绝、转交、撤销或发起审批时用 oa;配置应用事件回调时用 dev app event",
"只看事件目录/字段时用 event list / event schema",
},
Examples: []string{
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --flatten --max-events 1 --format ndjson",
"dws event consume user_im_message_receive_o2o user_im_message_read_o2o --user test-user-001 --flatten --max-events 2 --format ndjson",
"dws event consume user_oa_approval_task_created user_oa_approval_instance_finished --flatten --duration 10m --format ndjson",
"dws event consume user_im_group_member_added --group cid-example --flatten --max-events 1 --format ndjson",
},
},
},
+3 -3
View File
@@ -71,7 +71,7 @@ func newEventListenIMCommand() *cobra.Command {
Use: "+listen-im",
Short: "按 IM 意图解析目标并监听一个或多个个人消息事件",
Long: "把 @我、指定发送人、指定群、全部单聊或全部群聊等用户意图确定性编译为个人 EventKey," +
"自然姓名/群名会先唯一解析,再复用 event consume 的订阅、ready marker、NDJSON、取消、回滚和清理生命周期。",
"自然姓名/群名会先唯一解析,再复用 event consume 的订阅、ready marker、NDJSON、取消、回滚和清理生命周期;本命令只处理 IM,不接收 OA 审批事件。",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(c *cobra.Command, _ []string) error {
@@ -143,12 +143,12 @@ func newEventListenIMCommand() *cobra.Command {
Reason: "Reviewed IM event facade: it deterministically maps kind/events to public personal EventKeys, resolves one natural user/chat target with the shared typed resolver, then delegates one single- or multi-event invocation to the existing subscription, bus, ready-marker, NDJSON, rollback, cancellation, and cleanup lifecycle.",
},
Selection: contract.SelectionSpec{
AgentSummary: "按 @我、姓名、群名或全量范围监听一个或多个 IM 消息事件",
AgentSummary: "按 @我、发送人、群或全量范围监听普通 IM message/reaction/read/recall 事件",
UseWhen: []string{
"已知要监听 @我、指定发送人、指定群、全部单聊或全部群聊的 message/reaction/read/recall 事件时使用;姓名用 --user-query、群名用 --chat-query,CLI 会唯一解析目标并把多个兼容事件合并到一个消费生命周期。",
},
AvoidWhen: []string{
"需要群标题/成员/解散等生命周期事件、显式 EventKey、复用 subscribe_id、Filter DSL、原始 transport envelope 或其它底层 consume 控制时使用 event consume;只查历史消息时使用 chat 查询入口",
"OA 审批事件、群标题/成员/解散等生命周期事件、显式 EventKey、复用 subscribe_id、Filter DSL、原始 transport envelope 或其它底层控制使用 event consume;只查历史消息使用 chat 查询入口",
},
Examples: []string{
"dws event +listen-im --kind at-me --max-events 1",
+2 -2
View File
@@ -152,8 +152,8 @@ func TestCrossPlatformCoveragePersonalSubscriptionProtectionCoversAllPublicEvent
}
}
if publicCount != 16 {
t.Fatalf("public personal events = %d, want 16", publicCount)
if publicCount != 22 {
t.Fatalf("public personal events = %d, want 22 (16 IM + 6 OA)", publicCount)
}
for _, ruleType := range []string{"at", "all", "singleChat", "sender", "group"} {
if !ruleTypes[ruleType] {
+97 -26
View File
@@ -26,6 +26,7 @@ import (
"path/filepath"
"sort"
"strings"
"sync"
"text/tabwriter"
"time"
@@ -201,12 +202,15 @@ func newEventSchemaCommand() *cobra.Command {
},
Selection: contract.SelectionSpec{
AgentSummary: "查询指定个人事件码的输出字段结构;Agent 应查询 --flatten 模式",
UseWhen: []string{"已知任一公开个人 IM event_key,消费前需要理解输出字段或保守 payload 契约"},
UseWhen: []string{"已知任一公开个人 IM 或 OA event_key,消费前需要理解 --flatten 输出字段或 payload 契约"},
AvoidWhen: []string{
"查询 CLI 命令参数契约时用顶层 dws schema",
"要实际收事件时用 event consume",
},
Examples: []string{"dws event schema user_im_message_receive_at --flatten --format json"},
Examples: []string{
"dws event schema user_im_message_receive_at --flatten --format json",
"dws event schema user_oa_approval_task_created --flatten --format json",
},
},
},
})
@@ -262,6 +266,9 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
if err := ensurePublicPersonalEvent(opts.EventKey); err != nil {
return personalSubscriptionValidationError(err)
}
if err := validatePersonalOAOptions(opts.EventKey, opts); err != nil {
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
}
rawFormat := ""
if f := c.Flags().Lookup("format"); f != nil && f.Changed {
rawFormat = opts.Common.FormatRaw
@@ -290,11 +297,18 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
if err != nil {
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
}
client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
if opts.Common.DryRun {
if strings.TrimSpace(opts.SubscribeID) == "" {
if err := validatePersonalSubscriptionOptions(opts); err != nil {
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
}
} else {
_, eventKey, _, err := personalEnsureSubscription(ctx, client, identity, opts)
if err != nil {
return fmt.Errorf("event consume --as user: %w", err)
}
opts.EventKey = eventKey
}
cfg := consume.Config{
WorkDir: workDir,
@@ -369,7 +383,6 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
}
}
client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
var attempt *personalSubscriptionAttemptReservation
if strings.TrimSpace(opts.SubscribeID) == "" {
attempt, err = reservePersonalSubscriptionAttempts(
@@ -408,9 +421,17 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
)
return fmt.Errorf("event consume --as user: %w", err)
}
cleanup := func(cleanupCtx context.Context) {
_ = personalDeleteSubscription(client, cleanupCtx, sub.SubscribeID)
_ = personalRemoveRunStates(workDir, []string{sub.SubscribeID})
selfCreated := strings.TrimSpace(opts.SubscribeID) == ""
ownsSubscription := selfCreated || opts.Ephemeral
var cleanupOnce sync.Once
cleanupOwnedSubscription := func(cleanupCtx context.Context) {
if !ownsSubscription {
return
}
cleanupOnce.Do(func() {
_ = personalDeleteSubscription(client, cleanupCtx, sub.SubscribeID)
_ = personalRemoveRunStates(workDir, []string{sub.SubscribeID})
})
}
if err := personalUpsertRunState(workDir, personal.RunState{
SubscribeID: sub.SubscribeID,
@@ -421,19 +442,19 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
IdentityHash: identityHash,
}); err != nil {
wrapped := fmt.Errorf("save run state: %w", err)
cleanupCtx := context.Background()
if personalSubscriptionCanceled(ctx, wrapped) {
cleanupCtx = ctx
}
if attempt != nil {
cleanupCtx := context.Background()
if personalSubscriptionCanceled(ctx, wrapped) {
cleanupCtx = ctx
}
classification := personalSubscriptionLocalFailure()
wrapped = attempt.completeFailure(ctx, 0, 0, wrapped, &classification)
cleanup(cleanupCtx)
}
cleanupOwnedSubscription(cleanupCtx)
return fmt.Errorf("event consume --as user: %w", wrapped)
}
if err := attempt.completeSuccess(); err != nil {
cleanup(context.Background())
cleanupOwnedSubscription(context.Background())
return fmt.Errorf("event consume --as user: %w", err)
}
// Ownership-based cleanup: a subscription this run CREATED is
@@ -442,9 +463,8 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
// leaks server-side. A subscription REUSED via --subscribe-id is left
// intact — the caller owns its lifecycle. --ephemeral forces cleanup
// either way.
selfCreated := strings.TrimSpace(opts.SubscribeID) == ""
if opts.Ephemeral || selfCreated {
defer cleanup(context.Background())
if ownsSubscription {
defer cleanupOwnedSubscription(context.Background())
}
cfg.EventKey = eventKey
@@ -466,17 +486,9 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
Source: foregroundSource,
}
bus.ApplyEnvTuning(&busCfg)
err = personalBusRun(ctx, busCfg)
if err != nil && !opts.Ephemeral {
cleanup(context.Background())
}
return err
return personalBusRun(ctx, busCfg)
}
err = personalConsumeRun(ctx, cfg)
if err != nil && !opts.Ephemeral {
cleanup(context.Background())
}
return err
return personalConsumeRun(ctx, cfg)
}
type personalMultiSubscription struct {
@@ -688,6 +700,9 @@ func preparePersonalMultiOptions(opts personalConsumeOptions) ([]personalConsume
if !def.Public {
return nil, personal.PublicAvailabilityError(eventKey)
}
if err := validatePersonalOAOptions(eventKey, opts); err != nil {
return nil, err
}
switch def.RuleType {
case "singleChat", "sender":
hasUserScope = true
@@ -814,6 +829,9 @@ func applyPersonalConsumeFilters(cfg *consume.Config, opts personalConsumeOption
}
func validatePersonalSubscriptionOptions(opts personalConsumeOptions) error {
if err := validatePersonalOAOptions(opts.EventKey, opts); err != nil {
return err
}
if _, _, err := personal.BuildRuleParam(opts.EventKey, personal.RuleOptions{
RuleType: opts.Rule,
UserID: opts.UserID,
@@ -826,6 +844,37 @@ func validatePersonalSubscriptionOptions(opts personalConsumeOptions) error {
return err
}
func validatePersonalOAOptions(eventKey string, opts personalConsumeOptions) error {
changed := personalOAOptionNames(opts)
if len(changed) == 0 {
return nil
}
def, ok := personalLookupDefinition(strings.TrimSpace(eventKey))
if !ok || def.Category != "oa" {
return nil
}
return fmt.Errorf("%s not supported for OA event %s", strings.Join(changed, ", "), eventKey)
}
func personalOAOptionNames(opts personalConsumeOptions) []string {
var changed []string
for _, item := range []struct {
name string
value string
}{
{name: "--user", value: opts.UserID},
{name: "--open-dingtalk-id", value: opts.OpenDingTalkID},
{name: "--group", value: opts.GroupID},
{name: "--query", value: opts.QueryCSV},
{name: "--filter-json", value: opts.FilterJSON},
} {
if strings.TrimSpace(item.value) != "" {
changed = append(changed, item.name)
}
}
return changed
}
type personalPreparedSubscription struct {
EventKey string
RuleType string
@@ -839,6 +888,9 @@ func preparePersonalSubscription(identity personal.Identity, opts personalConsum
if err := ensurePublicPersonalEvent(opts.EventKey); err != nil {
return personalPreparedSubscription{}, err
}
if err := validatePersonalOAOptions(opts.EventKey, opts); err != nil {
return personalPreparedSubscription{}, err
}
ruleType, ruleParam, err := personal.BuildRuleParam(opts.EventKey, personal.RuleOptions{
RuleType: opts.Rule,
UserID: opts.UserID,
@@ -885,13 +937,32 @@ func ensurePersonalSubscription(ctx context.Context, client *personal.Client, id
if err != nil {
return nil, "", "", err
}
eventKey := firstNonEmptyPersonalString(opts.EventKey, sub.EventKey)
if sub == nil {
return nil, "", "", errors.New("personal event: server returned an empty subscription")
}
requestedEventKey := strings.TrimSpace(opts.EventKey)
actualEventKey := strings.TrimSpace(sub.EventKey)
if requestedEventKey != "" && actualEventKey != "" && requestedEventKey != actualEventKey {
return nil, "", "", fmt.Errorf(
"event_key %q does not match reused subscription %q event_key %q",
requestedEventKey,
strings.TrimSpace(opts.SubscribeID),
actualEventKey,
)
}
eventKey := actualEventKey
if eventKey == "" {
eventKey = requestedEventKey
}
if eventKey == "" {
return nil, "", "", fmt.Errorf("event_key is required when --subscribe-id lookup returns no event_key")
}
if err := ensurePublicPersonalEvent(eventKey); err != nil {
return nil, "", "", err
}
if err := validatePersonalOAOptions(eventKey, opts); err != nil {
return nil, "", "", err
}
ruleType := firstNonEmptyPersonalString(sub.RuleType, opts.Rule)
if ruleType == "" {
if def, ok := personal.Lookup(eventKey); ok {
@@ -18,6 +18,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/source"
eventtransport "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
@@ -36,18 +37,20 @@ func TestCrossPlatformCoveragePersonalEventRemainingSchemaAndSubscriptionCoverag
}
}
oldGet := personalGetSubscription
oldCreate := personalCreateSubscription
t.Cleanup(func() {
personalGetSubscription = oldGet
personalCreateSubscription = oldCreate
})
testseam.Protect(t, &personalGetSubscription)
testseam.Protect(t, &personalCreateSubscription)
client := personal.NewClient("https://example.test", personal.Identity{})
wantErr := errors.New("subscription")
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) { return nil, wantErr }
if _, _, _, err := ensurePersonalSubscription(context.Background(), client, personal.Identity{}, personalConsumeOptions{SubscribeID: "sub"}); !errors.Is(err, wantErr) {
t.Fatalf("get subscription error = %v", err)
}
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
return nil, nil
}
if _, _, _, err := ensurePersonalSubscription(context.Background(), client, personal.Identity{}, personalConsumeOptions{SubscribeID: "sub"}); err == nil || !strings.Contains(err.Error(), "empty subscription") {
t.Fatalf("nil subscription = %v", err)
}
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
return &personal.Subscription{}, nil
}
+627
View File
@@ -0,0 +1,627 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package app
import (
"bytes"
"context"
"encoding/json"
"io"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
"github.com/spf13/cobra"
)
func TestPersonalOAEventListAndSchemaCommands(t *testing.T) {
list := newEventListCommand()
list.SilenceUsage = true
list.SilenceErrors = true
var listOut bytes.Buffer
list.SetOut(&listOut)
list.SetArgs([]string{"--category", "oa"})
if err := list.Execute(); err != nil {
t.Fatalf("event list --category oa error = %v", err)
}
tests := []struct {
eventKey string
properties []string
}{
{
eventKey: personal.EventOAApprovalTaskCreated,
properties: []string{
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
"process_code", "task_id", "title", "status", "create_time", "event_time",
},
},
{
eventKey: personal.EventOAApprovalTaskFinished,
properties: []string{
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
"process_code", "task_id", "title", "status", "result", "create_time",
"finish_time", "event_time",
},
},
{
eventKey: personal.EventOAApprovalTaskRedirected,
properties: []string{
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
"process_code", "task_id", "title", "status", "result", "create_time",
"finish_time", "event_time",
},
},
{
eventKey: personal.EventOAApprovalInstanceStarted,
properties: []string{
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
"process_code", "title", "status", "create_time", "event_time",
},
},
{
eventKey: personal.EventOAApprovalInstanceTerminated,
properties: []string{
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
"process_code", "title", "status", "create_time", "finish_time", "event_time",
},
},
{
eventKey: personal.EventOAApprovalInstanceFinished,
properties: []string{
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
"process_code", "title", "status", "result", "create_time", "finish_time",
"event_time",
},
},
}
for _, tt := range tests {
eventKey := tt.eventKey
if !strings.Contains(listOut.String(), eventKey) {
t.Fatalf("OA event list missing %s:\n%s", eventKey, listOut.String())
}
schema := newEventSchemaCommand()
schema.SilenceUsage = true
schema.SilenceErrors = true
var schemaOut bytes.Buffer
schema.SetOut(&schemaOut)
schema.SetArgs([]string{eventKey, "--flatten"})
if err := schema.Execute(); err != nil {
t.Fatalf("event schema %s --flatten error = %v", eventKey, err)
}
var doc map[string]any
if err := json.Unmarshal(schemaOut.Bytes(), &doc); err != nil {
t.Fatalf("decode schema for %s: %v\n%s", eventKey, err, schemaOut.String())
}
if doc["event_key"] != eventKey || doc["rule_type"] != "all" || doc["jq_root_path"] != "." {
t.Fatalf("schema document for %s = %#v", eventKey, doc)
}
schemaBody, ok := doc["schema"].(map[string]any)
if !ok {
t.Fatalf("schema body for %s = %#v", eventKey, doc["schema"])
}
properties, ok := schemaBody["properties"].(map[string]any)
if !ok || len(properties) != len(tt.properties) {
t.Fatalf("schema properties for %s = %#v, want %d fields", eventKey, schemaBody["properties"], len(tt.properties))
}
for _, name := range tt.properties {
if _, ok := properties[name].(map[string]any); !ok {
t.Fatalf("schema property %s for %s = %#v", name, eventKey, properties[name])
}
}
if _, ok := properties["payload"]; ok {
t.Fatalf("schema for %s exposed generic payload: %#v", eventKey, properties)
}
}
if strings.Contains(listOut.String(), personal.EventMention) {
t.Fatalf("OA category list leaked IM event:\n%s", listOut.String())
}
}
func TestPersonalOAEventConsumeDryRunAndValidation(t *testing.T) {
oldIdentity := personalResolveEventIdentity
oldGet := personalGetSubscription
t.Cleanup(func() {
personalResolveEventIdentity = oldIdentity
personalGetSubscription = oldGet
})
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
return personal.Identity{
AccessToken: "token",
LocalSubject: "subject",
ClientID: "client",
SourceID: "open",
}, nil
}
personalGetSubscription = func(_ *personal.Client, _ context.Context, subscribeID string) (*personal.Subscription, error) {
switch subscribeID {
case "oa-sub-task":
return &personal.Subscription{
SubscribeID: subscribeID,
EventKey: personal.EventOAApprovalTaskCreated,
RuleType: "all",
}, nil
case "im-sub-at":
return &personal.Subscription{
SubscribeID: subscribeID,
EventKey: personal.EventMention,
RuleType: "at",
}, nil
default:
t.Fatalf("unexpected subscription lookup %q", subscribeID)
return nil, nil
}
}
oaEvents := []string{
personal.EventOAApprovalTaskCreated,
personal.EventOAApprovalTaskFinished,
personal.EventOAApprovalTaskRedirected,
personal.EventOAApprovalInstanceStarted,
personal.EventOAApprovalInstanceTerminated,
personal.EventOAApprovalInstanceFinished,
}
for _, eventKey := range oaEvents {
t.Run(eventKey+"/dry-run", func(t *testing.T) {
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
var stderr bytes.Buffer
cmd.SetOut(io.Discard)
cmd.SetErr(&stderr)
cmd.SetArgs([]string{eventKey, "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("OA dry-run error = %v", err)
}
if !strings.Contains(stderr.String(), "event_types : "+eventKey) {
t.Fatalf("OA dry-run does not select %s:\n%s", eventKey, stderr.String())
}
})
for _, args := range [][]string{
{"--user", "user-1"},
{"--open-dingtalk-id", "open-user-1"},
{"--group", "cid-1"},
{"--query", "urgent"},
{"--filter-json", `{"field":"content","op":"eq","value":"urgent"}`},
} {
name := strings.TrimPrefix(args[0], "--")
t.Run(eventKey+"/reject-"+name, func(t *testing.T) {
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs(append([]string{eventKey}, append(args, "--dry-run")...))
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "not supported") {
t.Fatalf("OA consume %s error = %v, want unsupported option", args[0], err)
}
})
}
}
t.Run("multi-dry-run", func(t *testing.T) {
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
var stderr bytes.Buffer
cmd.SetOut(io.Discard)
cmd.SetErr(&stderr)
cmd.SetArgs(append(append([]string(nil), oaEvents...), "--dry-run"))
if err := cmd.Execute(); err != nil {
t.Fatalf("multi OA dry-run error = %v", err)
}
for _, eventKey := range oaEvents {
want := "event_key=" + eventKey + " rule_type=all rule_param={}"
if !strings.Contains(stderr.String(), want) {
t.Fatalf("multi OA dry-run missing %q:\n%s", want, stderr.String())
}
}
})
reuseOverrides := [][]string{
{"--user", "user-1"},
{"--open-dingtalk-id", "open-user-1"},
{"--group", "cid-1"},
{"--query", "urgent"},
{"--filter-json", `{"field":"content","op":"eq","value":"urgent"}`},
}
t.Run("reuse-dry-run/implicit-event-key/resolves-oa-event", func(t *testing.T) {
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
var stderr bytes.Buffer
cmd.SetOut(io.Discard)
cmd.SetErr(&stderr)
cmd.SetArgs([]string{"--subscribe-id", "oa-sub-task", "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("implicit reused OA dry-run error = %v", err)
}
if !strings.Contains(stderr.String(), "event_types : "+personal.EventOAApprovalTaskCreated) {
t.Fatalf("implicit reused OA dry-run did not resolve event key:\n%s", stderr.String())
}
})
for _, explicitEventKey := range []bool{true, false} {
mode := "implicit-event-key"
if explicitEventKey {
mode = "explicit-event-key"
}
for _, override := range reuseOverrides {
flag := override[0]
t.Run("reuse-dry-run/"+mode+"/"+strings.TrimPrefix(flag, "--"), func(t *testing.T) {
args := make([]string, 0, 6)
if explicitEventKey {
args = append(args, personal.EventOAApprovalTaskCreated)
}
args = append(args, "--subscribe-id", "oa-sub-task", flag, override[1], "--dry-run")
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs(args)
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), flag+" not supported for OA event") {
t.Fatalf("%s reused OA dry-run %s error = %v", mode, flag, err)
}
})
}
}
t.Run("reuse-dry-run/implicit-im-remains-supported", func(t *testing.T) {
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs([]string{"--subscribe-id", "im-sub-at", "--query", "urgent", "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("implicit reused IM dry-run error = %v", err)
}
})
for _, override := range reuseOverrides {
flag, value := override[0], override[1]
t.Run("multi-reject-"+strings.TrimPrefix(flag, "--"), func(t *testing.T) {
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
args := append([]string(nil), oaEvents...)
args = append(args, flag, value, "--dry-run")
cmd.SetArgs(args)
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "not supported for OA event") {
t.Fatalf("multi OA consume %s error = %v", flag, err)
}
})
}
for _, test := range []struct {
name string
args []string
}{
{
name: "message query remains supported",
args: []string{personal.EventMention, "--query", "urgent", "--dry-run"},
},
{
name: "single group lifecycle filter remains supported",
args: []string{
personal.EventGroupUpdated,
"--group", "cid-1",
"--filter-json", `{"field":"future","op":"eq","value":"value"}`,
"--dry-run",
},
},
} {
t.Run(test.name, func(t *testing.T) {
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs(test.args)
if err := cmd.Execute(); err != nil {
t.Fatalf("existing IM consume behavior changed: %v", err)
}
})
}
}
func TestCrossPlatformCoveragePersonalOAValidationBranches(t *testing.T) {
invalid := personalConsumeOptions{
EventKey: personal.EventOAApprovalTaskCreated,
UserID: "user-1",
}
if err := validatePersonalSubscriptionOptions(invalid); err == nil ||
!strings.Contains(err.Error(), "--user not supported for OA event") {
t.Fatalf("validatePersonalSubscriptionOptions() error = %v", err)
}
if _, err := preparePersonalSubscription(personal.Identity{}, invalid); err == nil ||
!strings.Contains(err.Error(), "--user not supported for OA event") {
t.Fatalf("preparePersonalSubscription() error = %v", err)
}
oldGet := personalGetSubscription
t.Cleanup(func() { personalGetSubscription = oldGet })
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
return &personal.Subscription{
SubscribeID: "oa-sub-without-event-key",
RuleType: "all",
}, nil
}
_, _, _, err := ensurePersonalSubscription(
context.Background(),
nil,
personal.Identity{},
personalConsumeOptions{
SubscribeID: "oa-sub-without-event-key",
EventKey: personal.EventOAApprovalTaskCreated,
UserID: "user-1",
},
)
if err == nil || !strings.Contains(err.Error(), "--user not supported for OA event") {
t.Fatalf("ensurePersonalSubscription() error = %v", err)
}
}
func TestPersonalOAMultiConsumeCreatesIndependentAllSubscriptionsOnSharedBus(t *testing.T) {
restoreMany := installPersonalManySeams(t)
defer restoreMany()
oldCreate := personalCreateSubscription
defer func() { personalCreateSubscription = oldCreate }()
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
identity := personal.Identity{
AccessToken: "token",
LocalSubject: "subject",
ClientID: "client",
SourceID: "open",
}
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
return identity, nil
}
var requests []personal.CreateSubscriptionRequest
personalCreateSubscription = func(_ *personal.Client, _ context.Context, req personal.CreateSubscriptionRequest) (*personal.Subscription, error) {
requests = append(requests, req)
return &personal.Subscription{SubscribeID: "sub-" + req.EventKey}, nil
}
personalEnsureSubscription = ensurePersonalSubscription
var states []personal.RunState
personalUpsertRunState = func(_ string, state personal.RunState) error {
states = append(states, state)
return nil
}
personalDeleteSubscription = func(*personal.Client, context.Context, string) error { return nil }
personalRemoveRunStates = func(string, []string) error { return nil }
personalValidateConsumeConfig = func(consume.Config) error { return nil }
runManyCalls := 0
var gotSpecs []consume.ConsumerSpec
personalConsumeRunMany = func(_ context.Context, _ consume.Config, specs []consume.ConsumerSpec) error {
runManyCalls++
gotSpecs = append([]consume.ConsumerSpec(nil), specs...)
return nil
}
eventKeys := []string{
personal.EventOAApprovalTaskCreated,
personal.EventOAApprovalTaskFinished,
personal.EventOAApprovalTaskRedirected,
personal.EventOAApprovalInstanceStarted,
personal.EventOAApprovalInstanceTerminated,
personal.EventOAApprovalInstanceFinished,
}
if err := runPersonalEventConsume(newPersonalCoverageCommand(), personalConsumeOptions{
EventKeys: eventKeys,
Flatten: true,
}); err != nil {
t.Fatalf("multi OA consume error = %v", err)
}
if runManyCalls != 1 {
t.Fatalf("RunMany calls = %d, want one shared-bus consume call", runManyCalls)
}
if len(requests) != len(eventKeys) || len(states) != len(eventKeys) || len(gotSpecs) != len(eventKeys) {
t.Fatalf("requests=%d states=%d specs=%d, want %d each", len(requests), len(states), len(gotSpecs), len(eventKeys))
}
for i, eventKey := range eventKeys {
req := requests[i]
if req.EventKey != eventKey || req.RuleType != "all" || req.RuleParam == nil || len(req.RuleParam) != 0 || req.Filter != nil {
t.Fatalf("subscription request[%d] = %#v, want %s all/{}", i, req, eventKey)
}
if states[i].EventKey != eventKey || states[i].RuleType != "all" {
t.Fatalf("run state[%d] = %#v", i, states[i])
}
wantSpec := consume.ConsumerSpec{
EventKey: eventKey,
EventTypes: []string{eventKey},
SubscribeID: "sub-" + eventKey,
ReadySubscribeID: "sub-" + eventKey,
}
if !reflect.DeepEqual(gotSpecs[i], wantSpec) {
t.Fatalf("consumer spec[%d] = %#v, want %#v", i, gotSpecs[i], wantSpec)
}
}
}
func TestPersonalOAReusedSubscriptionRejectsDefinitionOverridesAtRuntime(t *testing.T) {
oldGet := personalGetSubscription
t.Cleanup(func() { personalGetSubscription = oldGet })
getCalls := 0
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
getCalls++
return &personal.Subscription{
SubscribeID: "oa-sub-task",
EventKey: personal.EventOAApprovalTaskCreated,
RuleType: "all",
}, nil
}
tests := []struct {
name string
set func(*personalConsumeOptions)
}{
{name: "user", set: func(opts *personalConsumeOptions) { opts.UserID = "user-1" }},
{name: "open-dingtalk-id", set: func(opts *personalConsumeOptions) { opts.OpenDingTalkID = "open-user-1" }},
{name: "group", set: func(opts *personalConsumeOptions) { opts.GroupID = "cid-1" }},
{name: "query", set: func(opts *personalConsumeOptions) { opts.QueryCSV = "urgent" }},
{name: "filter-json", set: func(opts *personalConsumeOptions) { opts.FilterJSON = `{"field":"content","op":"eq","value":"urgent"}` }},
}
for _, explicitEventKey := range []bool{true, false} {
mode := "implicit-event-key"
if explicitEventKey {
mode = "explicit-event-key"
}
for _, test := range tests {
t.Run(mode+"/"+test.name, func(t *testing.T) {
opts := personalConsumeOptions{SubscribeID: "oa-sub-task"}
if explicitEventKey {
opts.EventKey = personal.EventOAApprovalTaskCreated
}
test.set(&opts)
before := getCalls
_, _, _, err := ensurePersonalSubscription(
context.Background(),
nil,
personal.Identity{},
opts,
)
if err == nil || !strings.Contains(err.Error(), "--"+test.name+" not supported for OA event") {
t.Fatalf("reused OA subscription %s error = %v", test.name, err)
}
if getCalls != before+1 {
t.Fatalf("subscription lookup calls = %d, want %d", getCalls, before+1)
}
})
}
}
}
func TestPersonalOAImplicitReuseRuntimeLooksUpEventBeforeValidation(t *testing.T) {
oldIdentity := personalResolveEventIdentity
oldGet := personalGetSubscription
t.Cleanup(func() {
personalResolveEventIdentity = oldIdentity
personalGetSubscription = oldGet
})
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
return personal.Identity{
AccessToken: "token",
LocalSubject: "subject",
ClientID: "client",
SourceID: "open",
}, nil
}
getCalls := 0
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
getCalls++
return &personal.Subscription{
SubscribeID: "oa-sub-task",
EventKey: personal.EventOAApprovalTaskCreated,
RuleType: "all",
}, nil
}
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs([]string{"--subscribe-id", "oa-sub-task", "--group", "cid-1"})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "--group not supported for OA event "+personal.EventOAApprovalTaskCreated) {
t.Fatalf("implicit reused OA runtime error = %v", err)
}
if getCalls != 1 {
t.Fatalf("subscription lookup calls = %d, want 1", getCalls)
}
}
func TestPersonalIMReusedSubscriptionWithExistingOverridesRemainsSupported(t *testing.T) {
oldGet := personalGetSubscription
t.Cleanup(func() { personalGetSubscription = oldGet })
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
return &personal.Subscription{
SubscribeID: "im-sub",
EventKey: personal.EventSingleChat,
RuleType: "singleChat",
}, nil
}
sub, eventKey, ruleType, err := ensurePersonalSubscription(
context.Background(),
nil,
personal.Identity{},
personalConsumeOptions{
SubscribeID: "im-sub",
EventKey: personal.EventSingleChat,
UserID: "user-1",
QueryCSV: "urgent",
FilterJSON: `{"field":"content","op":"eq","value":"urgent"}`,
},
)
if err != nil {
t.Fatalf("reused IM subscription error = %v", err)
}
if sub.SubscribeID != "im-sub" || eventKey != personal.EventSingleChat || ruleType != "singleChat" {
t.Fatalf("reused IM subscription = %#v, event=%q rule=%q", sub, eventKey, ruleType)
}
}
func TestPersonalOAStatusAndStopCommandWiring(t *testing.T) {
oldStatus := eventRunPersonalStatus
oldStop := eventRunPersonalStop
t.Cleanup(func() {
eventRunPersonalStatus = oldStatus
eventRunPersonalStop = oldStop
})
var statusOpts personalStatusOptions
eventRunPersonalStatus = func(_ *cobra.Command, opts personalStatusOptions) error {
statusOpts = opts
return nil
}
status := newEventStatusCommand()
status.SilenceUsage = true
status.SilenceErrors = true
status.SetOut(io.Discard)
status.SetErr(io.Discard)
status.SetArgs([]string{
"--event", personal.EventOAApprovalTaskCreated,
"--subscribe-id", "oa-sub-task",
"--status", "all",
})
if err := status.Execute(); err != nil {
t.Fatalf("OA event status error = %v", err)
}
if statusOpts.EventKey != personal.EventOAApprovalTaskCreated ||
statusOpts.SubscribeID != "oa-sub-task" ||
statusOpts.Status != "all" {
t.Fatalf("OA status options = %#v", statusOpts)
}
var stopOpts personalStopOptions
eventRunPersonalStop = func(_ *cobra.Command, opts personalStopOptions) error {
stopOpts = opts
return nil
}
root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true}
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.PersistentFlags().Bool("yes", false, "")
event := &cobra.Command{Use: "event"}
event.AddCommand(newEventStopCommand())
root.AddCommand(event)
root.SetArgs([]string{"event", "stop", "oa-sub-task", "--yes"})
if err := root.Execute(); err != nil {
t.Fatalf("OA event stop error = %v", err)
}
if stopOpts.SubscribeID != "oa-sub-task" || stopOpts.All {
t.Fatalf("OA stop options = %#v", stopOpts)
}
}
@@ -0,0 +1,427 @@
// 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"
"errors"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/source"
)
func TestPersonalConsumeCleanupOwnershipRuntimeMatrix(t *testing.T) {
runErr := errors.New("runtime failed")
for _, foreground := range []bool{false, true} {
for _, selfCreated := range []bool{false, true} {
for _, ephemeral := range []bool{false, true} {
for _, failRuntime := range []bool{false, true} {
name := strings.Join([]string{
map[bool]string{false: "background", true: "foreground"}[foreground],
map[bool]string{false: "reused", true: "self-created"}[selfCreated],
map[bool]string{false: "persistent", true: "ephemeral"}[ephemeral],
map[bool]string{false: "success", true: "error"}[failRuntime],
}, "/")
t.Run(name, func(t *testing.T) {
restore := installPersonalManySeams(t)
t.Cleanup(restore)
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
oldNewSource := personalNewStreamSource
oldBusRun := personalBusRun
oldConsumeRun := personalConsumeRun
t.Cleanup(func() {
personalNewStreamSource = oldNewSource
personalBusRun = oldBusRun
personalConsumeRun = oldConsumeRun
})
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
return personal.Identity{
AccessToken: "token",
ClientID: "client",
SourceID: "open",
LocalSubject: "subject",
}, nil
}
personalEnsureSubscription = func(
context.Context,
*personal.Client,
personal.Identity,
personalConsumeOptions,
) (*personal.Subscription, string, string, error) {
return &personal.Subscription{SubscribeID: "sub-one"}, personal.EventMention, "at", nil
}
personalValidateConsumeConfig = func(consume.Config) error { return nil }
personalValidateNoOutputConflict = func(consume.Config, string) error { return nil }
personalUpsertRunState = func(string, personal.RunState) error { return nil }
deleteCalls := 0
removeCalls := 0
personalDeleteSubscription = func(*personal.Client, context.Context, string) error {
deleteCalls++
return nil
}
personalRemoveRunStates = func(string, []string) error {
removeCalls++
return nil
}
personalNewStreamSource = func(context.Context, personalStreamSourceOptions) (*source.PersonalSource, error) {
return nil, nil
}
personalBusRun = func(context.Context, bus.Config) error {
if failRuntime {
return runErr
}
return nil
}
personalConsumeRun = func(context.Context, consume.Config) error {
if failRuntime {
return runErr
}
return nil
}
opts := personalConsumeOptions{
EventKey: personal.EventMention,
Ephemeral: ephemeral,
ControlBaseURL: "https://mcp.example.test/dws",
Common: commonConsumeOptions{
Foreground: foreground,
},
}
if !selfCreated {
opts.SubscribeID = "sub-one"
}
err := runPersonalEventConsumeSingle(newPersonalCoverageCommand(), opts)
if failRuntime {
if !errors.Is(err, runErr) {
t.Fatalf("runtime error = %v, want %v", err, runErr)
}
} else if err != nil {
t.Fatalf("consume error = %v", err)
}
wantCleanup := 0
if selfCreated || ephemeral {
wantCleanup = 1
}
if deleteCalls != wantCleanup || removeCalls != wantCleanup {
t.Fatalf(
"cleanup delete/remove = %d/%d, want %d/%d",
deleteCalls,
removeCalls,
wantCleanup,
wantCleanup,
)
}
})
}
}
}
}
}
func TestPersonalConsumeCleanupOwnershipOnRunStateFailure(t *testing.T) {
stateErr := errors.New("save state failed")
for _, test := range []struct {
name string
selfCreated bool
ephemeral bool
wantCleanup int
}{
{name: "self-created", selfCreated: true, wantCleanup: 1},
{name: "reused persistent", wantCleanup: 0},
{name: "reused ephemeral", ephemeral: true, wantCleanup: 1},
} {
t.Run(test.name, func(t *testing.T) {
restore := installPersonalManySeams(t)
t.Cleanup(restore)
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
return personal.Identity{AccessToken: "token", ClientID: "client", SourceID: "open"}, nil
}
personalEnsureSubscription = func(
context.Context,
*personal.Client,
personal.Identity,
personalConsumeOptions,
) (*personal.Subscription, string, string, error) {
return &personal.Subscription{SubscribeID: "sub-one"}, personal.EventMention, "at", nil
}
personalValidateConsumeConfig = func(consume.Config) error { return nil }
personalUpsertRunState = func(string, personal.RunState) error { return stateErr }
deleteCalls := 0
removeCalls := 0
personalDeleteSubscription = func(*personal.Client, context.Context, string) error {
deleteCalls++
return nil
}
personalRemoveRunStates = func(string, []string) error {
removeCalls++
return nil
}
opts := personalConsumeOptions{
EventKey: personal.EventMention,
Ephemeral: test.ephemeral,
ControlBaseURL: "https://mcp.example.test/dws",
}
if !test.selfCreated {
opts.SubscribeID = "sub-one"
}
if err := runPersonalEventConsumeSingle(newPersonalCoverageCommand(), opts); !errors.Is(err, stateErr) {
t.Fatalf("state error = %v, want %v", err, stateErr)
}
if deleteCalls != test.wantCleanup || removeCalls != test.wantCleanup {
t.Fatalf(
"cleanup delete/remove = %d/%d, want %d/%d",
deleteCalls,
removeCalls,
test.wantCleanup,
test.wantCleanup,
)
}
})
}
}
func TestPersonalReusedSubscriptionEventKeyResolution(t *testing.T) {
oldGet := personalGetSubscription
t.Cleanup(func() { personalGetSubscription = oldGet })
for _, test := range []struct {
name string
requested string
actual string
wantKey string
wantErr bool
}{
{
name: "matching key uses actual",
requested: personal.EventMention,
actual: personal.EventMention,
wantKey: personal.EventMention,
},
{
name: "implicit key uses actual",
actual: personal.EventOAApprovalTaskCreated,
wantKey: personal.EventOAApprovalTaskCreated,
},
{
name: "missing actual falls back to requested",
requested: personal.EventOAApprovalTaskCreated,
wantKey: personal.EventOAApprovalTaskCreated,
},
{
name: "requested IM mismatches actual OA",
requested: personal.EventMention,
actual: personal.EventOAApprovalTaskCreated,
wantErr: true,
},
{
name: "requested OA mismatches actual IM",
requested: personal.EventOAApprovalTaskCreated,
actual: personal.EventMention,
wantErr: true,
},
} {
t.Run(test.name, func(t *testing.T) {
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
return &personal.Subscription{
SubscribeID: "sub-one",
EventKey: test.actual,
}, nil
}
_, eventKey, _, err := ensurePersonalSubscription(
context.Background(),
nil,
personal.Identity{},
personalConsumeOptions{SubscribeID: "sub-one", EventKey: test.requested},
)
if test.wantErr {
if err == nil || !strings.Contains(err.Error(), "does not match reused subscription") {
t.Fatalf("mismatch error = %v", err)
}
if !strings.Contains(err.Error(), test.requested) || !strings.Contains(err.Error(), test.actual) {
t.Fatalf("mismatch error does not identify both keys: %v", err)
}
return
}
if err != nil {
t.Fatalf("resolve reused subscription: %v", err)
}
if eventKey != test.wantKey {
t.Fatalf("resolved event key = %q, want %q", eventKey, test.wantKey)
}
})
}
}
func TestPersonalReusedSubscriptionMismatchStopsDryRunAndRuntime(t *testing.T) {
for _, mode := range []struct {
name string
dryRun bool
foreground bool
}{
{name: "dry-run", dryRun: true},
{name: "background"},
{name: "foreground", foreground: true},
} {
t.Run(mode.name, func(t *testing.T) {
restore := installPersonalManySeams(t)
t.Cleanup(restore)
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
oldGet := personalGetSubscription
oldNewSource := personalNewStreamSource
oldBusRun := personalBusRun
oldConsumeRun := personalConsumeRun
t.Cleanup(func() {
personalGetSubscription = oldGet
personalNewStreamSource = oldNewSource
personalBusRun = oldBusRun
personalConsumeRun = oldConsumeRun
})
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
return personal.Identity{AccessToken: "token", ClientID: "client", SourceID: "open"}, nil
}
personalEnsureSubscription = ensurePersonalSubscription
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
return &personal.Subscription{
SubscribeID: "sub-one",
EventKey: personal.EventOAApprovalTaskCreated,
RuleType: "all",
}, nil
}
personalValidateConsumeConfig = func(consume.Config) error { return nil }
personalValidateNoOutputConflict = func(consume.Config, string) error { return nil }
upsertCalls := 0
consumeCalls := 0
busCalls := 0
personalUpsertRunState = func(string, personal.RunState) error {
upsertCalls++
return nil
}
personalNewStreamSource = func(context.Context, personalStreamSourceOptions) (*source.PersonalSource, error) {
return nil, nil
}
personalBusRun = func(context.Context, bus.Config) error {
busCalls++
return nil
}
personalConsumeRun = func(context.Context, consume.Config) error {
consumeCalls++
return nil
}
err := runPersonalEventConsumeSingle(newPersonalCoverageCommand(), personalConsumeOptions{
EventKey: personal.EventMention,
SubscribeID: "sub-one",
ControlBaseURL: "https://mcp.example.test/dws",
Common: commonConsumeOptions{
DryRun: mode.dryRun,
Foreground: mode.foreground,
},
})
if err == nil || !strings.Contains(err.Error(), "does not match reused subscription") {
t.Fatalf("mismatch error = %v", err)
}
if upsertCalls != 0 || consumeCalls != 0 || busCalls != 0 {
t.Fatalf(
"mismatch reached upsert/consumer/bus = %d/%d/%d",
upsertCalls,
consumeCalls,
busCalls,
)
}
})
}
}
func TestPersonalReusedSubscriptionUsesActualKeyInDryRunAndRuntime(t *testing.T) {
for _, dryRun := range []bool{true, false} {
name := map[bool]string{false: "runtime", true: "dry-run"}[dryRun]
t.Run(name, func(t *testing.T) {
restore := installPersonalManySeams(t)
t.Cleanup(restore)
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
oldGet := personalGetSubscription
oldConsumeRun := personalConsumeRun
t.Cleanup(func() {
personalGetSubscription = oldGet
personalConsumeRun = oldConsumeRun
})
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
return personal.Identity{AccessToken: "token", ClientID: "client", SourceID: "open"}, nil
}
personalEnsureSubscription = ensurePersonalSubscription
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
return &personal.Subscription{
SubscribeID: "sub-oa",
EventKey: personal.EventOAApprovalInstanceFinished,
RuleType: "all",
}, nil
}
personalValidateConsumeConfig = func(consume.Config) error { return nil }
personalValidateNoOutputConflict = func(consume.Config, string) error { return nil }
personalUpsertRunState = func(_ string, state personal.RunState) error {
if state.EventKey != personal.EventOAApprovalInstanceFinished {
t.Fatalf("run state event key = %q", state.EventKey)
}
return nil
}
var got consume.Config
personalConsumeRun = func(_ context.Context, cfg consume.Config) error {
got = cfg
return nil
}
if err := runPersonalEventConsumeSingle(newPersonalCoverageCommand(), personalConsumeOptions{
SubscribeID: "sub-oa",
ControlBaseURL: "https://mcp.example.test/dws",
Common: commonConsumeOptions{
DryRun: dryRun,
},
}); err != nil {
t.Fatalf("reuse subscription: %v", err)
}
if got.EventKey != personal.EventOAApprovalInstanceFinished ||
len(got.EventTypes) != 1 || got.EventTypes[0] != personal.EventOAApprovalInstanceFinished ||
got.SubscribeID != "sub-oa" {
t.Fatalf("consume config = %#v", got)
}
})
}
}
func TestEventConsumeDryRunHelpDescribesReuseLookup(t *testing.T) {
usage := newEventConsumeCommand().Flags().Lookup("dry-run").Usage
for _, want := range []string{"不创建订阅", "不连接 bus", "复用 --subscribe-id", "只读查询控制面"} {
if !strings.Contains(usage, want) {
t.Fatalf("dry-run help %q missing %q", usage, want)
}
}
}
@@ -0,0 +1,74 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package app
import (
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
func TestCrossPlatformCoverageEventAgentSelectionBoundaries(t *testing.T) {
_ = NewRootCommand()
eventProduct, ok := contract.LookupProductDecl("event")
if !ok {
t.Fatal("event ProductDecl is not registered")
}
assertSelectionContains(t, "event product", eventProduct.Selection.AgentSummary,
[]string{"IM", "OA"})
assertSelectionContains(t, "event product use_when", strings.Join(eventProduct.Selection.UseWhen, "\n"),
[]string{"消息", "群生命周期", "OA"})
assertSelectionContains(t, "event product avoid_when", strings.Join(eventProduct.Selection.AvoidWhen, "\n"),
[]string{"chat", "oa", "dev app event"})
listenMeta, ok := cli.ResolveMeta("event +listen-im")
if !ok {
t.Fatal("event +listen-im metadata is not registered")
}
assertSelectionContains(t, "event.listen_im use_when", strings.Join(listenMeta.Selection.UseWhen, "\n"),
[]string{"@我", "message/reaction/read/recall"})
assertSelectionContains(t, "event.listen_im avoid_when", strings.Join(listenMeta.Selection.AvoidWhen, "\n"),
[]string{"OA 审批事件", "群标题", "Filter DSL", "event consume", "历史消息"})
consumeMeta, ok := cli.ResolveMeta("event consume")
if !ok {
t.Fatal("event consume metadata is not registered")
}
consumeUse := strings.Join(consumeMeta.Selection.UseWhen, "\n")
assertSelectionContains(t, "event.consume use_when", consumeUse,
[]string{"OA", "群", "EventKey", "Filter DSL", "subscribe_id", "transport envelope", "高级多事件"})
consumeAvoid := strings.Join(consumeMeta.Selection.AvoidWhen, "\n")
assertSelectionContains(t, "event.consume avoid_when", consumeAvoid,
[]string{"event +listen-im", "历史聊天", "oa", "dev app event"})
schemaMeta, ok := cli.ResolveMeta("event schema")
if !ok {
t.Fatal("event schema metadata is not registered")
}
assertSelectionContains(t, "event.schema use_when", strings.Join(schemaMeta.Selection.UseWhen, "\n"),
[]string{"IM", "OA", "--flatten"})
for productID, want := range map[string]string{
"chat": "event +listen-im",
"oa": "event consume",
} {
decl, found := contract.LookupProductDecl(productID)
if !found {
t.Fatalf("%s ProductDecl is not registered", productID)
}
assertSelectionContains(t, productID+" avoid_when", strings.Join(decl.Selection.AvoidWhen, "\n"), []string{want})
}
}
func assertSelectionContains(t *testing.T, label, text string, fragments []string) {
t.Helper()
for _, fragment := range fragments {
if !strings.Contains(text, fragment) {
t.Errorf("%s = %q, want fragment %q", label, text, fragment)
}
}
}
+15 -2
View File
@@ -39,7 +39,7 @@ func (c *paramAliasCaptureCaller) CallTool(_ context.Context, server, tool strin
copyArgs[key] = value
}
c.calls = append(c.calls, paramAliasToolCall{server: server, tool: tool, args: copyArgs})
text := paramAliasResponseForTool(tool)
text := c.paramAliasResponseForTool(tool)
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
}
@@ -48,7 +48,7 @@ func (c *paramAliasCaptureCaller) CallTool(_ context.Context, server, tool strin
// print the transport result and need an empty object; smart shortcuts that
// inspect a read response receive the smallest shape that lets their full RunE
// complete without falling back to a validation error.
func paramAliasResponseForTool(tool string) string {
func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string {
switch tool {
case "list_calendar_events":
return `{"result":{"events":[]}}`
@@ -62,9 +62,22 @@ func paramAliasResponseForTool(tool string) string {
return `{"result":[{"name":"Fixture User","userId":"fixture-user","openDingTalkId":"D-fixture-user"}]}`
case "list_doc_versions":
return `{"result":{"items":[{"version":3}]}}`
case "revert_doc_version":
return `{"version":3}`
case "search_doc_templates":
return `{"result":[{"templateId":"fixture-template-id"}]}`
case "create_document":
return `{"nodeId":"fixture-node"}`
case "get_document_content":
for index := len(c.calls) - 2; index >= 0; index-- {
call := c.calls[index]
for _, key := range []string{"jsonml", "markdown"} {
if content, ok := call.args[key].(string); ok {
encoded, _ := json.Marshal(map[string]any{"revision": 1, key: content})
return string(encoded)
}
}
}
return `{"revision":1}`
default:
return `{}`
@@ -123,7 +123,7 @@ var paramAliasCompleteCommands = map[string][]string{
"doc +version-list": {"doc", "+version-list", "--node", "node-1", "--limit", "7", "--cursor", "cursor-1"},
"doc +version-revert": {"doc", "+version-revert", "--node", "node-1", "--version", "3", "--yes"},
"doc +version-save": {"doc", "+version-save", "--node", "node-1", "--yes"},
"doc +update": {"doc", "+update", "--node", "node-1", "--command", "append", "--content", "fixture body", "--expected-revision", "1", "--yes"},
"doc +update": {"doc", "+update", "--node", "node-1", "--command", "overwrite", "--content", `["root",{}]`, "--doc-format", "jsonml", "--expected-revision", "1", "--yes"},
"doc block insert": {"doc", "block", "insert", "--node", "node-1", "--text", "fixture paragraph", "--yes"},
"doc block update": {"doc", "block", "update", "--node", "node-1", "--block-id", "block-1", "--text", "fixture paragraph", "--yes"},
"doc comment create": {"doc", "comment", "create", "--node", "node-1", "--content", "fixture comment", "--yes"},
@@ -296,7 +296,14 @@ func newAgentExampleFiles(t testing.TB, root string) agentExampleFiles {
t.Fatalf("write dry-run fixture %s: %v", path, err)
}
}
return agentExampleFiles{root: root, markdown: markdown, json: jsonFile, batch: batch, binary: binary, image: image}
return agentExampleFiles{
root: root,
markdown: "./" + filepath.Base(markdown),
json: "./" + filepath.Base(jsonFile),
batch: "./" + filepath.Base(batch),
binary: "./" + filepath.Base(binary),
image: "./" + filepath.Base(image),
}
}
func materializeAgentExampleArgv(argv []string, files agentExampleFiles) []string {
@@ -129,6 +129,75 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
}
}
func TestDeliveryDocUpdateShortcutPublishesCompleteConditionalContract(t *testing.T) {
leaf := executeShortcutSchemaQuery(t, "--cli-path", "doc +update")
if got, want := schemaContractString(leaf["confirmation"]), "user_required"; got != want {
t.Fatalf("confirmation = %q, want %q", got, want)
}
parameters := schemaContractMap(leaf["parameters"])
if got, want := len(parameters), 11; got != want {
t.Fatalf("parameter count = %d, want %d: %#v", got, want, parameters)
}
if required, _ := parameters["node"]["required"].(bool); !required {
t.Errorf("--node required = %#v, want true", parameters["node"]["required"])
}
if required, _ := parameters["command"]["required"].(bool); required {
t.Errorf("--command required = true, want runtime custom validation")
}
wantProperties := map[string]string{
"node": "node", "doc": "node", "command": "command", "content": "content", "text": "content", "doc-format": "docFormat",
"block-id": "blockId", "after-block-id": "afterBlockId", "old": "old", "new": "new",
"expected-revision": "expectedRevision",
}
for name, want := range wantProperties {
if got := schemaContractString(parameters[name]["property"]); got != want {
t.Errorf("--%s property = %q, want %q", name, got, want)
}
}
for _, name := range []string{"content", "block-id", "after-block-id", "old", "new"} {
parameter := parameters[name]
if required, _ := parameter["required"].(bool); required {
t.Errorf("--%s required = true, want runtime custom validation", name)
}
if got := schemaContractString(parameter["required_when"]); got != "" {
t.Errorf("--%s required_when = %q, want compatibility-safe custom validation", name, got)
}
}
if constraints, exists := leaf["constraints"]; exists && constraints != nil {
t.Fatalf("enum-discriminated requirements must not be mispublished as relationship constraints: %#v", constraints)
}
}
func TestDeliveryDocCommentExportImportContractsAreCanonical(t *testing.T) {
comment := executeShortcutSchemaQuery(t, "--cli-path", "doc +comment-create")
commentParameters := schemaContractMap(comment["parameters"])
for _, name := range []string{"node", "content", "selection", "block-id", "start", "end", "selected-text", "mention"} {
if _, ok := commentParameters[name]; !ok {
t.Errorf("comment-create missing --%s: %#v", name, commentParameters)
}
}
for name, want := range map[string]string{"node": "node", "mention": "mention"} {
if got := schemaContractString(commentParameters[name]["property"]); got != want {
t.Errorf("comment-create --%s property = %q, want %q", name, got, want)
}
}
export := executeShortcutSchemaQuery(t, "--cli-path", "doc +export")
exportFormat := schemaContractMap(export["parameters"])["export-format"]
if required, _ := exportFormat["required"].(bool); required {
t.Fatalf("export --export-format required = true, want compatibility default")
}
if defaultValue := schemaContractString(exportFormat["default"]); defaultValue != "docx" {
t.Fatalf("export --export-format default = %q, want docx", defaultValue)
}
importLeaf := executeShortcutSchemaQuery(t, "--cli-path", "doc +import")
constraints, _ := importLeaf["constraints"].(map[string]any)
if requireOneOf, ok := constraints["require_one_of"]; ok && !schemaContractJSONEqual(requireOneOf, [][]string{}) {
t.Fatalf("import unexpectedly requires a target: %#v", constraints)
}
}
func executeShortcutSchemaQuery(t testing.TB, args ...string) map[string]any {
t.Helper()
root := NewRootCommand()
+577 -1
View File
@@ -51,6 +51,8 @@ var (
skillSetupInstallMono = installSkillToHomes
skillSetupInstallMulti = installMultiSkillToHomes
skillSetupCopyDir = copyDir
skillSetupMkdirTemp = os.MkdirTemp
skillSetupRename = os.Rename
skillSetupRunForm = (*huh.Form).Run
skillSetupInteractive = isInteractiveTerminal
skillSetupReadDir = os.ReadDir
@@ -136,6 +138,9 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
// multi 模式枚举 src 下的子 skill 名,供确认信息与安装步骤共用
var multiSkillNames []string
var foldedEventMiscTargets []string
var migrateEventMiscTargets []string
var installsEventMiscCompanion bool
if mode == skillSetupModeMulti {
allMultiSkillNames, listErr := skillSetupListMulti(skillSrc)
if listErr != nil {
@@ -151,6 +156,30 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
// dingtalk-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)
foldedEventMiscTargets = findFoldedEventMiscTargets(dests)
if len(foldedEventMiscTargets) > 0 {
hasEvent := containsSkillName(multiSkillNames, multiEventSkill)
hasMisc := containsSkillName(multiSkillNames, multiMiscSkill)
switch {
case normalizedSkillListContains(excludeRaw, multiEventSkill):
return fmt.Errorf("检测到已有 dingtalk-misc 仍承载个人 Event 路由;不能显式 --exclude event,请先完成 dingtalk-event 迁移")
case hasMisc && !hasEvent:
return fmt.Errorf("检测到已有 dingtalk-misc 仍承载个人 Event 路由;不能只覆盖 dingtalk-misc,必须同时迁移 dingtalk-event")
case hasEvent:
if normalizedSkillListContains(excludeRaw, multiMiscSkill) {
return fmt.Errorf("检测到已有 dingtalk-misc 仍承载个人 Event 路由;本次安装 dingtalk-event 必须同时迁移 dingtalk-misc,不能显式 --exclude misc")
}
if !containsSkillName(allMultiSkillNames, multiMiscSkill) {
return fmt.Errorf("检测到已有 dingtalk-misc 仍承载个人 Event 路由,但当前 multi 源缺少迁移所需的 %s", multiMiscSkill)
}
if err := validateEventMiscMigrationSource(skillSrc); err != nil {
return err
}
migrateEventMiscTargets = append(migrateEventMiscTargets, foldedEventMiscTargets...)
installsEventMiscCompanion = !hasMisc
}
}
}
// --dry-run:仅预览将安装的内容与目标目录,不写入任何文件、不弹确认。
@@ -162,11 +191,15 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
}
if mode == skillSetupModeMulti && len(multiSkillNames) > 0 {
fmt.Fprintf(out, "子 skill:%s\n", strings.Join(multiSkillNames, ", "))
printEventMiscMigrationPreview(out, migrateEventMiscTargets, installsEventMiscCompanion)
}
return nil
}
if !autoYes {
if mode == skillSetupModeMulti {
printEventMiscMigrationPreview(out, migrateEventMiscTargets, installsEventMiscCompanion)
}
ok, err := skillSetupConfirm(out, mode, skillSrc, dests, multiSkillNames)
if err != nil {
return err
@@ -182,7 +215,14 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
case skillSetupModeMono:
installed, skipped, err = skillSetupInstallMono(skillSrc, dests, out, errOut)
case skillSetupModeMulti:
installed, skipped, err = skillSetupInstallMulti(skillSrc, multiSkillNames, dests, out, errOut)
installed, skipped, err = installMultiSkillsWithEventMigration(
skillSrc,
multiSkillNames,
dests,
migrateEventMiscTargets,
out,
errOut,
)
default:
return fmt.Errorf("内部错误:未知 mode %q", mode)
}
@@ -191,6 +231,7 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
}
fmt.Fprintf(out, "\n✅ Skill 安装完成(mode=%s, installed=%d, skipped=%d)\n", mode, installed, skipped)
fmt.Fprintln(out, "ℹ️ 若 Agent 会话已打开,请重启 Agent 或重新加载 Skills 后再验证路由。")
return nil
}
@@ -204,6 +245,226 @@ const multiSkillPrefix = "dingtalk-"
// dingtalk-shared that was never installed.
const multiSharedSkill = "dingtalk-shared"
// legacyMultiSharedSkill is the retired name shipped by older multi-skill
// bundles. Once the replacement has been installed successfully, remove this
// exact directory so Agent discovery cannot load both routing contracts.
const legacyMultiSharedSkill = "dws-shared"
const (
multiEventSkill = "dingtalk-event"
multiMiscSkill = "dingtalk-misc"
)
var eventMigrationRequiredReferences = []string{
"event-im.md",
"event-im-keys.md",
"event-im-lifecycle.md",
"event-im-operations.md",
"event-im-output.md",
"event-oa.md",
}
func containsSkillName(names []string, want string) bool {
for _, name := range names {
if name == want {
return true
}
}
return false
}
func normalizedSkillListContains(raw []string, want string) bool {
for _, name := range raw {
if normalizeMultiSkillName(name) == want {
return true
}
}
return false
}
// findFoldedEventMiscTargets identifies the short-lived multi-skill layout in
// which personal Event routing lived inside dingtalk-misc. Both markers are
// required so an unrelated misc install is never treated as a migration target.
func findFoldedEventMiscTargets(dests []string) []string {
var targets []string
for _, dest := range dests {
miscRoot := filepath.Join(dest, multiMiscSkill)
skillBody, err := os.ReadFile(filepath.Join(miscRoot, "SKILL.md"))
if err != nil || !containsPersonalEventRoute(skillBody) {
continue
}
eventRef, err := skillSetupStat(filepath.Join(miscRoot, "references", "event.md"))
if err != nil || eventRef.IsDir() {
continue
}
targets = append(targets, dest)
}
sort.Strings(targets)
return targets
}
func containsPersonalEventRoute(skillBody []byte) bool {
body := strings.ToLower(string(skillBody))
for _, marker := range []string{
"dws event",
"个人 event",
"个人 im 事件",
"个人 im/oa",
"personal event",
} {
if strings.Contains(body, marker) {
return true
}
}
return false
}
func printEventMiscMigrationPreview(out io.Writer, targets []string, installsCompanion bool) {
if len(targets) == 0 {
return
}
action := "将原子切换 dingtalk-event 与本次已选择的干净 dingtalk-misc"
if installsCompanion {
action = "将原子切换 dingtalk-event,并额外安装干净的 dingtalk-misc 作为迁移伴侣(仅限以下目标)"
}
fmt.Fprintf(out, "Event Skill 迁移:%s:\n", action)
for _, target := range targets {
fmt.Fprintf(out, " - %s\n", target)
}
}
func validateEventMiscMigrationSource(src string) error {
if err := validateEventMigrationSkillRoot(filepath.Join(src, multiEventSkill)); err != nil {
return fmt.Errorf("event Skill 迁移源无效: %w", err)
}
if err := validateMigrationSkillRoot(filepath.Join(src, multiMiscSkill), multiMiscSkill, nil); err != nil {
return fmt.Errorf("event Skill 迁移源无效: %w", err)
}
if err := validateCleanEventMiscRoot(filepath.Join(src, multiMiscSkill)); err != nil {
return fmt.Errorf("event Skill 迁移源无效: %w", err)
}
return nil
}
func validateEventMigrationSkillRoot(root string) error {
required := make([]string, 0, len(eventMigrationRequiredReferences))
for _, name := range eventMigrationRequiredReferences {
required = append(required, filepath.Join("references", name))
}
return validateMigrationSkillRoot(root, multiEventSkill, required)
}
func validateMigrationSkillRoot(root, expectedName string, requiredFiles []string) error {
skillPath := filepath.Join(root, "SKILL.md")
skillBody, err := os.ReadFile(skillPath)
if err != nil {
return fmt.Errorf("无法读取 %s: %w", skillPath, err)
}
name, err := parseMigrationSkillFrontmatter(skillBody)
if err != nil {
return fmt.Errorf("%s 无效: %w", skillPath, err)
}
if name != expectedName {
return fmt.Errorf("%s 的 name=%q,期望 %q", skillPath, name, expectedName)
}
for _, rel := range requiredFiles {
path := filepath.Join(root, rel)
info, statErr := skillSetupStat(path)
if statErr != nil || info.IsDir() {
if statErr == nil {
statErr = errors.New("is a directory")
}
return fmt.Errorf("缺少有效文件 %s: %w", path, statErr)
}
body, readErr := os.ReadFile(path)
if readErr != nil {
return fmt.Errorf("无法读取 %s: %w", path, readErr)
}
if strings.TrimSpace(string(body)) == "" {
return fmt.Errorf("文件为空 %s", path)
}
}
return nil
}
func parseMigrationSkillFrontmatter(body []byte) (string, error) {
normalized := strings.ReplaceAll(string(body), "\r\n", "\n")
lines := strings.Split(normalized, "\n")
if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" {
return "", errors.New("缺少 YAML frontmatter")
}
name := ""
description := ""
closingLine := -1
for i := 1; i < len(lines); i++ {
rawLine := lines[i]
line := strings.TrimSpace(rawLine)
if line == "---" {
closingLine = i
break
}
// Only inspect top-level frontmatter keys. Nested metadata may legally
// contain its own `name` without changing the Skill identity.
if strings.TrimLeft(rawLine, " \t") != rawLine {
continue
}
key, value, ok := strings.Cut(line, ":")
if !ok {
continue
}
value = strings.Trim(strings.TrimSpace(value), "\"'")
switch strings.TrimSpace(key) {
case "name":
if name != "" {
return "", errors.New("frontmatter 含重复 name")
}
name = value
case "description":
description = value
}
}
if closingLine < 0 {
return "", errors.New("YAML frontmatter 未闭合")
}
if name == "" {
return "", errors.New("frontmatter 缺少 name")
}
if description == "" {
return "", errors.New("frontmatter 缺少 description")
}
if strings.TrimSpace(strings.Join(lines[closingLine+1:], "\n")) == "" {
return "", errors.New("SKILL.md 正文为空")
}
return name, nil
}
func validateCleanEventMiscRoot(miscRoot string) error {
miscSkillPath := filepath.Join(miscRoot, "SKILL.md")
miscBody, err := os.ReadFile(miscSkillPath)
if err != nil {
return fmt.Errorf("无法读取 %s: %w", miscSkillPath, err)
}
if containsPersonalEventRoute(miscBody) {
return fmt.Errorf("%s 仍包含个人 Event 路由", miscSkillPath)
}
refsRoot := filepath.Join(miscRoot, "references")
entries, err := skillSetupReadDir(refsRoot)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return fmt.Errorf("无法检查 %s: %w", refsRoot, err)
}
for _, entry := range entries {
name := strings.ToLower(entry.Name())
if !entry.IsDir() && strings.HasPrefix(name, "event") && strings.HasSuffix(name, ".md") {
return fmt.Errorf("%s 仍存在折叠 Event 参考页", filepath.Join(refsRoot, entry.Name()))
}
}
return nil
}
// ensureMandatorySharedSkill guarantees the shared dependency skill is included
// whenever it exists in the source, even if --skill / --exclude narrowed it out.
func ensureMandatorySharedSkill(selected, all []string) []string {
@@ -607,6 +868,21 @@ func cleanupMutualExclusion(dest, mode string, out, errOut io.Writer) {
}
}
func cleanupLegacyMultiSharedSkill(dest string, out, errOut io.Writer) {
legacyPath := filepath.Join(dest, legacyMultiSharedSkill)
if _, err := skillSetupStat(legacyPath); err != nil {
if !errors.Is(err, os.ErrNotExist) {
fmt.Fprintf(errOut, " ⚠️ 无法检查已退役 Skill 残留 %s: %v\n", legacyPath, err)
}
return
}
if err := skillSetupRemoveAll(legacyPath); err != nil {
fmt.Fprintf(errOut, " ⚠️ 已退役 Skill 清理失败(已安装 %s) %s: %v\n", multiSharedSkill, legacyPath, err)
return
}
fmt.Fprintf(out, " × 已清理已退役 Skill 残留 %s\n", legacyPath)
}
func installSkillToHomes(src string, dests []string, out, errOut io.Writer) (installed, skipped int, err error) {
sort.Strings(dests)
for _, dest := range dests {
@@ -634,6 +910,299 @@ func installSkillToHomes(src string, dests []string, out, errOut io.Writer) (ins
return installed, skipped, nil
}
func installMultiSkillsWithEventMigration(
src string,
skillNames []string,
dests []string,
migrationTargets []string,
out, errOut io.Writer,
) (installed, skipped int, err error) {
if len(migrationTargets) == 0 {
return skillSetupInstallMulti(src, skillNames, dests, out, errOut)
}
migrationSet := make(map[string]struct{}, len(migrationTargets))
for _, dest := range migrationTargets {
migrationSet[dest] = struct{}{}
}
var ordinaryTargets []string
for _, dest := range dests {
if _, migrates := migrationSet[dest]; !migrates {
ordinaryTargets = append(ordinaryTargets, dest)
}
}
if len(ordinaryTargets) > 0 {
var n, nSkipped int
n, nSkipped, err = skillSetupInstallMulti(src, skillNames, ordinaryTargets, out, errOut)
installed += n
skipped += nSkipped
if err != nil {
return installed, skipped, err
}
if nSkipped > 0 {
return installed, skipped, fmt.Errorf("multi Skill 安装不完整(skipped=%d);已保留折叠版 Event/misc,未执行迁移", nSkipped)
}
}
// The folded pair is excluded from the ordinary best-effort installer. All
// other selected skills (especially dingtalk-shared) must succeed before the
// old Event route is touched.
for _, dest := range migrationTargets {
cleanupMutualExclusion(dest, skillSetupModeMulti, out, errOut)
}
var prerequisiteNames []string
for _, name := range skillNames {
if name != multiEventSkill && name != multiMiscSkill {
prerequisiteNames = append(prerequisiteNames, name)
}
}
if len(prerequisiteNames) > 0 {
var n, nSkipped int
n, nSkipped, err = skillSetupInstallMulti(src, prerequisiteNames, migrationTargets, out, errOut)
installed += n
skipped += nSkipped
if err != nil {
return installed, skipped, err
}
if nSkipped > 0 {
return installed, skipped, fmt.Errorf("event Skill 迁移前置安装不完整(skipped=%d);已保留折叠版 Event/misc", nSkipped)
}
}
migrated, migrationErr := migrateEventMiscAtomically(src, migrationTargets, out, errOut)
installed += migrated
if migrationErr != nil {
return installed, skipped, migrationErr
}
return installed, skipped, nil
}
type eventMiscMigration struct {
dest string
stageRoot string
stagedEvent string
stagedMisc string
backupEvent string
backupMisc string
eventPath string
miscPath string
eventBackedUp bool
miscBackedUp bool
newEventEnabled bool
newMiscEnabled bool
}
func prepareEventMiscMigration(src, dest string) (*eventMiscMigration, error) {
stageRoot, err := skillSetupMkdirTemp(dest, ".dws-event-migration-")
if err != nil {
return nil, fmt.Errorf("无法在目标文件系统创建 Event Skill 迁移 staging %s: %w", dest, err)
}
migration := &eventMiscMigration{
dest: dest,
stageRoot: stageRoot,
stagedEvent: filepath.Join(stageRoot, "new-event"),
stagedMisc: filepath.Join(stageRoot, "new-misc"),
backupEvent: filepath.Join(stageRoot, "old-event"),
backupMisc: filepath.Join(stageRoot, "old-misc"),
eventPath: filepath.Join(dest, multiEventSkill),
miscPath: filepath.Join(dest, multiMiscSkill),
}
cleanupOnError := func(cause error) (*eventMiscMigration, error) {
if cleanupErr := skillSetupRemoveAll(stageRoot); cleanupErr != nil {
cause = errors.Join(cause, fmt.Errorf("清理 staging %s 失败: %w", stageRoot, cleanupErr))
}
return nil, cause
}
if err := skillSetupCopyDir(filepath.Join(src, multiEventSkill), migration.stagedEvent); err != nil {
return cleanupOnError(fmt.Errorf("预备 dingtalk-event 失败 %s: %w", dest, err))
}
if err := skillSetupCopyDir(filepath.Join(src, multiMiscSkill), migration.stagedMisc); err != nil {
return cleanupOnError(fmt.Errorf("预备 dingtalk-misc 失败 %s: %w", dest, err))
}
if err := validateEventMigrationSkillRoot(migration.stagedEvent); err != nil {
return cleanupOnError(fmt.Errorf("迁移 staging 验证失败 %s: %w", migration.stagedEvent, err))
}
if err := validateMigrationSkillRoot(migration.stagedMisc, multiMiscSkill, nil); err != nil {
return cleanupOnError(fmt.Errorf("迁移 staging 验证失败 %s: %w", migration.stagedMisc, err))
}
if err := validateCleanEventMiscRoot(migration.stagedMisc); err != nil {
return cleanupOnError(fmt.Errorf("迁移 staging 验证失败 %s: %w", migration.stagedMisc, err))
}
return migration, nil
}
func migrateEventMiscAtomically(src string, dests []string, out, errOut io.Writer) (int, error) {
sortedDests := append([]string(nil), dests...)
sort.Strings(sortedDests)
migrations := make([]*eventMiscMigration, 0, len(sortedDests))
// Stage every target before switching any target. This prevents a source or
// copy failure on a later Agent home from leaving earlier homes upgraded.
for _, dest := range sortedDests {
migration, err := prepareEventMiscMigration(src, dest)
if err != nil {
if cleanupErr := cleanupEventMiscStages(migrations, false, errOut); cleanupErr != nil {
err = errors.Join(err, cleanupErr)
}
return 0, err
}
migrations = append(migrations, migration)
}
committed := make([]*eventMiscMigration, 0, len(migrations))
for _, migration := range migrations {
if err := commitEventMiscMigration(migration); err != nil {
rollbackErr := rollbackEventMiscMigrations(committed)
if rollbackErr != nil {
err = errors.Join(err, fmt.Errorf("已切换目标回滚失败: %w", rollbackErr))
}
var recoveryRoots []string
for _, candidate := range migrations {
if eventMiscMigrationNeedsRecovery(candidate) {
recoveryRoots = append(recoveryRoots, candidate.stageRoot)
}
}
if len(recoveryRoots) > 0 {
err = errors.Join(err, fmt.Errorf("回滚不完整,已保留恢复目录(请勿删除): %s", strings.Join(recoveryRoots, ", ")))
}
if cleanupErr := cleanupEventMiscStages(migrations, true, errOut); cleanupErr != nil {
err = errors.Join(err, cleanupErr)
}
return 0, err
}
committed = append(committed, migration)
}
for _, migration := range migrations {
fmt.Fprintf(out, " ✓ %s\n", migration.eventPath)
fmt.Fprintf(out, " ✓ %s(Event 原子迁移)\n", migration.miscPath)
}
if cleanupErr := cleanupEventMiscStages(migrations, false, errOut); cleanupErr != nil {
fmt.Fprintf(errOut, " ⚠️ Event Skill 迁移已完成,但 staging 清理不完整: %v\n", cleanupErr)
}
return len(migrations) * 2, nil
}
func cleanupEventMiscStages(migrations []*eventMiscMigration, preserveRecovery bool, errOut io.Writer) error {
var cleanupErr error
for _, migration := range migrations {
if preserveRecovery && eventMiscMigrationNeedsRecovery(migration) {
fmt.Fprintf(errOut, " ⚠️ 已保留 Event Skill 恢复目录 %s\n", migration.stageRoot)
continue
}
if err := skillSetupRemoveAll(migration.stageRoot); err != nil {
cleanupErr = errors.Join(cleanupErr, fmt.Errorf("清理 Event Skill staging %s 失败: %w", migration.stageRoot, err))
}
}
return cleanupErr
}
func eventMiscMigrationNeedsRecovery(migration *eventMiscMigration) bool {
return migration.eventBackedUp || migration.miscBackedUp || migration.newEventEnabled || migration.newMiscEnabled
}
func commitEventMiscMigration(migration *eventMiscMigration) error {
eventExists, err := skillSetupPathExists(migration.eventPath)
if err != nil {
return fmt.Errorf("无法检查旧 dingtalk-event %s: %w", migration.dest, err)
}
miscExists, err := skillSetupPathExists(migration.miscPath)
if err != nil {
return fmt.Errorf("无法检查旧 dingtalk-misc %s: %w", migration.dest, err)
}
if !miscExists {
return fmt.Errorf("event Skill 迁移中止:折叠版 dingtalk-misc 已不存在 %s", migration.dest)
}
rollbackFailure := func(cause error) error {
if rollbackErr := rollbackEventMiscMigration(migration); rollbackErr != nil {
return errors.Join(cause, fmt.Errorf("回滚 Event/misc 失败 %s: %w", migration.dest, rollbackErr))
}
return cause
}
if eventExists {
if err := skillSetupRename(migration.eventPath, migration.backupEvent); err != nil {
return fmt.Errorf("备份旧 dingtalk-event 失败 %s: %w", migration.dest, err)
}
migration.eventBackedUp = true
}
if err := skillSetupRename(migration.stagedEvent, migration.eventPath); err != nil {
return rollbackFailure(fmt.Errorf("切换 dingtalk-event 失败 %s: %w", migration.dest, err))
}
migration.newEventEnabled = true
if err := skillSetupRename(migration.miscPath, migration.backupMisc); err != nil {
return rollbackFailure(fmt.Errorf("备份旧 dingtalk-misc 失败 %s: %w", migration.dest, err))
}
migration.miscBackedUp = true
if err := skillSetupRename(migration.stagedMisc, migration.miscPath); err != nil {
return rollbackFailure(fmt.Errorf("切换 dingtalk-misc 失败 %s: %w", migration.dest, err))
}
migration.newMiscEnabled = true
return nil
}
func rollbackEventMiscMigrations(migrations []*eventMiscMigration) error {
var rollbackErr error
for i := len(migrations) - 1; i >= 0; i-- {
if err := rollbackEventMiscMigration(migrations[i]); err != nil {
rollbackErr = errors.Join(rollbackErr, err)
}
}
return rollbackErr
}
func rollbackEventMiscMigration(migration *eventMiscMigration) error {
move := func(enabled *bool, from, to, label string) error {
if !*enabled {
return nil
}
if err := skillSetupRename(from, to); err != nil {
return fmt.Errorf("%s: %w", label, err)
}
*enabled = false
return nil
}
// Stop at the first rollback failure. In particular, do not remove the
// already-working standalone Event while the folded misc route has not been
// restored: even an incomplete rollback must leave at least one Event entry
// point live and preserve the remaining assets in staging for recovery.
steps := []struct {
enabled *bool
from string
to string
label string
}{
{&migration.newMiscEnabled, migration.miscPath, migration.stagedMisc, "移出新 dingtalk-misc"},
{&migration.miscBackedUp, migration.backupMisc, migration.miscPath, "恢复旧 dingtalk-misc"},
{&migration.newEventEnabled, migration.eventPath, migration.stagedEvent, "移出新 dingtalk-event"},
{&migration.eventBackedUp, migration.backupEvent, migration.eventPath, "恢复旧 dingtalk-event"},
}
for _, step := range steps {
if err := move(step.enabled, step.from, step.to, step.label); err != nil {
return err
}
}
return nil
}
func skillSetupPathExists(path string) (bool, error) {
_, err := skillSetupStat(path)
switch {
case err == nil:
return true, nil
case errors.Is(err, os.ErrNotExist):
return false, nil
default:
return false, err
}
}
// installMultiSkillToHomes installs each subdir of src (dingtalk-*) into
// dest as a sibling skill directory. installed/skipped is counted per
// (agent-home × sub-skill) pair so the user sees granular progress.
@@ -649,6 +1218,7 @@ func installMultiSkillToHomes(src string, skillNames []string, dests []string, o
continue
}
sharedInstalled := false
for _, name := range skillNames {
subSrc := filepath.Join(src, name)
subDest := filepath.Join(dest, name)
@@ -664,6 +1234,12 @@ func installMultiSkillToHomes(src string, skillNames []string, dests []string, o
}
fmt.Fprintf(out, " ✓ %s\n", subDest)
installed++
if name == multiSharedSkill {
sharedInstalled = true
}
}
if sharedInstalled {
cleanupLegacyMultiSharedSkill(dest, out, errOut)
}
}
return installed, skipped, nil
+10 -2
View File
@@ -58,8 +58,9 @@ 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
// Schema hints are build inputs and must not become a third installable mode.
// contains the standalone Event skill, shared routing skill, and clean misc
// (including PAT docs). Structured Schema hints are build inputs and must not
// become a third installable mode.
func TestMaterializeEmbeddedSkillSourceMulti(t *testing.T) {
dir, cleanup, err := materializeEmbeddedSkillSource(skillSetupModeMulti)
if err != nil {
@@ -71,6 +72,8 @@ 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-event", "SKILL.md"),
filepath.Join("dingtalk-event", "references", "event-oa.md"),
filepath.Join("dingtalk-shared", "SKILL.md"),
filepath.Join("dingtalk-misc", "SKILL.md"),
filepath.Join("dingtalk-misc", "references", "pat.md"),
@@ -79,6 +82,11 @@ func TestMaterializeEmbeddedSkillSourceMulti(t *testing.T) {
t.Errorf("expected embedded multi skill to contain %s: %v", rel, err)
}
}
if _, err := os.Stat(filepath.Join(dir, "dingtalk-misc", "references", "event.md")); err == nil {
t.Fatal("embedded misc must not retain the folded personal Event reference")
} else if !os.IsNotExist(err) {
t.Fatalf("stat embedded misc event reference: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "schema-hints")); err == nil {
t.Fatal("embedded multi skill must not contain build-only schema-hints")
} else if !os.IsNotExist(err) {
@@ -7,9 +7,11 @@ import (
"io/fs"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/charmbracelet/huh"
"github.com/spf13/cobra"
)
@@ -120,12 +122,112 @@ func TestCrossPlatformCoverageSkillSetupHighLevelRemainingCoverage(t *testing.T)
}
}
func TestCrossPlatformCoverageSkillSetupMigratesLegacySharedAfterReplacement(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiSharedSkill, "dingtalk-chat"})
home := filepath.Join(t.TempDir(), "skills")
legacyPath := filepath.Join(home, legacyMultiSharedSkill)
customPath := filepath.Join(home, "custom-skill")
for _, path := range []string{legacyPath, customPath} {
if err := os.MkdirAll(path, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(path, "SKILL.md"), []byte("legacy or custom\n"), 0o644); err != nil {
t.Fatal(err)
}
}
var out, errOut bytes.Buffer
installed, skipped, err := installMultiSkillToHomes(
src,
[]string{multiSharedSkill, "dingtalk-chat"},
[]string{home},
&out,
&errOut,
)
if err != nil || installed != 2 || skipped != 0 {
t.Fatalf("install = %d/%d, err=%v, stderr=%s", installed, skipped, err, errOut.String())
}
if _, err := os.Stat(legacyPath); !os.IsNotExist(err) {
t.Fatalf("legacy shared skill still exists: %v", err)
}
if _, err := os.Stat(filepath.Join(home, multiSharedSkill, "SKILL.md")); err != nil {
t.Fatalf("replacement shared skill missing: %v", err)
}
if _, err := os.Stat(filepath.Join(customPath, "SKILL.md")); err != nil {
t.Fatalf("unrelated custom skill changed: %v", err)
}
if !strings.Contains(out.String(), "已清理已退役 Skill 残留") {
t.Fatalf("legacy cleanup was not reported: %s", out.String())
}
t.Run("failed replacement preserves legacy", func(t *testing.T) {
missingSource := t.TempDir()
failureHome := filepath.Join(t.TempDir(), "skills")
failureLegacy := filepath.Join(failureHome, legacyMultiSharedSkill)
if err := os.MkdirAll(failureLegacy, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(failureLegacy, "SKILL.md"), []byte("legacy\n"), 0o644); err != nil {
t.Fatal(err)
}
var failureOut, failureErr bytes.Buffer
installed, skipped, err := installMultiSkillToHomes(
missingSource,
[]string{multiSharedSkill},
[]string{failureHome},
&failureOut,
&failureErr,
)
if err != nil || installed != 0 || skipped != 1 {
t.Fatalf("failed replacement = %d/%d, err=%v", installed, skipped, err)
}
if _, err := os.Stat(filepath.Join(failureLegacy, "SKILL.md")); err != nil {
t.Fatalf("failed replacement removed legacy shared skill: %v", err)
}
})
}
func TestCrossPlatformCoverageSkillSetupLegacySharedCleanupFailures(t *testing.T) {
fail := errors.New("legacy cleanup failure")
t.Run("missing legacy is a no-op", func(t *testing.T) {
var out, errOut bytes.Buffer
cleanupLegacyMultiSharedSkill(t.TempDir(), &out, &errOut)
if out.Len() != 0 || errOut.Len() != 0 {
t.Fatalf("missing legacy emitted output: stdout=%q stderr=%q", out.String(), errOut.String())
}
})
t.Run("stat failure is reported", func(t *testing.T) {
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, fail })
var out, errOut bytes.Buffer
cleanupLegacyMultiSharedSkill("dest", &out, &errOut)
if out.Len() != 0 || !strings.Contains(errOut.String(), "无法检查已退役 Skill 残留") {
t.Fatalf("stat failure output: stdout=%q stderr=%q", out.String(), errOut.String())
}
})
t.Run("remove failure is reported", func(t *testing.T) {
testseam.Swap(t, &skillSetupStat, func(path string) (os.FileInfo, error) {
return skillSetupFileInfo{name: filepath.Base(path), mode: os.ModeDir}, nil
})
testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return fail })
var out, errOut bytes.Buffer
cleanupLegacyMultiSharedSkill("dest", &out, &errOut)
if out.Len() != 0 || !strings.Contains(errOut.String(), "已退役 Skill 清理失败") {
t.Fatalf("remove failure output: stdout=%q stderr=%q", out.String(), errOut.String())
}
})
}
func TestCrossPlatformCoverageSkillSetupLowLevelRemainingCoverage(t *testing.T) {
oldRunForm, oldInteractive := skillSetupRunForm, skillSetupInteractive
oldReadDir, oldStat := skillSetupReadDir, skillSetupStat
oldExecutable, oldGetwd, oldHome := skillSetupExecutable, skillSetupGetwd, skillSetupUserHomeDir
oldRemove, oldMkdir := skillSetupRemoveAll, skillSetupMkdirAll
oldCopyDir, oldWalk, oldRel := skillSetupCopyDir, skillSetupWalk, skillSetupRel
oldMkdirTemp, oldRename := skillSetupMkdirTemp, skillSetupRename
oldReadlink, oldOpen, oldOpenFile, oldCopy := skillSetupReadlink, skillSetupOpen, skillSetupOpenFile, skillSetupCopy
t.Cleanup(func() {
skillSetupRunForm, skillSetupInteractive = oldRunForm, oldInteractive
@@ -133,6 +235,7 @@ func TestCrossPlatformCoverageSkillSetupLowLevelRemainingCoverage(t *testing.T)
skillSetupExecutable, skillSetupGetwd, skillSetupUserHomeDir = oldExecutable, oldGetwd, oldHome
skillSetupRemoveAll, skillSetupMkdirAll = oldRemove, oldMkdir
skillSetupCopyDir, skillSetupWalk, skillSetupRel = oldCopyDir, oldWalk, oldRel
skillSetupMkdirTemp, skillSetupRename = oldMkdirTemp, oldRename
skillSetupReadlink, skillSetupOpen, skillSetupOpenFile, skillSetupCopy = oldReadlink, oldOpen, oldOpenFile, oldCopy
})
fail := errors.New("failure")
@@ -310,3 +413,236 @@ func TestCrossPlatformCoverageSkillSetupLowLevelRemainingCoverage(t *testing.T)
}
_ = fs.ValidPath("path")
}
func TestCrossPlatformCoverageSkillSetupEventMigrationFailureBranches(t *testing.T) {
fail := errors.New("injected failure")
validSkill := func(name string) []byte {
return []byte("---\nname: " + name + "\ndescription: valid migration skill\n---\n\n# Skill\n")
}
t.Run("folded discovery rejects directory reference", func(t *testing.T) {
dest := t.TempDir()
miscRoot := filepath.Join(dest, multiMiscSkill)
if err := os.MkdirAll(miscRoot, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(miscRoot, "SKILL.md"), []byte("dws event\n"), 0o644); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &skillSetupStat, func(path string) (os.FileInfo, error) {
if strings.HasSuffix(path, filepath.Join("references", "event.md")) {
return skillSetupFileInfo{name: "event.md", mode: os.ModeDir}, nil
}
return os.Stat(path)
})
if got := findFoldedEventMiscTargets([]string{dest}); len(got) != 0 {
t.Fatalf("directory event reference accepted: %#v", got)
}
})
t.Run("migration root validation failures", func(t *testing.T) {
if err := validateMigrationSkillRoot(filepath.Join(t.TempDir(), "missing"), multiEventSkill, nil); err == nil {
t.Fatal("missing SKILL.md succeeded")
}
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "SKILL.md"), validSkill(multiEventSkill), 0o644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(root, "references", "directory.md"), 0o755); err != nil {
t.Fatal(err)
}
if err := validateMigrationSkillRoot(root, multiEventSkill, []string{filepath.Join("references", "directory.md")}); err == nil || !strings.Contains(err.Error(), "is a directory") {
t.Fatalf("directory required file = %v", err)
}
missing := filepath.Join("references", "missing.md")
testseam.Swap(t, &skillSetupStat, func(path string) (os.FileInfo, error) {
if path == filepath.Join(root, missing) {
return skillSetupFileInfo{name: "missing.md"}, nil
}
return os.Stat(path)
})
if err := validateMigrationSkillRoot(root, multiEventSkill, []string{missing}); err == nil || !strings.Contains(err.Error(), "无法读取") {
t.Fatalf("unreadable required file = %v", err)
}
})
t.Run("frontmatter validation branches", func(t *testing.T) {
validWithIgnoredLine := []byte("---\nignored line\nname: dingtalk-event\ndescription: valid\n---\n\nbody\n")
if name, err := parseMigrationSkillFrontmatter(validWithIgnoredLine); err != nil || name != multiEventSkill {
t.Fatalf("ignored frontmatter line = %q, %v", name, err)
}
for name, body := range map[string][]byte{
"duplicate name": []byte("---\nname: one\nname: two\ndescription: valid\n---\nbody\n"),
"unclosed": []byte("---\nname: one\ndescription: valid\nbody\n"),
"missing name": []byte("---\ndescription: valid\n---\nbody\n"),
"missing desc": []byte("---\nname: one\n---\nbody\n"),
"empty body": []byte("---\nname: one\ndescription: valid\n---\n \n"),
} {
t.Run(name, func(t *testing.T) {
if _, err := parseMigrationSkillFrontmatter(body); err == nil {
t.Fatal("invalid frontmatter succeeded")
}
})
}
})
t.Run("clean misc validation branches", func(t *testing.T) {
if err := validateCleanEventMiscRoot(filepath.Join(t.TempDir(), "missing")); err == nil {
t.Fatal("missing misc root succeeded")
}
routed := t.TempDir()
if err := os.WriteFile(filepath.Join(routed, "SKILL.md"), append(validSkill(multiMiscSkill), []byte("dws event\n")...), 0o644); err != nil {
t.Fatal(err)
}
if err := validateCleanEventMiscRoot(routed); err == nil || !strings.Contains(err.Error(), "仍包含个人 Event 路由") {
t.Fatalf("routed misc = %v", err)
}
clean := t.TempDir()
if err := os.WriteFile(filepath.Join(clean, "SKILL.md"), validSkill(multiMiscSkill), 0o644); err != nil {
t.Fatal(err)
}
if err := validateCleanEventMiscRoot(clean); err != nil {
t.Fatalf("missing references should be clean: %v", err)
}
testseam.Swap(t, &skillSetupReadDir, func(string) ([]os.DirEntry, error) { return nil, fail })
if err := validateCleanEventMiscRoot(clean); !errors.Is(err, fail) {
t.Fatalf("read-dir failure = %v", err)
}
})
t.Run("ordinary and prerequisite install errors", func(t *testing.T) {
testseam.Swap(t, &skillSetupInstallMulti, func(string, []string, []string, io.Writer, io.Writer) (int, int, error) {
return 0, 0, fail
})
migration := filepath.Join(t.TempDir(), "migration")
ordinary := filepath.Join(t.TempDir(), "ordinary")
if _, _, err := installMultiSkillsWithEventMigration("src", []string{multiEventSkill}, []string{migration, ordinary}, []string{migration}, io.Discard, io.Discard); !errors.Is(err, fail) {
t.Fatalf("ordinary install failure = %v", err)
}
if _, _, err := installMultiSkillsWithEventMigration("src", []string{multiEventSkill, multiMiscSkill, multiSharedSkill}, []string{migration}, []string{migration}, io.Discard, io.Discard); !errors.Is(err, fail) {
t.Fatalf("prerequisite install failure = %v", err)
}
})
t.Run("preparation cleanup and staged misc validation", func(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiMiscSkill})
dest := t.TempDir()
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error { return fail })
cleanupFail := errors.New("cleanup failure")
testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return cleanupFail })
if _, err := prepareEventMiscMigration(src, dest); err == nil || !errors.Is(err, fail) || !errors.Is(err, cleanupFail) {
t.Fatalf("joined preparation cleanup error = %v", err)
}
})
t.Run("invalid staged misc root", func(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiMiscSkill})
if err := os.WriteFile(filepath.Join(src, multiMiscSkill, "SKILL.md"), validSkill(multiEventSkill), 0o644); err != nil {
t.Fatal(err)
}
if _, err := prepareEventMiscMigration(src, t.TempDir()); err == nil || !strings.Contains(err.Error(), "staging 验证失败") {
t.Fatalf("invalid staged misc = %v", err)
}
})
t.Run("later staging failure joins cleanup error", func(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiMiscSkill})
first := filepath.Join(t.TempDir(), "a")
second := filepath.Join(t.TempDir(), "b")
if err := os.MkdirAll(first, 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(second, 0o755); err != nil {
t.Fatal(err)
}
originalMkdirTemp := skillSetupMkdirTemp
calls := 0
testseam.Swap(t, &skillSetupMkdirTemp, func(dir, pattern string) (string, error) {
calls++
if calls == 2 {
return "", fail
}
return originalMkdirTemp(dir, pattern)
})
cleanupFail := errors.New("stage cleanup failure")
testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return cleanupFail })
if _, err := migrateEventMiscAtomically(src, []string{second, first}, io.Discard, io.Discard); err == nil || !errors.Is(err, fail) || !errors.Is(err, cleanupFail) {
t.Fatalf("later preparation failure = %v", err)
}
})
t.Run("successful migration reports cleanup warning", func(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiMiscSkill})
home := filepath.Join(t.TempDir(), "skills")
writeFoldedEventMisc(t, home)
cleanupFail := errors.New("final cleanup failure")
testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return cleanupFail })
var stderr bytes.Buffer
installed, err := migrateEventMiscAtomically(src, []string{home}, io.Discard, &stderr)
if err != nil || installed != 2 || !strings.Contains(stderr.String(), cleanupFail.Error()) {
t.Fatalf("successful migration cleanup warning: installed=%d err=%v stderr=%s", installed, err, stderr.String())
}
})
t.Run("commit rollback and cleanup failures are joined", func(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiMiscSkill})
root := t.TempDir()
first := filepath.Join(root, "a", "skills")
second := filepath.Join(root, "b", "skills")
for _, home := range []string{first, second} {
writeFoldedEventMisc(t, home)
writeOldStandaloneEvent(t, home)
}
commitFail := errors.New("second commit failure")
rollbackFail := errors.New("first rollback failure")
originalRename := skillSetupRename
testseam.Swap(t, &skillSetupRename, func(oldPath, newPath string) error {
if filepath.Base(oldPath) == "new-misc" && newPath == filepath.Join(second, multiMiscSkill) {
return commitFail
}
if filepath.Base(oldPath) == "old-misc" && newPath == filepath.Join(first, multiMiscSkill) {
return rollbackFail
}
return originalRename(oldPath, newPath)
})
cleanupFail := errors.New("post-rollback cleanup failure")
testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return cleanupFail })
if _, err := migrateEventMiscAtomically(src, []string{second, first}, io.Discard, io.Discard); err == nil || !errors.Is(err, commitFail) || !errors.Is(err, rollbackFail) || !errors.Is(err, cleanupFail) {
t.Fatalf("joined commit/rollback/cleanup error = %v", err)
}
})
t.Run("commit preflight and rollback aggregation", func(t *testing.T) {
migration := &eventMiscMigration{dest: "dest", eventPath: "event", miscPath: "misc"}
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, fail })
if err := commitEventMiscMigration(migration); !errors.Is(err, fail) {
t.Fatalf("event stat failure = %v", err)
}
testseam.Swap(t, &skillSetupStat, func(path string) (os.FileInfo, error) {
if path == migration.eventPath {
return skillSetupFileInfo{name: "event", mode: os.ModeDir}, nil
}
return nil, fail
})
if err := commitEventMiscMigration(migration); !errors.Is(err, fail) {
t.Fatalf("misc stat failure = %v", err)
}
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
if err := commitEventMiscMigration(migration); err == nil || !strings.Contains(err.Error(), "已不存在") {
t.Fatalf("missing misc = %v", err)
}
migration.newMiscEnabled = true
testseam.Swap(t, &skillSetupRename, func(string, string) error { return fail })
if err := rollbackEventMiscMigrations([]*eventMiscMigration{migration}); !errors.Is(err, fail) {
t.Fatalf("rollback aggregation = %v", err)
}
})
}
+796 -1
View File
@@ -2,6 +2,9 @@ package app
import (
"bytes"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
@@ -184,12 +187,20 @@ func writeMultiSkillSource(t *testing.T, names []string) string {
if err := os.MkdirAll(filepath.Join(sub, "references"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sub, "SKILL.md"), []byte("# "+n), 0o644); err != nil {
skillBody := "---\nname: " + n + "\ndescription: test skill\n---\n\n# " + n + "\n"
if err := os.WriteFile(filepath.Join(sub, "SKILL.md"), []byte(skillBody), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sub, "references", n+".md"), []byte("ref "+n), 0o644); err != nil {
t.Fatal(err)
}
if n == multiEventSkill {
for _, ref := range eventMigrationRequiredReferences {
if err := os.WriteFile(filepath.Join(sub, "references", ref), []byte("ref "+ref+"\n"), 0o644); err != nil {
t.Fatal(err)
}
}
}
}
return root
}
@@ -549,3 +560,787 @@ func TestResolveSkillSetupSourceMultiFinds(t *testing.T) {
t.Fatalf("expected %s, got %s", multiDir, got)
}
}
func executeMultiSkillSetupTest(t *testing.T, src string, dests []string, args ...string) (string, string, error) {
t.Helper()
originalTargets := skillSetupResolveTargets
skillSetupResolveTargets = func(string, string) ([]string, error) {
return append([]string(nil), dests...), nil
}
t.Cleanup(func() { skillSetupResolveTargets = originalTargets })
cmd := newSkillSetupCommand()
cmd.Flags().Bool("dry-run", false, "")
var stdout, stderr bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetErr(&stderr)
baseArgs := []string{"--mode", "multi", "--source", src}
cmd.SetArgs(append(baseArgs, args...))
err := cmd.Execute()
return stdout.String(), stderr.String(), err
}
func writeFoldedEventMisc(t *testing.T, agentHome string) {
t.Helper()
miscRoot := filepath.Join(agentHome, multiMiscSkill)
if err := os.MkdirAll(filepath.Join(miscRoot, "references"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(miscRoot, "SKILL.md"), []byte("personal IM route: dws event consume\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(miscRoot, "references", "event.md"), []byte("folded event docs\n"), 0o644); err != nil {
t.Fatal(err)
}
}
func writeOldStandaloneEvent(t *testing.T, agentHome string) {
t.Helper()
eventRoot := filepath.Join(agentHome, multiEventSkill)
if err := os.MkdirAll(eventRoot, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(eventRoot, "SKILL.md"), []byte("old standalone event\n"), 0o644); err != nil {
t.Fatal(err)
}
}
func assertOldEventMiscPair(t *testing.T, agentHome string) {
t.Helper()
eventBody, err := os.ReadFile(filepath.Join(agentHome, multiEventSkill, "SKILL.md"))
if err != nil || string(eventBody) != "old standalone event\n" {
t.Fatalf("old standalone event was not restored: body=%q err=%v", eventBody, err)
}
miscBody, err := os.ReadFile(filepath.Join(agentHome, multiMiscSkill, "SKILL.md"))
if err != nil || !strings.Contains(string(miscBody), "dws event") {
t.Fatalf("folded misc was not restored: body=%q err=%v", miscBody, err)
}
if _, err := os.Stat(filepath.Join(agentHome, multiMiscSkill, "references", "event.md")); err != nil {
t.Fatalf("folded Event reference was not restored: %v", err)
}
}
func assertNoEventMigrationStages(t *testing.T, agentHome string) {
t.Helper()
entries, err := os.ReadDir(agentHome)
if err != nil {
t.Fatal(err)
}
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), ".dws-event-migration-") {
t.Fatalf("unexpected leftover Event migration stage %s", filepath.Join(agentHome, entry.Name()))
}
}
}
func TestSkillSetupSelectiveEventMigratesOnlyFoldedTargets(t *testing.T) {
src := writeMultiSkillSource(t, []string{
multiEventSkill, multiSharedSkill, multiMiscSkill, "dingtalk-doc",
})
foldedHome := filepath.Join(t.TempDir(), "folded", "skills")
freshHome := filepath.Join(t.TempDir(), "fresh", "skills")
writeFoldedEventMisc(t, foldedHome)
if err := os.MkdirAll(filepath.Join(foldedHome, multiEventSkill), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(foldedHome, multiEventSkill, "SKILL.md"), []byte("old standalone event\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(foldedHome, "dingtalk-chat"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(foldedHome, "dingtalk-chat", "SKILL.md"), []byte("keep sibling\n"), 0o644); err != nil {
t.Fatal(err)
}
stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{freshHome, foldedHome}, "--skill", "event")
if err != nil {
t.Fatalf("selective event setup failed: %v\nstderr=%s\nstdout=%s", err, stderr, stdout)
}
if !strings.Contains(stdout, "迁移伴侣") || !strings.Contains(stdout, foldedHome) {
t.Fatalf("confirmation output should expose folded misc migration: %s", stdout)
}
if !strings.Contains(stdout, "重新加载 Skills") {
t.Fatalf("completion should tell the user to reload skills: %s", stdout)
}
for _, home := range []string{freshHome, foldedHome} {
for _, name := range []string{multiSharedSkill, multiEventSkill} {
if _, err := os.Stat(filepath.Join(home, name, "SKILL.md")); err != nil {
t.Errorf("%s missing from %s: %v", name, home, err)
}
}
if _, err := os.Stat(filepath.Join(home, "dingtalk-doc")); !os.IsNotExist(err) {
t.Errorf("unselected doc appeared in %s: %v", home, err)
}
}
if _, err := os.Stat(filepath.Join(freshHome, multiMiscSkill)); !os.IsNotExist(err) {
t.Fatalf("fresh selective target must not receive misc, stat err=%v", err)
}
if _, err := os.Stat(filepath.Join(foldedHome, multiMiscSkill, "references", "event.md")); !os.IsNotExist(err) {
t.Fatalf("folded event reference survived clean misc replacement, stat err=%v", err)
}
eventBody, err := os.ReadFile(filepath.Join(foldedHome, multiEventSkill, "SKILL.md"))
if err != nil || strings.Contains(string(eventBody), "old standalone") {
t.Fatalf("old standalone event was not replaced: body=%q err=%v", eventBody, err)
}
siblingBody, err := os.ReadFile(filepath.Join(foldedHome, "dingtalk-chat", "SKILL.md"))
if err != nil || string(siblingBody) != "keep sibling\n" {
t.Fatalf("unrelated sibling changed: body=%q err=%v", siblingBody, err)
}
// A second selective run sees the already-clean misc, does not plan another
// migration, and leaves that unselected sibling in place.
stdout, stderr, err = executeMultiSkillSetupTest(t, src, []string{freshHome, foldedHome}, "--skill", "event", "--yes")
if err != nil {
t.Fatalf("idempotent event setup failed: %v\nstderr=%s", err, stderr)
}
if strings.Contains(stdout, "迁移伴侣") {
t.Fatalf("clean second run should not re-detect folded misc: %s", stdout)
}
if _, err := os.Stat(filepath.Join(foldedHome, multiMiscSkill, "SKILL.md")); err != nil {
t.Fatalf("second selective run removed clean misc: %v", err)
}
}
func TestSkillSetupEventMigrationDryRunAndExplicitExclude(t *testing.T) {
src := writeMultiSkillSource(t, []string{
multiEventSkill, multiSharedSkill, multiMiscSkill, "dingtalk-doc",
})
t.Run("dry run reports companion without writes", func(t *testing.T) {
home := filepath.Join(t.TempDir(), "skills")
writeFoldedEventMisc(t, home)
stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--dry-run", "--yes")
if err != nil {
t.Fatalf("dry run failed: %v\nstderr=%s", err, stderr)
}
if !strings.Contains(stdout, "DRY-RUN") || !strings.Contains(stdout, "迁移伴侣") {
t.Fatalf("dry run did not expose migration: %s", stdout)
}
body, readErr := os.ReadFile(filepath.Join(home, multiMiscSkill, "SKILL.md"))
if readErr != nil || !strings.Contains(string(body), "dws event") {
t.Fatalf("dry run changed folded misc: body=%q err=%v", body, readErr)
}
if _, statErr := os.Stat(filepath.Join(home, multiEventSkill)); !os.IsNotExist(statErr) {
t.Fatalf("dry run installed event, stat err=%v", statErr)
}
})
t.Run("excluding required misc fails before writes", func(t *testing.T) {
home := filepath.Join(t.TempDir(), "skills")
writeFoldedEventMisc(t, home)
_, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--exclude", "misc", "--yes")
if err == nil || !strings.Contains(err.Error(), "不能显式 --exclude misc") {
t.Fatalf("expected clear migration exclusion error, got %v", err)
}
if _, statErr := os.Stat(filepath.Join(home, multiEventSkill)); !os.IsNotExist(statErr) {
t.Fatalf("failed migration installed event, stat err=%v", statErr)
}
body, readErr := os.ReadFile(filepath.Join(home, multiMiscSkill, "SKILL.md"))
if readErr != nil || !strings.Contains(string(body), "dws event") {
t.Fatalf("failed migration changed misc: body=%q err=%v", body, readErr)
}
})
}
func TestSkillSetupEventMigrationRequiresCleanMiscInSource(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill})
home := filepath.Join(t.TempDir(), "skills")
writeFoldedEventMisc(t, home)
_, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
if err == nil || !strings.Contains(err.Error(), "当前 multi 源缺少") {
t.Fatalf("expected missing migration companion error, got %v", err)
}
if _, statErr := os.Stat(filepath.Join(home, multiEventSkill)); !os.IsNotExist(statErr) {
t.Fatalf("failed preflight installed event, stat err=%v", statErr)
}
}
func TestSkillSetupEventMigrationAcceptsShippedMultiBundle(t *testing.T) {
wd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
src := filepath.Clean(filepath.Join(wd, "..", "..", "skills", "multi"))
if err := validateEventMiscMigrationSource(src); err != nil {
t.Fatalf("shipped multi bundle is not a valid Event migration source: %v", err)
}
}
func TestSkillSetupEventMigrationRejectsInvalidSkillBundlesBeforeWrites(t *testing.T) {
tests := []struct {
name string
mutate func(t *testing.T, src string)
}{
{
name: "empty event root",
mutate: func(t *testing.T, src string) {
t.Helper()
if err := os.WriteFile(filepath.Join(src, multiEventSkill, "SKILL.md"), nil, 0o644); err != nil {
t.Fatal(err)
}
},
},
{
name: "wrong event name",
mutate: func(t *testing.T, src string) {
t.Helper()
body := "---\nname: dingtalk-chat\ndescription: wrong skill\n---\n\n# Wrong\n"
if err := os.WriteFile(filepath.Join(src, multiEventSkill, "SKILL.md"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
},
},
{
name: "missing event reference",
mutate: func(t *testing.T, src string) {
t.Helper()
if err := os.Remove(filepath.Join(src, multiEventSkill, "references", "event-oa.md")); err != nil {
t.Fatal(err)
}
},
},
{
name: "empty event reference",
mutate: func(t *testing.T, src string) {
t.Helper()
if err := os.WriteFile(filepath.Join(src, multiEventSkill, "references", "event-im.md"), nil, 0o644); err != nil {
t.Fatal(err)
}
},
},
{
name: "wrong misc name",
mutate: func(t *testing.T, src string) {
t.Helper()
body := "---\nname: dingtalk-event\ndescription: wrong skill\n---\n\n# Wrong\n"
if err := os.WriteFile(filepath.Join(src, multiMiscSkill, "SKILL.md"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
test.mutate(t, src)
home := filepath.Join(t.TempDir(), "skills")
writeFoldedEventMisc(t, home)
writeOldStandaloneEvent(t, home)
stdout, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
if err == nil || !strings.Contains(err.Error(), "迁移源无效") {
t.Fatalf("invalid migration source was accepted: %v", err)
}
if strings.Contains(stdout, "Skill 安装完成") {
t.Fatalf("invalid migration source reported success: %s", stdout)
}
assertOldEventMiscPair(t, home)
if _, statErr := os.Stat(filepath.Join(home, multiSharedSkill)); !os.IsNotExist(statErr) {
t.Fatalf("invalid source wrote shared skill: %v", statErr)
}
})
}
}
func TestSkillSetupSelectiveEventPreservesFoldedMiscAfterPrimarySkip(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
home := filepath.Join(t.TempDir(), "skills")
writeFoldedEventMisc(t, home)
originalInstallMulti := skillSetupInstallMulti
t.Cleanup(func() { skillSetupInstallMulti = originalInstallMulti })
calls := 0
skillSetupInstallMulti = func(string, []string, []string, io.Writer, io.Writer) (int, int, error) {
calls++
if calls > 1 {
t.Fatal("misc migration companion ran after a primary install skip")
}
return 1, 1, nil
}
stdout, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
if err == nil || !strings.Contains(err.Error(), "已保留折叠版 Event/misc") {
t.Fatalf("expected preserved-fallback error, got %v", err)
}
if calls != 1 {
t.Fatalf("install calls = %d, want 1", calls)
}
if strings.Contains(stdout, "Skill 安装完成") {
t.Fatalf("partial migration reported success: %s", stdout)
}
body, readErr := os.ReadFile(filepath.Join(home, multiMiscSkill, "SKILL.md"))
if readErr != nil || !strings.Contains(string(body), "dws event") {
t.Fatalf("primary skip changed folded misc: body=%q err=%v", body, readErr)
}
if _, statErr := os.Stat(filepath.Join(home, multiMiscSkill, "references", "event.md")); statErr != nil {
t.Fatalf("primary skip removed folded Event reference: %v", statErr)
}
}
func TestSkillSetupFreshTargetFailureDoesNotTouchFoldedPair(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
freshHome := filepath.Join(t.TempDir(), "fresh", "skills")
foldedHome := filepath.Join(t.TempDir(), "folded", "skills")
writeFoldedEventMisc(t, foldedHome)
writeOldStandaloneEvent(t, foldedHome)
originalInstallMulti := skillSetupInstallMulti
t.Cleanup(func() { skillSetupInstallMulti = originalInstallMulti })
calls := 0
skillSetupInstallMulti = func(string, []string, []string, io.Writer, io.Writer) (int, int, error) {
calls++
if calls > 1 {
t.Fatal("folded target prerequisites ran after fresh target failure")
}
return 1, 1, nil
}
stdout, _, err := executeMultiSkillSetupTest(
t,
src,
[]string{foldedHome, freshHome},
"--skill", "event",
"--yes",
)
if err == nil || !strings.Contains(err.Error(), "已保留折叠版 Event/misc") {
t.Fatalf("fresh target failure did not block migration: %v", err)
}
if strings.Contains(stdout, "Skill 安装完成") {
t.Fatalf("partial mixed-target install reported success: %s", stdout)
}
assertOldEventMiscPair(t, foldedHome)
assertNoEventMigrationStages(t, foldedHome)
}
func TestSkillSetupUnrelatedSelectiveInstallLeavesFoldedPairUntouched(t *testing.T) {
src := writeMultiSkillSource(t, []string{
multiEventSkill, multiSharedSkill, multiMiscSkill, "dingtalk-doc",
})
home := filepath.Join(t.TempDir(), "skills")
writeFoldedEventMisc(t, home)
writeOldStandaloneEvent(t, home)
stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "doc", "--yes")
if err != nil {
t.Fatalf("unrelated selective install failed: %v\nstdout=%s\nstderr=%s", err, stdout, stderr)
}
if strings.Contains(stdout, "Event Skill 迁移") || strings.Contains(stdout, "Event 原子迁移") {
t.Fatalf("unrelated selective install planned Event migration: %s", stdout)
}
if _, err := os.Stat(filepath.Join(home, "dingtalk-doc", "SKILL.md")); err != nil {
t.Fatalf("selected doc was not installed: %v", err)
}
assertOldEventMiscPair(t, home)
assertNoEventMigrationStages(t, home)
}
func TestSkillSetupSelectiveEventAtomicStageFailurePreservesFoldedPair(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
home := filepath.Join(t.TempDir(), "skills")
writeFoldedEventMisc(t, home)
originalCopyDir := skillSetupCopyDir
t.Cleanup(func() { skillSetupCopyDir = originalCopyDir })
skillSetupCopyDir = func(src, dest string) error {
if strings.HasSuffix(dest, "new-misc") {
return errors.New("injected stage failure")
}
return originalCopyDir(src, dest)
}
stdout, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
if err == nil || !strings.Contains(err.Error(), "预备 dingtalk-misc 失败") {
t.Fatalf("expected atomic staging error, got %v", err)
}
if strings.Contains(stdout, "Skill 安装完成") {
t.Fatalf("partial atomic migration reported success: %s", stdout)
}
body, readErr := os.ReadFile(filepath.Join(home, multiMiscSkill, "SKILL.md"))
if readErr != nil || !strings.Contains(string(body), "dws event") {
t.Fatalf("stage failure changed folded misc: body=%q err=%v", body, readErr)
}
if _, statErr := os.Stat(filepath.Join(home, multiEventSkill)); !os.IsNotExist(statErr) {
t.Fatalf("stage failure installed standalone event, stat err=%v", statErr)
}
}
func TestSkillSetupEventMigrationPreparationFailuresPreserveFoldedPair(t *testing.T) {
t.Run("same-filesystem staging creation", func(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
home := filepath.Join(t.TempDir(), "skills")
writeFoldedEventMisc(t, home)
writeOldStandaloneEvent(t, home)
originalMkdirTemp := skillSetupMkdirTemp
t.Cleanup(func() { skillSetupMkdirTemp = originalMkdirTemp })
skillSetupMkdirTemp = func(dir, pattern string) (string, error) {
if dir != home {
t.Fatalf("staging dir = %s, want target filesystem root %s", dir, home)
}
return "", errors.New("injected mkdir-temp failure")
}
_, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
if err == nil || !strings.Contains(err.Error(), "injected mkdir-temp failure") {
t.Fatalf("staging creation failure was not returned: %v", err)
}
assertOldEventMiscPair(t, home)
assertNoEventMigrationStages(t, home)
})
t.Run("event staging copy", func(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
home := filepath.Join(t.TempDir(), "skills")
writeFoldedEventMisc(t, home)
writeOldStandaloneEvent(t, home)
originalCopyDir := skillSetupCopyDir
t.Cleanup(func() { skillSetupCopyDir = originalCopyDir })
skillSetupCopyDir = func(src, dest string) error {
if strings.HasSuffix(dest, "new-event") {
return errors.New("injected event copy failure")
}
return originalCopyDir(src, dest)
}
_, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
if err == nil || !strings.Contains(err.Error(), "injected event copy failure") {
t.Fatalf("event staging failure was not returned: %v", err)
}
assertOldEventMiscPair(t, home)
assertNoEventMigrationStages(t, home)
})
}
func TestSkillSetupSelectiveEventRejectsCorruptStagedMisc(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
home := filepath.Join(t.TempDir(), "skills")
writeFoldedEventMisc(t, home)
originalCopyDir := skillSetupCopyDir
t.Cleanup(func() { skillSetupCopyDir = originalCopyDir })
skillSetupCopyDir = func(src, dest string) error {
if err := originalCopyDir(src, dest); err != nil {
return err
}
if strings.HasSuffix(dest, "new-misc") {
return os.WriteFile(filepath.Join(dest, "references", "event-partial.md"), []byte("corrupt\n"), 0o644)
}
return nil
}
_, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
if err == nil || !strings.Contains(err.Error(), "staging 验证失败") {
t.Fatalf("corrupt staged misc was accepted: %v", err)
}
miscBody, readErr := os.ReadFile(filepath.Join(home, multiMiscSkill, "SKILL.md"))
if readErr != nil || !strings.Contains(string(miscBody), "dws event") {
t.Fatalf("staging validation failure changed folded misc: body=%q err=%v", miscBody, readErr)
}
if _, err := os.Stat(filepath.Join(home, multiEventSkill)); !os.IsNotExist(err) {
t.Fatalf("staging validation failure installed event: %v", err)
}
assertNoEventMigrationStages(t, home)
}
func TestSkillSetupSelectiveEventRejectsIncompleteStagedEvent(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
home := filepath.Join(t.TempDir(), "skills")
writeFoldedEventMisc(t, home)
writeOldStandaloneEvent(t, home)
originalCopyDir := skillSetupCopyDir
t.Cleanup(func() { skillSetupCopyDir = originalCopyDir })
skillSetupCopyDir = func(src, dest string) error {
if err := originalCopyDir(src, dest); err != nil {
return err
}
if strings.HasSuffix(dest, "new-event") {
return os.Remove(filepath.Join(dest, "references", "event-oa.md"))
}
return nil
}
_, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
if err == nil || !strings.Contains(err.Error(), "staging 验证失败") {
t.Fatalf("incomplete staged Event was accepted: %v", err)
}
assertOldEventMiscPair(t, home)
assertNoEventMigrationStages(t, home)
}
func TestSkillSetupFullEventMigrationIsAtomicAndPreservesSiblings(t *testing.T) {
src := writeMultiSkillSource(t, []string{
multiEventSkill, multiSharedSkill, multiMiscSkill, "dingtalk-doc",
})
home := filepath.Join(t.TempDir(), "skills")
writeFoldedEventMisc(t, home)
writeOldStandaloneEvent(t, home)
sibling := filepath.Join(home, "dingtalk-private-sibling")
if err := os.MkdirAll(sibling, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sibling, "SKILL.md"), []byte("keep\n"), 0o644); err != nil {
t.Fatal(err)
}
stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{home}, "--yes")
if err != nil {
t.Fatalf("full setup migration failed: %v\nstdout=%s\nstderr=%s", err, stdout, stderr)
}
if !strings.Contains(stdout, "Event 原子迁移") || !strings.Contains(stdout, "Skill 安装完成") {
t.Fatalf("full setup did not report atomic migration success: %s", stdout)
}
for _, name := range []string{multiEventSkill, multiMiscSkill, multiSharedSkill, "dingtalk-doc"} {
if _, err := os.Stat(filepath.Join(home, name, "SKILL.md")); err != nil {
t.Fatalf("full setup missing %s: %v", name, err)
}
}
if _, err := os.Stat(filepath.Join(home, multiMiscSkill, "references", "event.md")); !os.IsNotExist(err) {
t.Fatalf("full setup retained folded Event reference: %v", err)
}
body, err := os.ReadFile(filepath.Join(sibling, "SKILL.md"))
if err != nil || string(body) != "keep\n" {
t.Fatalf("full setup changed unrelated sibling: body=%q err=%v", body, err)
}
assertNoEventMigrationStages(t, home)
}
func TestSkillSetupEventMigrationWithoutSharedStillCleansMonoLeftover(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiMiscSkill})
home := filepath.Join(t.TempDir(), "skills")
writeFoldedEventMisc(t, home)
monoLeftover := filepath.Join(home, "dws")
if err := os.MkdirAll(monoLeftover, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(monoLeftover, "SKILL.md"), []byte("old mono\n"), 0o644); err != nil {
t.Fatal(err)
}
stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
if err != nil {
t.Fatalf("migration without shared failed: %v\nstdout=%s\nstderr=%s", err, stdout, stderr)
}
if _, err := os.Stat(monoLeftover); !os.IsNotExist(err) {
t.Fatalf("migration without prerequisites retained mono leftover: %v", err)
}
for _, name := range []string{multiEventSkill, multiMiscSkill} {
if _, err := os.Stat(filepath.Join(home, name, "SKILL.md")); err != nil {
t.Fatalf("migration without shared missing %s: %v", name, err)
}
}
assertNoEventMigrationStages(t, home)
}
func TestSkillSetupFoldedEventMigrationSelectionPreflight(t *testing.T) {
src := writeMultiSkillSource(t, []string{
multiEventSkill, multiSharedSkill, multiMiscSkill, "dingtalk-doc",
})
tests := []struct {
name string
args []string
want string
}{
{name: "misc only", args: []string{"--skill", "misc", "--yes"}, want: "不能只覆盖 dingtalk-misc"},
{name: "explicitly excludes event", args: []string{"--exclude", "event", "--yes"}, want: "不能显式 --exclude event"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
home := filepath.Join(t.TempDir(), "skills")
writeFoldedEventMisc(t, home)
_, _, err := executeMultiSkillSetupTest(t, src, []string{home}, tt.args...)
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Fatalf("preflight error = %v, want %q", err, tt.want)
}
if _, err := os.Stat(filepath.Join(home, multiSharedSkill)); !os.IsNotExist(err) {
t.Fatalf("preflight failure wrote shared skill: %v", err)
}
if _, err := os.Stat(filepath.Join(home, multiEventSkill)); !os.IsNotExist(err) {
t.Fatalf("preflight failure wrote event skill: %v", err)
}
miscBody, readErr := os.ReadFile(filepath.Join(home, multiMiscSkill, "SKILL.md"))
if readErr != nil || !strings.Contains(string(miscBody), "dws event") {
t.Fatalf("preflight failure changed folded misc: body=%q err=%v", miscBody, readErr)
}
})
}
}
func TestSkillSetupEventMigrationRejectsEveryFoldedReferenceVariant(t *testing.T) {
for _, filename := range []string{"event.md", "event-im.md", "event-oa.md", "EVENT-legacy.MD"} {
t.Run(filename, func(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
if err := os.WriteFile(filepath.Join(src, multiMiscSkill, "references", filename), []byte("stale\n"), 0o644); err != nil {
t.Fatal(err)
}
home := filepath.Join(t.TempDir(), "skills")
writeFoldedEventMisc(t, home)
_, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
if err == nil || !strings.Contains(err.Error(), "仍存在折叠 Event 参考页") {
t.Fatalf("source with %s was accepted: %v", filename, err)
}
if _, err := os.Stat(filepath.Join(home, multiSharedSkill)); !os.IsNotExist(err) {
t.Fatalf("invalid source wrote shared skill: %v", err)
}
})
}
}
func TestSkillSetupEventMigrationRenameFailuresRollbackPair(t *testing.T) {
for failAt := 1; failAt <= 4; failAt++ {
t.Run(fmt.Sprintf("rename_%d", failAt), func(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
home := filepath.Join(t.TempDir(), "skills")
writeFoldedEventMisc(t, home)
writeOldStandaloneEvent(t, home)
originalRename := skillSetupRename
t.Cleanup(func() { skillSetupRename = originalRename })
renameCalls := 0
skillSetupRename = func(oldPath, newPath string) error {
renameCalls++
if renameCalls == failAt {
return errors.New("injected rename failure")
}
return originalRename(oldPath, newPath)
}
stdout, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
if err == nil || !strings.Contains(err.Error(), "injected rename failure") {
t.Fatalf("rename failure %d was not returned: %v", failAt, err)
}
if strings.Contains(stdout, "Skill 安装完成") {
t.Fatalf("rename failure %d reported success: %s", failAt, stdout)
}
assertOldEventMiscPair(t, home)
assertNoEventMigrationStages(t, home)
})
}
}
func TestSkillSetupEventMigrationFailureRollsBackEarlierTargets(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
root := t.TempDir()
firstHome := filepath.Join(root, "a", "skills")
secondHome := filepath.Join(root, "b", "skills")
for _, home := range []string{firstHome, secondHome} {
writeFoldedEventMisc(t, home)
writeOldStandaloneEvent(t, home)
}
originalRename := skillSetupRename
t.Cleanup(func() { skillSetupRename = originalRename })
failed := false
skillSetupRename = func(oldPath, newPath string) error {
if !failed && oldPath == filepath.Join(secondHome, multiMiscSkill) {
failed = true
return errors.New("second target failure")
}
return originalRename(oldPath, newPath)
}
_, _, err := executeMultiSkillSetupTest(t, src, []string{secondHome, firstHome}, "--skill", "event", "--yes")
if err == nil || !strings.Contains(err.Error(), "second target failure") {
t.Fatalf("second target failure was not returned: %v", err)
}
for _, home := range []string{firstHome, secondHome} {
assertOldEventMiscPair(t, home)
assertNoEventMigrationStages(t, home)
}
}
func TestSkillSetupEventMigrationRollbackFailurePreservesRecoveryDirectory(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
home := filepath.Join(t.TempDir(), "skills")
writeFoldedEventMisc(t, home)
writeOldStandaloneEvent(t, home)
originalRename := skillSetupRename
t.Cleanup(func() { skillSetupRename = originalRename })
skillSetupRename = func(oldPath, newPath string) error {
if strings.HasSuffix(oldPath, filepath.Join("new-misc")) && newPath == filepath.Join(home, multiMiscSkill) {
return errors.New("commit failure")
}
if strings.HasSuffix(oldPath, filepath.Join("old-misc")) && newPath == filepath.Join(home, multiMiscSkill) {
return errors.New("rollback restore failure")
}
return originalRename(oldPath, newPath)
}
_, stderr, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
if err == nil || !strings.Contains(err.Error(), "回滚不完整") || !strings.Contains(err.Error(), "恢复目录") {
t.Fatalf("rollback failure did not expose recovery directory: %v", err)
}
entries, readErr := os.ReadDir(home)
if readErr != nil {
t.Fatal(readErr)
}
var recoveryRoot string
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), ".dws-event-migration-") {
recoveryRoot = filepath.Join(home, entry.Name())
break
}
}
if recoveryRoot == "" {
t.Fatal("rollback failure deleted the only recovery directory")
}
if !strings.Contains(err.Error(), recoveryRoot) || !strings.Contains(stderr, recoveryRoot) {
t.Fatalf("recovery directory was not reported: err=%v stderr=%s", err, stderr)
}
if _, err := os.Stat(filepath.Join(recoveryRoot, "old-misc", "SKILL.md")); err != nil {
t.Fatalf("old folded misc backup is missing from recovery directory: %v", err)
}
if _, err := os.Stat(filepath.Join(recoveryRoot, "old-event", "SKILL.md")); err != nil {
t.Fatalf("old standalone Event backup is missing from recovery directory: %v", err)
}
if _, err := os.Stat(filepath.Join(home, multiEventSkill, "SKILL.md")); err != nil {
t.Fatalf("rollback failure removed the live standalone Event entry: %v", err)
}
}
func TestSkillSetupEventMigrationRollbackFailureKeepsNewEventWithoutOldStandalone(t *testing.T) {
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
home := filepath.Join(t.TempDir(), "skills")
writeFoldedEventMisc(t, home)
originalRename := skillSetupRename
t.Cleanup(func() { skillSetupRename = originalRename })
skillSetupRename = func(oldPath, newPath string) error {
if strings.HasSuffix(oldPath, filepath.Join("new-misc")) && newPath == filepath.Join(home, multiMiscSkill) {
return errors.New("commit failure")
}
if strings.HasSuffix(oldPath, filepath.Join("old-misc")) && newPath == filepath.Join(home, multiMiscSkill) {
return errors.New("rollback restore failure")
}
return originalRename(oldPath, newPath)
}
_, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
if err == nil || !strings.Contains(err.Error(), "回滚不完整") {
t.Fatalf("rollback failure was not returned: %v", err)
}
if _, statErr := os.Stat(filepath.Join(home, multiEventSkill, "SKILL.md")); statErr != nil {
t.Fatalf("rollback failure removed the only live Event entry: %v", statErr)
}
entries, readErr := os.ReadDir(home)
if readErr != nil {
t.Fatal(readErr)
}
for _, entry := range entries {
if !strings.HasPrefix(entry.Name(), ".dws-event-migration-") {
continue
}
if _, statErr := os.Stat(filepath.Join(home, entry.Name(), "old-misc", "SKILL.md")); statErr != nil {
t.Fatalf("rollback failure lost the folded misc recovery copy: %v", statErr)
}
return
}
t.Fatal("rollback failure did not preserve a recovery directory")
}
+8 -19
View File
@@ -2003,7 +2003,6 @@ var generatedParamAliases = []ParamAliasEntry{
"next-page-token": "cursor",
"next-token": "cursor",
"node-id": "node",
"page-size": "limit",
"page-token": "cursor",
"per-page": "limit",
"size": "limit",
@@ -2459,25 +2458,15 @@ var generatedParamAliases = []ParamAliasEntry{
{
CLIPath: "doc +version-list",
Aliases: map[string]string{
"doc": "node",
"doc-id": "node",
"document-id": "node",
"file-id": "node",
"max-result": "limit",
"max-results": "limit",
"next-cursor": "cursor",
"next-page-token": "cursor",
"next-token": "cursor",
"node-id": "node",
"page-size": "limit",
"page-token": "cursor",
"per-page": "limit",
"size": "limit",
"take": "limit",
"top": "limit",
"url": "node",
"doc": "node",
"doc-id": "node",
"document-id": "node",
"file-id": "node",
"node-id": "node",
"url": "node",
},
Blocked: []string{"block-id", "comment-id", "comment-key", "count", "folder", "folder-id", "id", "job-id", "offset", "page", "parent-id", "revision", "task-id", "template-id", "version", "workspace", "workspace-id"},
Blocked: []string{"block-id", "comment-id", "comment-key", "folder", "folder-id", "id", "job-id", "parent-id", "revision", "task-id", "template-id", "version", "workspace", "workspace-id"},
Ambiguous: []string{"max-result", "max-results", "next-cursor", "next-page-token", "next-token", "per-page", "size", "take", "top"},
},
{
CLIPath: "doc +version-revert",
+1
View File
@@ -44,6 +44,7 @@
},
"command_overrides": {
"doc +version-list": {"ambiguous": ["size", "max-results", "max-result", "take", "top", "per-page", "next-cursor", "next-token", "next-page-token"], "note": "--limit/--cursor and the shipped visible compatibility flags --page-size/--page-token remain native. Other pagination spellings cannot choose between two visible real flags and must stop before execution."},
"chat group rename": {"bind": {"id": "open_conversation_id"}, "note": "This command's real --id carries one openConversationId; aliases reduce to --id without changing the value."},
"chat group members": {"bind": {"id": "open_conversation_id"}},
"chat group members add": {"bind": {"id": "open_conversation_id"}, "block": ["user-id", "open-dingtalk-id"], "note": "The real --users is a list and may contain mixed userId/openDingTalkId values; singular inputs are not promoted automatically."},
+12
View File
@@ -558,6 +558,18 @@ func RegisterFlags(cmd *cobra.Command, flags []FlagSpec) {
for _, alias := range flag.Aliases {
RegisterFlag(cmd, flag.Kind, alias, "", flag.Usage+" (alias)")
_ = cmd.Flags().MarkHidden(alias)
if registered := cmd.Flags().Lookup(alias); registered != nil {
runtimeannotate.SetFlagAnnotation(
registered,
runtimeannotate.AnnotationFlagAliasOf,
flag.Name,
)
runtimeannotate.SetFlagAnnotation(
registered,
runtimeannotate.AnnotationFlagAliasOrigin,
runtimeannotate.FlagAliasOriginCorecmdV1,
)
}
}
if flag.MarkRequired {
_ = cmd.MarkFlagRequired(flag.Name)
+7 -1
View File
@@ -90,11 +90,17 @@ func TestCrossPlatformCoverageRegisterFlagsAllKinds(t *testing.T) {
}
}
// Aliases are registered with the main kind and hidden.
for _, alias := range []string{"i-alias", "sl-alias"} {
for alias, canonical := range map[string]string{"i-alias": "i", "sl-alias": "sl"} {
f := cmd.Flags().Lookup(alias)
if f == nil || !f.Hidden {
t.Fatalf("alias %q = %#v, want registered+hidden", alias, f)
}
if got := f.Annotations[runtimeannotate.AnnotationFlagAliasOf]; len(got) != 1 || got[0] != canonical {
t.Fatalf("alias %q annotation = %#v, want alias_of %q", alias, got, canonical)
}
if got := f.Annotations[runtimeannotate.AnnotationFlagAliasOrigin]; len(got) != 1 || got[0] != runtimeannotate.FlagAliasOriginCorecmdV1 {
t.Fatalf("alias %q origin = %#v, want corecmd FlagSpec marker", alias, got)
}
}
if cmd.Flags().Lookup("i-alias").Value.Type() != "int" {
t.Fatal("int alias must be registered as int")
@@ -0,0 +1,25 @@
// 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 runtimeannotate
// CLI flag alias evidence is kept in this narrow file so the base-owned
// Interface Snapshot helper can add the protocol constants to an older stable
// worktree without replacing that revision's complete runtimeannotate package.
// Only corecmd.FlagSpec.Aliases writes the exact origin; neither field is a
// Schema synonym or final payload-equivalence proof.
const (
AnnotationFlagAliasOf = "dws.compat.alias_of"
AnnotationFlagAliasOrigin = "dws.compat.alias_origin"
FlagAliasOriginCorecmdV1 = "corecmd.flag_spec_aliases.v1"
)
+6
View File
@@ -128,6 +128,12 @@ func TestClientCreateRuleBasedSubscriptionsUsesDocumentedRuleParam(t *testing.T)
{"receive_user/openDingtalkId", EventFromUser, RuleOptions{OpenDingTalkID: "open-user-1"}, map[string]any{"targetUid": "open-user-1", "targetUidType": "openDingtalkId"}},
{"receive_o2o_all", EventAllSingleChat, RuleOptions{}, map[string]any{}},
{"receive_group_all", EventAllGroupChat, RuleOptions{}, map[string]any{}},
{"oa_approval_task_created", EventOAApprovalTaskCreated, RuleOptions{}, map[string]any{}},
{"oa_approval_task_finished", EventOAApprovalTaskFinished, RuleOptions{}, map[string]any{}},
{"oa_approval_task_redirected", EventOAApprovalTaskRedirected, RuleOptions{}, map[string]any{}},
{"oa_approval_instance_started", EventOAApprovalInstanceStarted, RuleOptions{}, map[string]any{}},
{"oa_approval_instance_terminated", EventOAApprovalInstanceTerminated, RuleOptions{}, map[string]any{}},
{"oa_approval_instance_finished", EventOAApprovalInstanceFinished, RuleOptions{}, map[string]any{}},
{"read_group", EventReadGroup, RuleOptions{GroupID: "cid-1"}, map[string]any{"openConversationId": "cid-1"}},
{"recall_group", EventRecallGroup, RuleOptions{GroupID: "cid-1"}, map[string]any{"openConversationId": "cid-1"}},
{"reaction_group", EventReactionGroup, RuleOptions{GroupID: "cid-1"}, map[string]any{"openConversationId": "cid-1"}},
+241 -2
View File
@@ -121,6 +121,94 @@ type GroupLifecycleEventOutput struct {
Payload map[string]any `json:"payload" description:"群生命周期事件业务数据,字段以服务端实际推送为准" additional_properties:"true"`
}
type OAApprovalTaskCreatedOutput struct {
Type string `json:"type" description:"事件类型,固定为当前 event_key"`
EventID string `json:"event_id" description:"事件 ID,可用于去重"`
Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"`
SubscribeID string `json:"subscribe_id" description:"订阅 ID"`
ProcessInstanceID string `json:"process_instance_id" description:"审批实例 ID"`
ProcessCode string `json:"process_code" description:"审批流程模板编码"`
TaskID string `json:"task_id" description:"审批任务 ID"`
Title string `json:"title" description:"审批标题"`
Status string `json:"status" description:"审批任务状态"`
CreateTime int64 `json:"create_time" description:"审批任务创建时间" format:"timestamp_ms"`
EventTime int64 `json:"event_time" description:"审批任务事件业务时间" format:"timestamp_ms"`
}
type OAApprovalTaskFinishedOutput struct {
Type string `json:"type" description:"事件类型,固定为当前 event_key"`
EventID string `json:"event_id" description:"事件 ID,可用于去重"`
Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"`
SubscribeID string `json:"subscribe_id" description:"订阅 ID"`
ProcessInstanceID string `json:"process_instance_id" description:"审批实例 ID"`
ProcessCode string `json:"process_code" description:"审批流程模板编码"`
TaskID string `json:"task_id" description:"审批任务 ID"`
Title string `json:"title" description:"审批标题"`
Status string `json:"status" description:"审批任务状态"`
Result string `json:"result" description:"审批任务处理结果,值以服务端实际推送为准"`
CreateTime int64 `json:"create_time" description:"审批任务创建时间" format:"timestamp_ms"`
FinishTime int64 `json:"finish_time" description:"审批任务完成时间" format:"timestamp_ms"`
EventTime int64 `json:"event_time" description:"审批任务事件业务时间" format:"timestamp_ms"`
}
type OAApprovalTaskRedirectedOutput struct {
Type string `json:"type" description:"事件类型,固定为当前 event_key"`
EventID string `json:"event_id" description:"事件 ID,可用于去重"`
Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"`
SubscribeID string `json:"subscribe_id" description:"订阅 ID"`
ProcessInstanceID string `json:"process_instance_id" description:"审批实例 ID"`
ProcessCode string `json:"process_code" description:"审批流程模板编码"`
TaskID string `json:"task_id" description:"原审批任务 ID"`
Title string `json:"title" description:"审批标题"`
Status string `json:"status" description:"原审批任务状态"`
Result string `json:"result" description:"审批任务转交结果,值以服务端实际推送为准"`
CreateTime int64 `json:"create_time" description:"原审批任务创建时间" format:"timestamp_ms"`
FinishTime int64 `json:"finish_time" description:"原审批任务转交完成时间" format:"timestamp_ms"`
EventTime int64 `json:"event_time" description:"审批任务转交事件业务时间" format:"timestamp_ms"`
}
type OAApprovalInstanceStartedOutput struct {
Type string `json:"type" description:"事件类型,固定为当前 event_key"`
EventID string `json:"event_id" description:"事件 ID,可用于去重"`
Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"`
SubscribeID string `json:"subscribe_id" description:"订阅 ID"`
ProcessInstanceID string `json:"process_instance_id" description:"审批实例 ID"`
ProcessCode string `json:"process_code" description:"审批流程模板编码"`
Title string `json:"title" description:"审批标题"`
Status string `json:"status" description:"审批实例状态"`
CreateTime int64 `json:"create_time" description:"审批实例创建时间" format:"timestamp_ms"`
EventTime int64 `json:"event_time" description:"审批实例事件业务时间" format:"timestamp_ms"`
}
type OAApprovalInstanceTerminatedOutput struct {
Type string `json:"type" description:"事件类型,固定为当前 event_key"`
EventID string `json:"event_id" description:"事件 ID,可用于去重"`
Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"`
SubscribeID string `json:"subscribe_id" description:"订阅 ID"`
ProcessInstanceID string `json:"process_instance_id" description:"审批实例 ID"`
ProcessCode string `json:"process_code" description:"审批流程模板编码"`
Title string `json:"title" description:"审批标题"`
Status string `json:"status" description:"审批实例状态"`
CreateTime int64 `json:"create_time" description:"审批实例创建时间" format:"timestamp_ms"`
FinishTime int64 `json:"finish_time" description:"审批实例终止时间" format:"timestamp_ms"`
EventTime int64 `json:"event_time" description:"审批实例终止事件业务时间" format:"timestamp_ms"`
}
type OAApprovalInstanceFinishedOutput struct {
Type string `json:"type" description:"事件类型,固定为当前 event_key"`
EventID string `json:"event_id" description:"事件 ID,可用于去重"`
Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"`
SubscribeID string `json:"subscribe_id" description:"订阅 ID"`
ProcessInstanceID string `json:"process_instance_id" description:"审批实例 ID"`
ProcessCode string `json:"process_code" description:"审批流程模板编码"`
Title string `json:"title" description:"审批标题"`
Status string `json:"status" description:"审批实例状态"`
Result string `json:"result" description:"审批实例处理结果,值以服务端实际推送为准"`
CreateTime int64 `json:"create_time" description:"审批实例创建时间" format:"timestamp_ms"`
FinishTime int64 `json:"finish_time" description:"审批实例完成时间" format:"timestamp_ms"`
EventTime int64 `json:"event_time" description:"审批实例事件业务时间" format:"timestamp_ms"`
}
type GroupMemberEventOutput struct {
Type string `json:"type" description:"事件类型,固定为当前 event_key"`
EventID string `json:"event_id" description:"事件 ID,可用于去重"`
@@ -230,6 +318,22 @@ type personalGroupMemberRecord struct {
OpenDingTalkID string `json:"openDingTalkId"`
}
type personalOAApprovalPayload struct {
EventTime int64 `json:"event_time"`
Body personalOAApprovalBody `json:"body"`
}
type personalOAApprovalBody struct {
ProcessInstanceID string `json:"processInstanceId"`
ProcessCode string `json:"processCode"`
TaskID string `json:"taskId"`
Title string `json:"title"`
Status string `json:"status"`
Result string `json:"result"`
CreateTime int64 `json:"createTime"`
FinishTime int64 `json:"finishTime"`
}
func (b *personalReactionBody) UnmarshalJSON(data []byte) error {
// encoding/json otherwise falls back to case-insensitive field matching.
// Read this protocol field from a map so only operOpenDingtalkId is accepted.
@@ -343,7 +447,7 @@ func ProjectOutput(ev transport.Event) (any, error) {
case isGroupMemberEvent(eventType):
return projectGroupMemberEvent(ev, base, data.Payload)
case isGroupLifecycleEvent(eventType):
payload, err := decodeGroupLifecyclePayload(data.Payload)
payload, err := decodeConservativePayload(data.Payload)
if err != nil {
return ev, fmt.Errorf("decode personal group lifecycle payload: %w", err)
}
@@ -354,6 +458,8 @@ func ProjectOutput(ev transport.Event) (any, error) {
SubscribeID: base.SubscribeID,
Payload: payload,
}, nil
case isOAEvent(eventType):
return projectOAApprovalEvent(ev, base, data.Payload)
default:
return ev, fmt.Errorf("unsupported personal event type %q", eventType)
}
@@ -370,7 +476,7 @@ func projectMessageEventContext(message personalMessageContext) MessageEventCont
}
}
func decodeGroupLifecyclePayload(raw json.RawMessage) (map[string]any, error) {
func decodeConservativePayload(raw json.RawMessage) (map[string]any, error) {
trimmed := bytes.TrimSpace(raw)
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
return nil, fmt.Errorf("payload is missing")
@@ -489,6 +595,112 @@ func projectGroupMemberEvent(ev transport.Event, base baseEventOutput, raw json.
}, nil
}
func projectOAApprovalEvent(ev transport.Event, base baseEventOutput, raw json.RawMessage) (any, error) {
var payload personalOAApprovalPayload
if err := decodeRequiredPayload(raw, &payload); err != nil {
return ev, fmt.Errorf("decode personal OA payload: %w", err)
}
if strings.TrimSpace(payload.Body.ProcessInstanceID) == "" {
return ev, fmt.Errorf("decode personal OA payload: processInstanceId is required")
}
if isOAApprovalTaskEvent(base.Type) && strings.TrimSpace(payload.Body.TaskID) == "" {
return ev, fmt.Errorf("decode personal OA payload: taskId is required for %s", base.Type)
}
switch base.Type {
case EventOAApprovalTaskCreated:
return OAApprovalTaskCreatedOutput{
Type: base.Type,
EventID: base.EventID,
Timestamp: base.Timestamp,
SubscribeID: base.SubscribeID,
ProcessInstanceID: payload.Body.ProcessInstanceID,
ProcessCode: payload.Body.ProcessCode,
TaskID: payload.Body.TaskID,
Title: payload.Body.Title,
Status: payload.Body.Status,
CreateTime: payload.Body.CreateTime,
EventTime: payload.EventTime,
}, nil
case EventOAApprovalTaskFinished:
return OAApprovalTaskFinishedOutput{
Type: base.Type,
EventID: base.EventID,
Timestamp: base.Timestamp,
SubscribeID: base.SubscribeID,
ProcessInstanceID: payload.Body.ProcessInstanceID,
ProcessCode: payload.Body.ProcessCode,
TaskID: payload.Body.TaskID,
Title: payload.Body.Title,
Status: payload.Body.Status,
Result: payload.Body.Result,
CreateTime: payload.Body.CreateTime,
FinishTime: payload.Body.FinishTime,
EventTime: payload.EventTime,
}, nil
case EventOAApprovalTaskRedirected:
return OAApprovalTaskRedirectedOutput{
Type: base.Type,
EventID: base.EventID,
Timestamp: base.Timestamp,
SubscribeID: base.SubscribeID,
ProcessInstanceID: payload.Body.ProcessInstanceID,
ProcessCode: payload.Body.ProcessCode,
TaskID: payload.Body.TaskID,
Title: payload.Body.Title,
Status: payload.Body.Status,
Result: payload.Body.Result,
CreateTime: payload.Body.CreateTime,
FinishTime: payload.Body.FinishTime,
EventTime: payload.EventTime,
}, nil
case EventOAApprovalInstanceStarted:
return OAApprovalInstanceStartedOutput{
Type: base.Type,
EventID: base.EventID,
Timestamp: base.Timestamp,
SubscribeID: base.SubscribeID,
ProcessInstanceID: payload.Body.ProcessInstanceID,
ProcessCode: payload.Body.ProcessCode,
Title: payload.Body.Title,
Status: payload.Body.Status,
CreateTime: payload.Body.CreateTime,
EventTime: payload.EventTime,
}, nil
case EventOAApprovalInstanceTerminated:
return OAApprovalInstanceTerminatedOutput{
Type: base.Type,
EventID: base.EventID,
Timestamp: base.Timestamp,
SubscribeID: base.SubscribeID,
ProcessInstanceID: payload.Body.ProcessInstanceID,
ProcessCode: payload.Body.ProcessCode,
Title: payload.Body.Title,
Status: payload.Body.Status,
CreateTime: payload.Body.CreateTime,
FinishTime: payload.Body.FinishTime,
EventTime: payload.EventTime,
}, nil
case EventOAApprovalInstanceFinished:
return OAApprovalInstanceFinishedOutput{
Type: base.Type,
EventID: base.EventID,
Timestamp: base.Timestamp,
SubscribeID: base.SubscribeID,
ProcessInstanceID: payload.Body.ProcessInstanceID,
ProcessCode: payload.Body.ProcessCode,
Title: payload.Body.Title,
Status: payload.Body.Status,
Result: payload.Body.Result,
CreateTime: payload.Body.CreateTime,
FinishTime: payload.Body.FinishTime,
EventTime: payload.EventTime,
}, nil
default:
return ev, fmt.Errorf("unsupported personal OA event type %q", base.Type)
}
}
func decodeRequiredPayload(raw json.RawMessage, target any) error {
trimmed := bytes.TrimSpace(raw)
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
@@ -651,6 +863,18 @@ func outputTypeForEvent(eventKey string) reflect.Type {
return reflect.TypeOf(GroupMemberEventOutput{})
case isGroupLifecycleEvent(eventKey):
return reflect.TypeOf(GroupLifecycleEventOutput{})
case eventKey == EventOAApprovalTaskCreated:
return reflect.TypeOf(OAApprovalTaskCreatedOutput{})
case eventKey == EventOAApprovalTaskFinished:
return reflect.TypeOf(OAApprovalTaskFinishedOutput{})
case eventKey == EventOAApprovalTaskRedirected:
return reflect.TypeOf(OAApprovalTaskRedirectedOutput{})
case eventKey == EventOAApprovalInstanceStarted:
return reflect.TypeOf(OAApprovalInstanceStartedOutput{})
case eventKey == EventOAApprovalInstanceTerminated:
return reflect.TypeOf(OAApprovalInstanceTerminatedOutput{})
case eventKey == EventOAApprovalInstanceFinished:
return reflect.TypeOf(OAApprovalInstanceFinishedOutput{})
default:
return reflect.TypeOf(baseEventOutput{})
}
@@ -677,6 +901,21 @@ func isGroupLifecycleEvent(eventKey string) bool {
eventKey == EventGroupDisbanded
}
func isOAEvent(eventKey string) bool {
return eventKey == EventOAApprovalTaskCreated ||
eventKey == EventOAApprovalTaskFinished ||
eventKey == EventOAApprovalTaskRedirected ||
eventKey == EventOAApprovalInstanceStarted ||
eventKey == EventOAApprovalInstanceTerminated ||
eventKey == EventOAApprovalInstanceFinished
}
func isOAApprovalTaskEvent(eventKey string) bool {
return eventKey == EventOAApprovalTaskCreated ||
eventKey == EventOAApprovalTaskFinished ||
eventKey == EventOAApprovalTaskRedirected
}
func schemaType(t reflect.Type) string {
switch t.Kind() {
case reflect.String:
+304
View File
@@ -150,6 +150,59 @@ func personalGroupMemberData(eventKey string) string {
}`, eventKey)
}
func personalOAData(eventKey string) string {
body := map[string]any{
"processInstanceId": "process-instance-1",
"createTime": int64(1785229100000),
"processCode": "PROC-TEST-1",
"title": "测试审批",
}
switch eventKey {
case EventOAApprovalTaskCreated:
body["taskId"] = "approval-task-1"
body["status"] = "RUNNING"
case EventOAApprovalTaskFinished:
body["taskId"] = "approval-task-1"
body["status"] = "FINISHED"
body["result"] = "agree"
body["finishTime"] = int64(1785229199000)
case EventOAApprovalTaskRedirected:
body["taskId"] = "approval-task-1"
body["status"] = "FINISHED"
body["result"] = "redirect"
body["finishTime"] = int64(1785229199000)
case EventOAApprovalInstanceStarted:
body["status"] = "RUNNING"
case EventOAApprovalInstanceTerminated:
body["status"] = "TERMINATED"
body["finishTime"] = int64(1785229199000)
case EventOAApprovalInstanceFinished:
body["status"] = "FINISHED"
body["result"] = "agree"
body["finishTime"] = int64(1785229199000)
}
data := map[string]any{
"eventId": "oa-event",
"eventKey": eventKey,
"occurredAtMs": int64(1785229200123),
"subId": "oa-data-sub",
"payload": map[string]any{
"uid": 100001,
"CORPID": "internal-corp",
"clientId": "internal-client",
"filterSubId": "internal-filter",
"bizid": "internal-biz",
"orgId": 100002,
"sourceId": "open",
"body": body,
"event_time": int64(1785229199000),
"futureField": map[string]any{"nested": true},
},
}
encoded, _ := json.Marshal(data)
return string(encoded)
}
func TestCrossPlatformCoverageProjectOutputMessageEvents(t *testing.T) {
for _, eventKey := range []string{EventMention, EventSingleChat, EventInChat, EventFromUser, EventAllSingleChat, EventAllGroupChat} {
t.Run(eventKey, func(t *testing.T) {
@@ -396,6 +449,170 @@ func TestCrossPlatformCoverageProjectOutputGroupLifecycleEvents(t *testing.T) {
}
}
func TestCrossPlatformCoverageProjectOutputOAEvents(t *testing.T) {
tests := []struct {
eventKey string
want any
}{
{
eventKey: EventOAApprovalTaskCreated,
want: OAApprovalTaskCreatedOutput{
Type: EventOAApprovalTaskCreated,
EventID: "oa-event",
Timestamp: 1785229200123,
SubscribeID: "outer-sub",
ProcessInstanceID: "process-instance-1",
ProcessCode: "PROC-TEST-1",
TaskID: "approval-task-1",
Title: "测试审批",
Status: "RUNNING",
CreateTime: 1785229100000,
EventTime: 1785229199000,
},
},
{
eventKey: EventOAApprovalTaskFinished,
want: OAApprovalTaskFinishedOutput{
Type: EventOAApprovalTaskFinished,
EventID: "oa-event",
Timestamp: 1785229200123,
SubscribeID: "outer-sub",
ProcessInstanceID: "process-instance-1",
ProcessCode: "PROC-TEST-1",
TaskID: "approval-task-1",
Title: "测试审批",
Status: "FINISHED",
Result: "agree",
CreateTime: 1785229100000,
FinishTime: 1785229199000,
EventTime: 1785229199000,
},
},
{
eventKey: EventOAApprovalTaskRedirected,
want: OAApprovalTaskRedirectedOutput{
Type: EventOAApprovalTaskRedirected,
EventID: "oa-event",
Timestamp: 1785229200123,
SubscribeID: "outer-sub",
ProcessInstanceID: "process-instance-1",
ProcessCode: "PROC-TEST-1",
TaskID: "approval-task-1",
Title: "测试审批",
Status: "FINISHED",
Result: "redirect",
CreateTime: 1785229100000,
FinishTime: 1785229199000,
EventTime: 1785229199000,
},
},
{
eventKey: EventOAApprovalInstanceStarted,
want: OAApprovalInstanceStartedOutput{
Type: EventOAApprovalInstanceStarted,
EventID: "oa-event",
Timestamp: 1785229200123,
SubscribeID: "outer-sub",
ProcessInstanceID: "process-instance-1",
ProcessCode: "PROC-TEST-1",
Title: "测试审批",
Status: "RUNNING",
CreateTime: 1785229100000,
EventTime: 1785229199000,
},
},
{
eventKey: EventOAApprovalInstanceTerminated,
want: OAApprovalInstanceTerminatedOutput{
Type: EventOAApprovalInstanceTerminated,
EventID: "oa-event",
Timestamp: 1785229200123,
SubscribeID: "outer-sub",
ProcessInstanceID: "process-instance-1",
ProcessCode: "PROC-TEST-1",
Title: "测试审批",
Status: "TERMINATED",
CreateTime: 1785229100000,
FinishTime: 1785229199000,
EventTime: 1785229199000,
},
},
{
eventKey: EventOAApprovalInstanceFinished,
want: OAApprovalInstanceFinishedOutput{
Type: EventOAApprovalInstanceFinished,
EventID: "oa-event",
Timestamp: 1785229200123,
SubscribeID: "outer-sub",
ProcessInstanceID: "process-instance-1",
ProcessCode: "PROC-TEST-1",
Title: "测试审批",
Status: "FINISHED",
Result: "agree",
CreateTime: 1785229100000,
FinishTime: 1785229199000,
EventTime: 1785229199000,
},
},
}
for _, tt := range tests {
t.Run(tt.eventKey, func(t *testing.T) {
projected, err := ProjectOutput(transport.Event{
EventID: "outer-event",
EventBornTime: 11,
EventType: tt.eventKey,
SubscribeID: "outer-sub",
Data: personalOAData(tt.eventKey),
})
if err != nil {
t.Fatalf("ProjectOutput() error = %v", err)
}
if !reflect.DeepEqual(projected, tt.want) {
t.Fatalf("ProjectOutput() = %#v, want %#v", projected, tt.want)
}
assertNoInternalActionFields(t, projected)
})
}
}
func TestCrossPlatformCoverageProjectOutputRejectsUnsupportedOAType(t *testing.T) {
ev := transport.Event{EventID: "outer-event", EventType: "user_oa_approval_unknown"}
projected, err := projectOAApprovalEvent(
ev,
baseEventOutput{Type: ev.EventType, EventID: ev.EventID},
json.RawMessage(`{"body":{"processInstanceId":"process-instance-1"},"event_time":1}`),
)
if err == nil || !strings.Contains(err.Error(), `unsupported personal OA event type "user_oa_approval_unknown"`) {
t.Fatalf("projectOAApprovalEvent() error = %v", err)
}
if got, ok := projected.(transport.Event); !ok || !reflect.DeepEqual(got, ev) {
t.Fatalf("projectOAApprovalEvent() fallback = %#v, want %#v", projected, ev)
}
}
func TestCrossPlatformCoverageProjectOutputOADecodesDoublyWrappedJSONString(t *testing.T) {
once, err := json.Marshal(personalOAData(EventOAApprovalTaskCreated))
if err != nil {
t.Fatal(err)
}
twice, err := json.Marshal(string(once))
if err != nil {
t.Fatal(err)
}
projected, err := ProjectOutput(transport.Event{Data: string(twice)})
if err != nil {
t.Fatalf("ProjectOutput() error = %v", err)
}
got, ok := projected.(OAApprovalTaskCreatedOutput)
if !ok {
t.Fatalf("ProjectOutput() type = %T, want OAApprovalTaskCreatedOutput", projected)
}
if got.Type != EventOAApprovalTaskCreated || got.EventID != "oa-event" || got.SubscribeID != "oa-data-sub" {
t.Fatalf("ProjectOutput() = %#v", got)
}
}
func TestCrossPlatformCoverageProjectOutputGroupMemberEvents(t *testing.T) {
for _, eventKey := range []string{EventGroupMemberAdded, EventGroupMemberExited} {
t.Run(eventKey, func(t *testing.T) {
@@ -549,6 +766,93 @@ func TestCrossPlatformCoverageProjectOutputRejectsInvalidGroupLifecyclePayloads(
}
}
func TestCrossPlatformCoverageProjectOutputRejectsInvalidOAPayloads(t *testing.T) {
tests := []struct {
name string
payload string
}{
{name: "missing"},
{name: "null", payload: `,"payload":null`},
{name: "empty object", payload: `,"payload":{}`},
{name: "array", payload: `,"payload":[]`},
{name: "string", payload: `,"payload":"invalid"`},
{name: "missing body", payload: `,"payload":{"event_time":1}`},
{name: "null body", payload: `,"payload":{"body":null,"event_time":1}`},
{name: "empty body", payload: `,"payload":{"body":{},"event_time":1}`},
}
for _, eventKey := range []string{
EventOAApprovalTaskCreated,
EventOAApprovalTaskFinished,
EventOAApprovalTaskRedirected,
EventOAApprovalInstanceStarted,
EventOAApprovalInstanceTerminated,
EventOAApprovalInstanceFinished,
} {
for _, tt := range tests {
t.Run(eventKey+"/"+tt.name, func(t *testing.T) {
ev := transport.Event{
EventID: "outer-event",
EventType: eventKey,
Data: fmt.Sprintf(`{"eventKey":%q%s}`, eventKey, tt.payload),
}
projected, err := ProjectOutput(ev)
if err == nil {
t.Fatal("ProjectOutput() error = nil, want OA payload validation error")
}
if !strings.Contains(err.Error(), "decode personal OA payload") {
t.Fatalf("ProjectOutput() error = %v, want OA payload context", err)
}
got, ok := projected.(transport.Event)
if !ok || !reflect.DeepEqual(got, ev) {
t.Fatalf("ProjectOutput() fallback = %#v, want %#v", projected, ev)
}
})
}
}
}
func TestCrossPlatformCoverageProjectOutputRejectsOAWithoutStableIDs(t *testing.T) {
tests := []struct {
name string
eventKey string
body string
want string
}{
{
name: "missing process instance",
eventKey: EventOAApprovalInstanceStarted,
body: `{"status":"RUNNING"}`,
want: "processInstanceId is required",
},
{
name: "missing task",
eventKey: EventOAApprovalTaskCreated,
body: `{"processInstanceId":"process-instance-1","status":"RUNNING"}`,
want: "taskId is required",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ev := transport.Event{
EventID: "outer-event",
EventType: tt.eventKey,
Data: fmt.Sprintf(
`{"eventKey":%q,"payload":{"body":%s,"event_time":1}}`,
tt.eventKey,
tt.body,
),
}
projected, err := ProjectOutput(ev)
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Fatalf("ProjectOutput() error = %v, want %q", err, tt.want)
}
if got, ok := projected.(transport.Event); !ok || !reflect.DeepEqual(got, ev) {
t.Fatalf("ProjectOutput() fallback = %#v, want %#v", projected, ev)
}
})
}
}
func TestCrossPlatformCoverageProjectOutputDecodesWrappedJSONString(t *testing.T) {
wrapped, err := json.Marshal(personalMessageData(EventSingleChat))
if err != nil {
+88 -16
View File
@@ -23,22 +23,28 @@ import (
)
const (
EventMention = "user_im_message_receive_at"
EventSingleChat = "user_im_message_receive_o2o"
EventInChat = "user_im_message_receive_group"
EventFromUser = "user_im_message_receive_user"
EventAllSingleChat = "user_im_message_receive_o2o_all"
EventAllGroupChat = "user_im_message_receive_group_all"
EventReadO2O = "user_im_message_read_o2o"
EventReadGroup = "user_im_message_read_group"
EventRecallO2O = "user_im_message_recall_o2o"
EventRecallGroup = "user_im_message_recall_group"
EventReactionO2O = "user_im_message_reaction_o2o"
EventReactionGroup = "user_im_message_reaction_group"
EventGroupUpdated = "user_im_group_updated"
EventGroupMemberAdded = "user_im_group_member_added"
EventGroupMemberExited = "user_im_group_member_exited"
EventGroupDisbanded = "user_im_group_disbanded"
EventMention = "user_im_message_receive_at"
EventSingleChat = "user_im_message_receive_o2o"
EventInChat = "user_im_message_receive_group"
EventFromUser = "user_im_message_receive_user"
EventAllSingleChat = "user_im_message_receive_o2o_all"
EventAllGroupChat = "user_im_message_receive_group_all"
EventReadO2O = "user_im_message_read_o2o"
EventReadGroup = "user_im_message_read_group"
EventRecallO2O = "user_im_message_recall_o2o"
EventRecallGroup = "user_im_message_recall_group"
EventReactionO2O = "user_im_message_reaction_o2o"
EventReactionGroup = "user_im_message_reaction_group"
EventGroupUpdated = "user_im_group_updated"
EventGroupMemberAdded = "user_im_group_member_added"
EventGroupMemberExited = "user_im_group_member_exited"
EventGroupDisbanded = "user_im_group_disbanded"
EventOAApprovalTaskCreated = "user_oa_approval_task_created"
EventOAApprovalTaskFinished = "user_oa_approval_task_finished"
EventOAApprovalTaskRedirected = "user_oa_approval_task_redirected"
EventOAApprovalInstanceStarted = "user_oa_approval_instance_started"
EventOAApprovalInstanceTerminated = "user_oa_approval_instance_terminated"
EventOAApprovalInstanceFinished = "user_oa_approval_instance_finished"
)
const (
@@ -273,6 +279,72 @@ var definitions = []Definition{
Auth: map[string]any{"identity": "user"},
Public: true,
},
{
EventKey: EventOAApprovalTaskCreated,
DisplayName: "审批任务创建",
Description: "审批任务创建,发送给审批人",
Category: "oa",
RuleType: "all",
Status: StatusEnabled,
RequiredParams: nil,
Auth: map[string]any{"identity": "user"},
Public: true,
},
{
EventKey: EventOAApprovalTaskFinished,
DisplayName: "审批任务完成",
Description: "审批任务已完成",
Category: "oa",
RuleType: "all",
Status: StatusEnabled,
RequiredParams: nil,
Auth: map[string]any{"identity": "user"},
Public: true,
},
{
EventKey: EventOAApprovalTaskRedirected,
DisplayName: "审批人任务转交",
Description: "审批任务已转交",
Category: "oa",
RuleType: "all",
Status: StatusEnabled,
RequiredParams: nil,
Auth: map[string]any{"identity": "user"},
Public: true,
},
{
EventKey: EventOAApprovalInstanceStarted,
DisplayName: "发起审批单",
Description: "审批实例已发起",
Category: "oa",
RuleType: "all",
Status: StatusEnabled,
RequiredParams: nil,
Auth: map[string]any{"identity": "user"},
Public: true,
},
{
EventKey: EventOAApprovalInstanceTerminated,
DisplayName: "审批单终止",
Description: "审批实例已终止",
Category: "oa",
RuleType: "all",
Status: StatusEnabled,
RequiredParams: nil,
Auth: map[string]any{"identity": "user"},
Public: true,
},
{
EventKey: EventOAApprovalInstanceFinished,
DisplayName: "审批单完成",
Description: "审批实例完成,发送给审批单发起人",
Category: "oa",
RuleType: "all",
Status: StatusEnabled,
RequiredParams: nil,
Auth: map[string]any{"identity": "user"},
Public: true,
},
}
func targetUIDConstraints() *ParameterConstraints {
+147 -1
View File
@@ -46,12 +46,47 @@ func TestCatalogEnabledEvents(t *testing.T) {
EventGroupMemberAdded,
EventGroupMemberExited,
EventGroupDisbanded,
EventOAApprovalTaskCreated,
EventOAApprovalTaskFinished,
EventOAApprovalTaskRedirected,
EventOAApprovalInstanceStarted,
EventOAApprovalInstanceTerminated,
EventOAApprovalInstanceFinished,
}
if !reflect.DeepEqual(keys, want) {
t.Fatalf("keys = %#v, want %#v", keys, want)
}
}
func TestOAEventCatalogDefinitions(t *testing.T) {
items := Catalog("oa", true, false)
wantKeys := []string{
EventOAApprovalTaskCreated,
EventOAApprovalTaskFinished,
EventOAApprovalTaskRedirected,
EventOAApprovalInstanceStarted,
EventOAApprovalInstanceTerminated,
EventOAApprovalInstanceFinished,
}
if len(items) != len(wantKeys) {
t.Fatalf("Catalog(oa) = %#v, want %d events", items, len(wantKeys))
}
for i, item := range items {
if item.EventKey != wantKeys[i] {
t.Fatalf("Catalog(oa)[%d].event_key = %q, want %q", i, item.EventKey, wantKeys[i])
}
if item.Category != "oa" || item.RuleType != "all" || item.Status != StatusEnabled || !item.Public {
t.Fatalf("Catalog(oa)[%d] = %#v, want public enabled oa/all event", i, item)
}
if len(item.RequiredParams) != 0 || item.Constraints != nil {
t.Fatalf("Catalog(oa)[%d] parameters = %#v/%#v, want none", i, item.RequiredParams, item.Constraints)
}
if item.Auth["identity"] != "user" {
t.Fatalf("Catalog(oa)[%d].auth = %#v, want user identity", i, item.Auth)
}
}
}
func TestEventFromUserIsPublic(t *testing.T) {
if _, ok := Lookup(EventFromUser); !ok {
t.Fatalf("Lookup(%q) failed", EventFromUser)
@@ -119,6 +154,12 @@ func TestSchemaDocumentsDefaultToTransportEnvelope(t *testing.T) {
EventGroupMemberAdded,
EventGroupMemberExited,
EventGroupDisbanded,
EventOAApprovalTaskCreated,
EventOAApprovalTaskFinished,
EventOAApprovalTaskRedirected,
EventOAApprovalInstanceStarted,
EventOAApprovalInstanceTerminated,
EventOAApprovalInstanceFinished,
} {
t.Run(eventKey, func(t *testing.T) {
def, ok := Lookup(eventKey)
@@ -433,6 +474,96 @@ func TestGroupLifecycleSchemaDocumentsUseConservativePayload(t *testing.T) {
}
}
func TestOAEventSchemaDocumentsMatchOutputDTO(t *testing.T) {
tests := []struct {
eventKey string
properties []string
}{
{
eventKey: EventOAApprovalTaskCreated,
properties: []string{
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
"process_code", "task_id", "title", "status", "create_time", "event_time",
},
},
{
eventKey: EventOAApprovalTaskFinished,
properties: []string{
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
"process_code", "task_id", "title", "status", "result", "create_time",
"finish_time", "event_time",
},
},
{
eventKey: EventOAApprovalTaskRedirected,
properties: []string{
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
"process_code", "task_id", "title", "status", "result", "create_time",
"finish_time", "event_time",
},
},
{
eventKey: EventOAApprovalInstanceStarted,
properties: []string{
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
"process_code", "title", "status", "create_time", "event_time",
},
},
{
eventKey: EventOAApprovalInstanceTerminated,
properties: []string{
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
"process_code", "title", "status", "create_time", "finish_time", "event_time",
},
},
{
eventKey: EventOAApprovalInstanceFinished,
properties: []string{
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
"process_code", "title", "status", "result", "create_time", "finish_time",
"event_time",
},
},
}
for _, tt := range tests {
t.Run(tt.eventKey, func(t *testing.T) {
def, ok := Lookup(tt.eventKey)
if !ok {
t.Fatalf("Lookup(%q) failed", tt.eventKey)
}
doc := BuildSchemaDocumentForMode(def, true)
if doc.JQRootPath != "." {
t.Fatalf("jq_root_path = %q, want .", doc.JQRootPath)
}
props, ok := doc.Schema["properties"].(map[string]any)
if !ok || len(props) != len(tt.properties) {
t.Fatalf("schema.properties = %#v, want exactly %d fields", doc.Schema["properties"], len(tt.properties))
}
for _, name := range tt.properties {
if _, ok := props[name].(map[string]any); !ok {
t.Fatalf("schema.properties.%s = %#v, want object", name, props[name])
}
}
eventType := props["type"].(map[string]any)
if !reflect.DeepEqual(eventType["enum"], []string{tt.eventKey}) {
t.Fatalf("schema.properties.type.enum = %#v, want %q", eventType["enum"], tt.eventKey)
}
if _, ok := props["payload"]; ok {
t.Fatalf("schema.properties exposed generic payload: %#v", props)
}
for _, name := range []string{"timestamp", "create_time", "finish_time", "event_time"} {
property, exists := props[name].(map[string]any)
if !exists {
continue
}
if property["type"] != "integer" || property["format"] != "timestamp_ms" {
t.Fatalf("schema.properties.%s = %#v, want timestamp_ms integer", name, property)
}
}
})
}
}
func TestGroupMemberSchemaDocumentsMatchOutputDTO(t *testing.T) {
wantProperties := []string{
"type", "event_id", "timestamp", "subscribe_id", "conversation_id",
@@ -495,7 +626,16 @@ func TestBuildRuleParamMention(t *testing.T) {
}
func TestBuildRuleParamAllEvents(t *testing.T) {
for _, eventKey := range []string{EventAllSingleChat, EventAllGroupChat} {
for _, eventKey := range []string{
EventAllSingleChat,
EventAllGroupChat,
EventOAApprovalTaskCreated,
EventOAApprovalTaskFinished,
EventOAApprovalTaskRedirected,
EventOAApprovalInstanceStarted,
EventOAApprovalInstanceTerminated,
EventOAApprovalInstanceFinished,
} {
t.Run(eventKey, func(t *testing.T) {
rule, param, err := BuildRuleParam(eventKey, RuleOptions{})
if err != nil {
@@ -701,6 +841,12 @@ func TestSupportsMessageFilter(t *testing.T) {
EventReadO2O,
EventReactionGroup,
EventGroupUpdated,
EventOAApprovalTaskCreated,
EventOAApprovalTaskFinished,
EventOAApprovalTaskRedirected,
EventOAApprovalInstanceStarted,
EventOAApprovalInstanceTerminated,
EventOAApprovalInstanceFinished,
"unknown_event",
} {
if SupportsMessageFilter(eventKey) {
+1 -1
View File
@@ -1271,7 +1271,7 @@ func newChatCommand() *cobra.Command {
"请求涉及群聊管理、聊天记录、消息发送、会话设置或群机器人",
},
AvoidWhen: []string{
"邮件用 mail;开放平台应用/机器人建号发布用 dev;企业语义找人优先 aisearch person",
"实时监听未来 IM 事件用 event +listen-im;邮件用 mail;开放平台应用/机器人建号发布用 dev;企业语义找人优先 aisearch person",
},
},
})
+4 -4
View File
@@ -94,7 +94,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
"请求涉及企业内部应用的查询、创建、配置、成员权限、机器人、事件订阅或版本管理",
},
AvoidWhen: []string{
"开放平台接口文档搜索使用 devdoc;普通钉钉业务数据使用对应产品命令",
"个人 IM/OA 实时事件监听使用 event;开放平台接口文档搜索使用 devdoc;普通钉钉业务数据使用对应产品命令",
},
},
})
@@ -293,7 +293,7 @@ func newDevAppEventListCommand(runner executor.Runner) *cobra.Command {
Selection: contract.SelectionSpec{
AgentSummary: "列出或搜索应用可订阅的事件",
UseWhen: []string{"需要查事件码、事件名称或当前事件列表时"},
AvoidWhen: []string{"订阅或退订事件使用对应写命令"},
AvoidWhen: []string{"订阅或退订应用回调事件使用对应写命令;监听当前用户个人 IM/OA 事件使用 event"},
Examples: []string{`dws dev app event list --unified-app-id <unifiedAppId> --keyword "审批" --page-size 20`},
},
},
@@ -329,7 +329,7 @@ func newDevAppEventSubscribeCommand(runner executor.Runner) *cobra.Command {
Selection: contract.SelectionSpec{
AgentSummary: "为应用订阅指定事件码",
UseWhen: []string{"已确认事件码并需要新增事件订阅时"},
AvoidWhen: []string{"查询事件码或已有订阅时先使用 dev app event list"},
AvoidWhen: []string{"查询事件码或已有订阅时先使用 dev app event list;个人 IM/OA 事件长连接监听使用 event"},
Examples: []string{"dws dev app event subscribe --unified-app-id <unifiedAppId> --event-codes bpms_task_change --dry-run"},
},
},
@@ -365,7 +365,7 @@ func newDevAppEventUnsubscribeCommand(runner executor.Runner) *cobra.Command {
Selection: contract.SelectionSpec{
AgentSummary: "取消应用的指定事件订阅",
UseWhen: []string{"需要停止接收一个或多个已订阅事件时"},
AvoidWhen: []string{"只是查看事件订阅时使用 dev app event list"},
AvoidWhen: []string{"只是查看应用事件订阅时使用 dev app event list;停止个人事件监听使用 event stop"},
Examples: []string{"dws dev app event unsubscribe --unified-app-id <unifiedAppId> --event-codes bpms_task_change --dry-run"},
},
},
+66 -18
View File
@@ -532,16 +532,23 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
fileSize := fileInfo.Size()
if deps.Caller.DryRun() {
deps.Out.PrintKeyValue("操作", "上传附件并插入文档")
deps.Out.PrintKeyValue("文档", nodeID)
deps.Out.PrintKeyValue("文件", filePath)
deps.Out.PrintKeyValue("名称", fileName)
deps.Out.PrintKeyValue("类型", mimeType)
deps.Out.PrintKeyValue("大小", fmt.Sprintf("%d bytes", fileSize))
return nil
return deps.Out.PrintJSON(map[string]any{
"contractVersion": "doc.operation.v1",
"dry_run": true,
"preview_kind": "plan",
"ok": true,
"status": "success",
"complete": true,
"operation": "doc.media_insert",
"data": map[string]any{
"executed": false, "nodeId": nodeID, "file": filePath,
"fileName": fileName, "mimeType": mimeType, "sizeBytes": fileSize,
},
"steps": []map[string]any{{"name": "validate_local_file", "status": "success"}},
})
}
ctx := context.Background()
ctx := cmd.Context()
// Step 1: get upload credentials (uploadUrl + resourceId)
deps.Out.PrintInfo(fmt.Sprintf("[1/3] 获取附件上传凭证 (%s, %d bytes)...", fileName, fileSize))
@@ -568,7 +575,20 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
"Content-Type": mimeType,
}
if err := httpPutFile(ctx, uploadURL, ossHeaders, filePath, fileSize); err != nil {
return err
return apperrors.NewAPI(
"附件上传结果未知;尚未确认正文 block 已插入,请先检查文档媒体列表,禁止改用手写 HTTP",
apperrors.WithOperation("doc.media_insert"),
apperrors.WithReason("doc_media_upload_unknown"),
apperrors.WithFailureStage("upload_oss"),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(false),
apperrors.WithActions("运行 dws doc +media-list 检查当前文档", "确认没有对应媒体后才重新执行 +media-insert", "不要 curl 上传地址或安装本地依赖"),
apperrors.WithDetails(map[string]any{
"contractVersion": "doc.operation.v1", "status": "unknown", "nodeId": nodeID,
"resourceId": resourceID, "fileName": fileName, "stage": "upload_oss",
}),
apperrors.WithCause(err),
)
}
// Step 3: insert block into document
@@ -625,15 +645,43 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
}
if err := callMCPTool("insert_document_block", insertArgs); err != nil {
return err
return apperrors.NewAPI(
"附件已上传,但正文 block 插入结果未知;请先检查媒体列表,不要重复上传或插入",
apperrors.WithOperation("doc.media_insert"),
apperrors.WithReason("doc_media_insert_partial"),
apperrors.WithFailureStage("insert_block"),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(false),
apperrors.WithActions("运行 dws doc +media-list 检查 resourceId/blockId", "不要直接重试 +media-insert,不要使用 resourceUrl 手写请求"),
apperrors.WithDetails(map[string]any{
"contractVersion": "doc.operation.v1", "status": "partial_success", "nodeId": nodeID,
"resourceId": resourceID, "resourceUrl": resourceURL, "fileName": fileName,
"steps": []map[string]any{
{"name": "resolve_upload", "status": "success"},
{"name": "upload_oss", "status": "success"},
{"name": "insert_block", "status": "unknown"},
},
}),
apperrors.WithCause(err),
)
}
if strings.HasPrefix(mimeType, "image/") {
deps.Out.PrintInfo(fmt.Sprintf("图片已插入文档: %s (resourceUrl=%s)", fileName, resourceURL))
} else {
deps.Out.PrintInfo(fmt.Sprintf("附件已插入文档: %s (resourceId=%s)", fileName, resourceID))
}
return nil
return deps.Out.PrintJSON(map[string]any{
"contractVersion": "doc.operation.v1",
"ok": true,
"status": "success",
"complete": true,
"operation": "doc.media_insert",
"data": map[string]any{
"nodeId": nodeID, "resourceId": resourceID, "resourceUrl": resourceURL,
"fileName": fileName, "mimeType": mimeType, "sizeBytes": fileSize, "inserted": true,
},
"steps": []map[string]any{
{"name": "resolve_upload", "status": "success"},
{"name": "upload_oss", "status": "success"},
{"name": "insert_block", "status": "success"},
},
})
}
// parseAttachmentUploadInfo extracts uploadUrl, resourceId and resourceUrl from the MCP tool response.
@@ -3831,8 +3879,8 @@ CLI 内部自动完成全部流程:
},
}
importCmd.Flags().String("file", "", "本地文件路径 (必填)")
importCmd.Flags().String("folder", "", "目标文件夹 ID 或 URL (可选,与 --workspace 至少传一个)")
importCmd.Flags().String("workspace", "", "目标知识库 ID 或 URL (可选,与 --folder 至少传一个)")
importCmd.Flags().String("folder", "", "目标文件夹 ID 或 URL (可选;folder/workspace 都不传时导入到默认根目录)")
importCmd.Flags().String("workspace", "", "目标知识库 ID 或 URL (可选;folder/workspace 都不传时导入到默认根目录)")
importCmd.Flags().StringP("name", "n", "", "导入后文档名称 (可选,默认取文件名)")
importCmd.Flags().String("folder-id", "", "")
_ = importCmd.Flags().MarkHidden("folder-id")
+36 -30
View File
@@ -9,6 +9,7 @@ import (
"syscall"
"unicode/utf8"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
)
@@ -17,9 +18,6 @@ const (
// Server-side OSS delta resolution is now fixed, so large chunks are safe.
initialChunkSize = 10000
// minChunkSize is the floor; below this we report an error instead of retrying.
minChunkSize = 5000
// longContentWarningThreshold triggers a hint to use --content-file.
longContentWarningThreshold = 2048
)
@@ -95,10 +93,11 @@ func docWritePipeline(cmd *cobra.Command, toolName string, toolArgs map[string]a
// Single write path
nodeID, lastResponse, writeErr = singleWrite(ctx, toolName, toolArgs)
chunksWritten = 1
if writeErr != nil && isTimeoutError(writeErr.Error()) && runeCount > minChunkSize {
// Timeout on single write — fallback to chunked with halved size
deps.Out.PrintInfo("[INFO] 单次写入超时,自动切换为分片写入...")
nodeID, chunksWritten, lastResponse, writeErr = chunkedWrite(ctx, toolName, toolArgs, markdown, operation, initialChunkSize/2)
if writeErr != nil && isTimeoutError(writeErr.Error()) {
// The server may have committed the write before the client observed the
// timeout. Replaying create/append here can duplicate a document or
// content, so fail closed and require inspection before any retry.
writeErr = docWriteUnknownStateError(operation, nodeID, "single_write", 0, 1, writeErr)
}
} else {
// Chunked write path
@@ -156,6 +155,9 @@ func chunkedWrite(ctx context.Context, toolName string, toolArgs map[string]any,
len(chunks), utf8.RuneCountInString(chunks[0])))
resultText, err := callMCPToolReturnText(ctx, "create_document", createArgs)
if err != nil {
if isTimeoutError(err.Error()) {
return "", 0, resultText, docWriteUnknownStateError(operation, "", "chunk_1", 0, len(chunks), err)
}
return "", 0, resultText, fmt.Errorf("创建文档失败: %w", err)
}
nodeID = extractNodeIDFromResult(resultText)
@@ -181,6 +183,9 @@ func chunkedWrite(ctx context.Context, toolName string, toolArgs map[string]any,
len(chunks), utf8.RuneCountInString(chunks[0]), firstMode))
resultText, err := callMCPToolReturnText(ctx, "update_document", updateArgs)
if err != nil {
if isTimeoutError(err.Error()) {
return nodeID, 0, resultText, docWriteUnknownStateError(operation, nodeID, "chunk_1", 0, len(chunks), err)
}
return nodeID, 0, resultText, fmt.Errorf("第 1 片写入失败: %w", err)
}
lastResponse = resultText
@@ -209,29 +214,9 @@ func chunkedWrite(ctx context.Context, toolName string, toolArgs map[string]any,
resultText, err := callMCPToolReturnText(ctx, "update_document", updateArgs)
if err != nil {
if isTimeoutError(err.Error()) {
newSize := chunkSize / 2
if newSize < minChunkSize {
return nodeID, writtenCount, resultText, &CLIError{
Code: CodeContentTruncated,
Message: fmt.Sprintf("分片写入持续超时,已写入 %d 片。当前分片大小 %d 字符已低于最小阈值 %d",
writtenCount, chunkSize, minChunkSize),
Suggestion: fmt.Sprintf("后端写入超时无法恢复。已成功写入部分内容到 nodeId=%s,请使用 dws doc read --node %s 查看已写入部分",
nodeID, nodeID),
Operation: operation,
}
}
deps.Out.PrintInfo(fmt.Sprintf("[INFO] 写入超时,分片大小减半为 %d 字符后重试...", newSize))
chunkSize = newSize
var remaining strings.Builder
remaining.WriteString(chunk)
for j := i + 1; j < len(chunks); j++ {
remaining.WriteString(chunks[j])
}
newChunks := splitMarkdownSafe(remaining.String(), chunkSize)
chunks = append(chunks[:i], newChunks...)
i-- // retry current index
continue
return nodeID, writtenCount, resultText, docWriteUnknownStateError(
operation, nodeID, fmt.Sprintf("chunk_%d", i+1), writtenCount, len(chunks), err,
)
}
return nodeID, writtenCount, resultText, fmt.Errorf("分片 %d 写入失败: %w", writtenCount+1, err)
}
@@ -243,6 +228,27 @@ func chunkedWrite(ctx context.Context, toolName string, toolArgs map[string]any,
return nodeID, writtenCount, lastResponse, nil
}
func docWriteUnknownStateError(operation, nodeID, stage string, written, total int, cause error) error {
details := map[string]any{
"status": "unknown",
"nodeId": nodeID,
"chunksWritten": written,
"chunksTotal": total,
"failedStage": stage,
}
return apperrors.NewAPI(
"文档写入响应超时,服务端提交状态未知;为避免重复创建或重复追加,已停止自动重试",
apperrors.WithOperation(operation),
apperrors.WithReason("doc_write_commit_unknown"),
apperrors.WithFailureStage(stage),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(false),
apperrors.WithActions("先读取目标文档确认实际写入状态", "仅在确认服务端未提交后重新执行"),
apperrors.WithDetails(details),
apperrors.WithCause(cause),
)
}
// isTimeoutError checks if an error message indicates a server-side timeout.
func isTimeoutError(msg string) bool {
lower := strings.ToLower(msg)
@@ -7,6 +7,7 @@ import (
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
)
@@ -40,10 +41,12 @@ func TestCrossPlatformCoverageDocWritePipelineStrategyRemainingCoverage(t *testi
t.Fatalf("long literal single write: %v", err)
}
fallback := strings.Repeat("x", minChunkSize+100)
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{err: errors.New("timeout")}, {text: `{}`}, {text: `{}`}}})
if err := docWritePipeline(docWriteCoverageCommand(), "update_document", map[string]any{"nodeId": "node", "mode": "overwrite", "markdown": fallback}, fallback, "update"); err != nil {
t.Fatalf("single timeout fallback: %v", err)
fallback := strings.Repeat("x", 5100)
timeoutCaller := &scriptedToolCaller{steps: []scriptedToolStep{{err: errors.New("timeout")}, {text: `{}`}}}
installScriptedCaller(t, timeoutCaller)
err := docWritePipeline(docWriteCoverageCommand(), "update_document", map[string]any{"nodeId": "node", "mode": "overwrite", "markdown": fallback}, fallback, "update")
if err == nil || !strings.Contains(err.Error(), "提交状态未知") || timeoutCaller.calls != 1 {
t.Fatalf("single timeout must stop without replay: err=%v calls=%d", err, timeoutCaller.calls)
}
chunked := strings.Repeat("x", initialChunkSize+100)
@@ -58,9 +61,43 @@ func TestCrossPlatformCoverageChunkedWriteAdaptiveRetryRemainingCoverage(t *test
os.Args = []string{"dws", "doc"}
t.Cleanup(func() { os.Args = oldArgs })
markdown := strings.Repeat("x", 24000)
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{}`}, {err: errors.New("HSFTimeoutException")}, {text: `{}`}}})
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{}`}, {err: errors.New("HSFTimeoutException")}, {text: `{}`}}}
installScriptedCaller(t, caller)
_, written, _, err := chunkedWrite(context.Background(), "update_document", map[string]any{"nodeId": "node"}, markdown, "update", 10000)
if err != nil || written < 3 {
t.Fatalf("adaptive retry written=%d err=%v", written, err)
if err == nil || written != 1 || caller.calls != 2 || !strings.Contains(err.Error(), "提交状态未知") {
t.Fatalf("timeout must stop without replay: written=%d calls=%d err=%v", written, caller.calls, err)
}
}
func TestCrossPlatformCoverageDocWriteFirstChunkTimeoutIsUnknown(t *testing.T) {
oldArgs := os.Args
os.Args = []string{"dws", "doc"}
t.Cleanup(func() { os.Args = oldArgs })
markdown := strings.Repeat("x", 24000)
tests := []struct {
name string
tool string
args map[string]any
wantNode string
}{
{name: "create", tool: "create_document", args: map[string]any{"name": "doc"}},
{name: "update", tool: "update_document", args: map[string]any{"nodeId": "node", "mode": "overwrite"}, wantNode: "node"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{err: errors.New("HSFTimeoutException")}, {text: `{}`}}}
installScriptedCaller(t, caller)
nodeID, written, _, err := chunkedWrite(context.Background(), tc.tool, tc.args, markdown, tc.name, 10000)
var typed *apperrors.Error
if err == nil || !errors.As(err, &typed) {
t.Fatalf("error = %#v", err)
}
if nodeID != tc.wantNode || written != 0 || caller.calls != 1 {
t.Fatalf("node=%q written=%d calls=%d", nodeID, written, caller.calls)
}
if typed.Reason != "doc_write_commit_unknown" || typed.FailureStage != "chunk_1" || typed.ExecutionStarted == nil || !*typed.ExecutionStarted || !typed.RetryableSet || typed.Retryable {
t.Fatalf("unknown commit metadata = %#v", typed)
}
})
}
}
+1 -1
View File
@@ -63,7 +63,7 @@ func newOaCommand() *cobra.Command {
"查看待审、已办、已发起或抄送审批,并执行同意、拒绝、撤销、转交等审批动作时",
},
AvoidWhen: []string{
"不要用于普通待办任务或工作日志;需要创建审批实例时先确认当前命令面是否支持",
"不要用于普通待办任务或工作日志;需要实时监听未来的审批任务/实例事件时使用 event consume",
},
},
})
+9
View File
@@ -229,6 +229,15 @@ func compareEffectiveFlags(result *Comparison, acceptedPath string, oldCommand,
After: newFlag.NoOpt,
})
}
if oldFlag.AliasOf != "" && newFlag.AliasOf != oldFlag.AliasOf {
result.Blocking = append(result.Blocking, Change{
Kind: "flag_alias_target_changed",
Path: acceptedPath,
Flag: name,
Before: oldFlag.AliasOf,
After: newFlag.AliasOf,
})
}
if !oldFlag.Hidden && newFlag.Hidden {
result.Blocking = append(result.Blocking, Change{
Kind: "flag_became_hidden",
+408 -2
View File
@@ -251,6 +251,40 @@ func TestCrossPlatformCoverageCompareBlocksCallableMetadataRegressions(t *testin
}
}
func TestCrossPlatformCoverageCompareBlocksFlagAliasTargetRegression(t *testing.T) {
baseLegacy := testFlag("legacy-id", "string", false)
baseLegacy.AliasOf = "message-id"
base := testSnapshot(
testCommand("dws"),
testCommand("dws send", baseLegacy, testFlag("message-id", "string", false)),
)
for _, target := range []string{"", "other-id"} {
t.Run("target_"+target, func(t *testing.T) {
currentLegacy := baseLegacy
currentLegacy.AliasOf = target
current := testSnapshot(
testCommand("dws"),
testCommand(
"dws send",
currentLegacy,
testFlag("message-id", "string", false),
testFlag("other-id", "string", false),
),
)
comparison := Compare(current, base, "base")
if comparison.Compatible || !hasFlagChange(
comparison.Blocking,
"flag_alias_target_changed",
"dws send",
"legacy-id",
) {
t.Fatalf("alias target regression was not blocked: %#v", comparison)
}
})
}
}
func TestCrossPlatformCoverageCompareUsesEffectiveFlagsAtEveryCommandPath(t *testing.T) {
base := testSnapshot(
testCommand("dws", testFlag("profile", "string", false)),
@@ -314,6 +348,58 @@ func TestCrossPlatformCoverageCompareAllRequiresBothReferencesToPass(t *testing.
}
}
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsAuthorizesOnlyTheExactTransition(t *testing.T) {
before := testFlagMigrationSnapshot(false, true)
authority := testFlagMigrationManifest(FlagMigrationPending)
candidate := testFlagMigrationManifest(FlagMigrationConsumed)
report, err := CompareAllWithFlagMigrations(
testFlagMigrationSnapshot(true, true),
map[string]Snapshot{
"merge-base": before,
"stable": before,
},
authority,
candidate,
)
if err != nil {
t.Fatalf("exact base-owned migration was rejected: %v", err)
}
if !report.Compatible {
t.Fatalf("exact migration remained incompatible: %#v", report.Comparisons)
}
for _, comparison := range report.Comparisons {
if len(comparison.Blocking) != 0 {
t.Fatalf("reference %q retained blocking changes: %#v", comparison.Reference, comparison.Blocking)
}
}
}
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsComposesWithReviewedTypeChange(t *testing.T) {
registerReviewedFixture(t, flagTypeChange{CommandPath: "dws search", Flag: "query", From: "string", To: "int"})
before := testFlagMigrationSnapshot(false, true)
before.Commands = append(before.Commands, testCommand("dws search", testFlag("query", "string", false)))
after := testFlagMigrationSnapshot(true, true)
after.Commands = append(after.Commands, testCommand("dws search", testFlag("query", "int", false)))
report, err := CompareAllWithFlagMigrations(
after,
map[string]Snapshot{
"merge-base": before,
"stable": before,
},
testFlagMigrationManifest(FlagMigrationPending),
testFlagMigrationManifest(FlagMigrationConsumed),
)
if err != nil {
t.Fatalf("composed reviewed migrations were rejected: %v", err)
}
if !report.Compatible {
t.Fatalf("composed reviewed migrations remained incompatible: %#v", report.Comparisons)
}
}
// registerReviewedFixture puts a fixture command in the reviewed table for the
// duration of one test, so the behaviour tests exercise the real lookup instead
// of depending on whichever production entries happen to exist.
@@ -426,6 +512,12 @@ func TestCrossPlatformCoverageCompareReviewedFlagTypeChangeRejectsBundledRegress
next: Flag{Name: "query", Type: "int", Hidden: true},
kind: "flag_became_hidden",
},
{
name: "changed alias target",
base: Flag{Name: "query", Type: "string", AliasOf: "old-query"},
next: Flag{Name: "query", Type: "int", AliasOf: "new-query"},
kind: "flag_alias_target_changed",
},
}
for _, test := range tests {
@@ -501,8 +593,8 @@ var reviewedEntryPattern = regexp.MustCompile(
const interfaceBaselineReviewedPath = "../../scripts/policy/interface-baseline/reviewed.go"
// The two copies of the table must stay identical, otherwise a migration passes
// one gate and is blocked by the other in the same CI job.
// The modern authority and legacy smoke helper keep mirrored tables so local
// smoke checks cannot disagree with the authoritative comparison.
func TestCrossPlatformCoverageReviewedFlagTypeTableMatchesInterfaceBaseline(t *testing.T) {
source, err := os.ReadFile(interfaceBaselineReviewedPath)
if err != nil {
@@ -533,6 +625,234 @@ func TestCrossPlatformCoverageReviewedFlagTypeTableMatchesInterfaceBaseline(t *t
}
}
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsRejectsInvalidLifecycleTransitions(t *testing.T) {
empty := testEmptyFlagMigrationManifest()
pending := testFlagMigrationManifest(FlagMigrationPending)
consumed := testFlagMigrationManifest(FlagMigrationConsumed)
before := testFlagMigrationSnapshot(false, true)
after := testFlagMigrationSnapshot(true, true)
partial := testFlagMigrationSnapshot(false, true)
partial.Commands[1].LocalFlags[0].Hidden = true
partial.Commands[1].LocalFlags[0].Required = false
partial.Commands[1].LocalFlags[0].AliasOf = "message-id"
typeDrift := testFlagMigrationSnapshot(true, true)
typeDrift.Commands[1].LocalFlags[0].Type = "stringSlice"
noOptDrift := testFlagMigrationSnapshot(true, true)
noOptDrift.Commands[1].LocalFlags[0].NoOpt = ""
shorthandDrift := testFlagMigrationSnapshot(true, true)
shorthandDrift.Commands[1].LocalFlags[0].Shorthand = "x"
tests := []struct {
name string
current Snapshot
authority FlagMigrationManifest
candidate FlagMigrationManifest
}{
{
name: "candidate-added pending record cannot approve its own surface change",
current: after,
authority: empty,
candidate: pending,
},
{
name: "candidate-added consumed record cannot approve its own surface change",
current: after,
authority: empty,
candidate: consumed,
},
{
name: "partial migration is rejected",
current: partial,
authority: pending,
candidate: consumed,
},
{
name: "pending record cannot be falsely consumed before the surface changes",
current: before,
authority: pending,
candidate: consumed,
},
{
name: "completed surface change must consume its pending record",
current: after,
authority: pending,
candidate: pending,
},
{
name: "legacy type drift is outside the approval",
current: typeDrift,
authority: pending,
candidate: consumed,
},
{
name: "legacy no-opt drift is outside the approval",
current: noOptDrift,
authority: pending,
candidate: consumed,
},
{
name: "legacy shorthand drift is outside the approval",
current: shorthandDrift,
authority: pending,
candidate: consumed,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
report, err := CompareAllWithFlagMigrations(
test.current,
map[string]Snapshot{
"merge-base": before,
"stable": before,
},
test.authority,
test.candidate,
)
if err == nil {
t.Fatalf("invalid transition passed: %#v", report)
}
})
}
}
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsAllowsApprovalCreationBeforeSurfaceChange(t *testing.T) {
before := testFlagMigrationSnapshot(false, true)
report, err := CompareAllWithFlagMigrations(
before,
map[string]Snapshot{
"merge-base": before,
"stable": before,
},
testEmptyFlagMigrationManifest(),
testFlagMigrationManifest(FlagMigrationPending),
)
if err != nil {
t.Fatalf("pending approval creation was rejected before the surface changed: %v", err)
}
if !report.Compatible {
t.Fatalf("pending approval creation changed compatibility: %#v", report.Comparisons)
}
}
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsKeepsUnrelatedBreakageBlocking(t *testing.T) {
before := testFlagMigrationSnapshot(false, true)
pending := testFlagMigrationManifest(FlagMigrationPending)
consumed := testFlagMigrationManifest(FlagMigrationConsumed)
tests := []struct {
name string
mutate func(*Snapshot)
kind string
flag string
}{
{
name: "unrelated flag removal",
mutate: func(snapshot *Snapshot) {
snapshot.Commands[1].LocalFlags = snapshot.Commands[1].LocalFlags[:2]
},
kind: "flag_removed",
flag: "format",
},
{
name: "unrelated flag type change",
mutate: func(snapshot *Snapshot) {
snapshot.Commands[1].LocalFlags[2].Type = "stringSlice"
},
kind: "flag_type_changed",
flag: "format",
},
{
name: "unrelated flag no-opt change",
mutate: func(snapshot *Snapshot) {
snapshot.Commands[1].LocalFlags[2].NoOpt = ""
},
kind: "flag_no_opt_changed",
flag: "format",
},
{
name: "unrelated flag shorthand change",
mutate: func(snapshot *Snapshot) {
snapshot.Commands[1].LocalFlags[2].Shorthand = ""
},
kind: "flag_shorthand_changed",
flag: "format",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
current := testFlagMigrationSnapshot(true, true)
test.mutate(&current)
report, err := CompareAllWithFlagMigrations(
current,
map[string]Snapshot{
"merge-base": before,
"stable": before,
},
pending,
consumed,
)
if err != nil {
t.Fatalf("valid migration with unrelated breakage returned a lifecycle error: %v", err)
}
if report.Compatible {
t.Fatalf("unrelated breakage was hidden by migration approval: %#v", report)
}
for _, comparison := range report.Comparisons {
if !hasFlagChange(comparison.Blocking, test.kind, "dws chat send", test.flag) {
t.Fatalf("reference %q blocking=%#v, want %s for --%s", comparison.Reference, comparison.Blocking, test.kind, test.flag)
}
}
})
}
}
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsRetainsConsumedReceiptUntilStableCatchesUp(t *testing.T) {
before := testFlagMigrationSnapshot(false, true)
after := testFlagMigrationSnapshot(true, true)
consumed := testFlagMigrationManifest(FlagMigrationConsumed)
report, err := CompareAllWithFlagMigrations(
after,
map[string]Snapshot{
"merge-base": after,
"stable": before,
},
consumed,
consumed,
)
if err != nil {
t.Fatalf("consumed receipt was rejected while stable still needed it: %v", err)
}
if !report.Compatible {
t.Fatalf("stable comparison did not honor the consumed receipt: %#v", report.Comparisons)
}
}
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsRequiresCleanupAfterAllReferencesCatchUp(t *testing.T) {
after := testFlagMigrationSnapshot(true, true)
consumed := testFlagMigrationManifest(FlagMigrationConsumed)
references := map[string]Snapshot{
"merge-base": after,
"stable": after,
}
if report, err := CompareAllWithFlagMigrations(after, references, consumed, consumed); err == nil {
t.Fatalf("stale consumed receipt was accepted after every reference caught up: %#v", report)
}
report, err := CompareAllWithFlagMigrations(after, references, consumed, testEmptyFlagMigrationManifest())
if err != nil {
t.Fatalf("cleanup of stale consumed receipt was rejected: %v", err)
}
if !report.Compatible {
t.Fatalf("cleanup-only candidate was incompatible: %#v", report.Comparisons)
}
}
func testSnapshot(commands ...Command) Snapshot {
return Snapshot{
SchemaVersion: SchemaVersion,
@@ -571,6 +891,92 @@ func testFlag(name, flagType string, required bool) Flag {
return Flag{Name: name, Type: flagType, Required: required}
}
func testFlagMigrationSnapshot(after, includeUnrelated bool) Snapshot {
legacy := Flag{
Name: "legacy-id",
Shorthand: "l",
Type: "string",
NoOpt: "auto",
Required: true,
}
flags := []Flag{legacy}
if after {
legacy.Required = false
legacy.Hidden = true
legacy.AliasOf = "message-id"
flags = []Flag{
legacy,
{
Name: "message-id",
Type: "string",
Required: true,
},
}
}
if includeUnrelated {
flags = append(flags, Flag{
Name: "format",
Shorthand: "f",
Type: "string",
NoOpt: "json",
})
}
return testSnapshot(
testCommand("dws"),
testCommand("dws chat send", flags...),
)
}
func testFlagMigrationManifest(state string) FlagMigrationManifest {
return FlagMigrationManifest{
Version: FlagMigrationManifestVersion,
Migrations: []FlagMigration{
{
Command: "dws chat send",
Legacy: FlagMigrationSide{
Name: "legacy-id",
Before: FlagMigrationState{
Present: true,
Type: "string",
Required: true,
Shorthand: "l",
NoOpt: "auto",
Scope: "local",
},
After: FlagMigrationState{
Present: true,
Type: "string",
Hidden: true,
Shorthand: "l",
NoOpt: "auto",
Scope: "local",
AliasOf: "message-id",
},
},
Canonical: FlagMigrationSide{
Name: "message-id",
Before: FlagMigrationState{},
After: FlagMigrationState{
Present: true,
Type: "string",
Required: true,
Scope: "local",
},
},
State: state,
Reason: "rename the public flag while preserving the executable legacy alias",
},
},
}
}
func testEmptyFlagMigrationManifest() FlagMigrationManifest {
return FlagMigrationManifest{
Version: FlagMigrationManifestVersion,
Migrations: []FlagMigration{},
}
}
func hasChangeKind(changes []Change, kind string) bool {
for _, change := range changes {
if change.Kind == kind {
+696
View File
@@ -0,0 +1,696 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package interfacesnapshot
import (
"bytes"
"encoding/json"
"fmt"
"io"
"reflect"
"strings"
)
const FlagMigrationManifestVersion = 1
const (
FlagMigrationPending = "pending"
FlagMigrationConsumed = "consumed"
)
type FlagMigrationManifest struct {
Version int `json:"version"`
Migrations []FlagMigration `json:"migrations"`
}
type FlagMigration struct {
Command string `json:"command"`
Legacy FlagMigrationSide `json:"legacy"`
Canonical FlagMigrationSide `json:"canonical"`
State string `json:"state"`
Reason string `json:"reason"`
}
type FlagMigrationSide struct {
Name string `json:"name"`
Before FlagMigrationState `json:"before"`
After FlagMigrationState `json:"after"`
}
type FlagMigrationState struct {
Present bool `json:"present"`
Type string `json:"type,omitempty"`
Required bool `json:"required,omitempty"`
Hidden bool `json:"hidden,omitempty"`
Shorthand string `json:"shorthand,omitempty"`
NoOpt string `json:"no_opt,omitempty"`
Scope string `json:"scope,omitempty"`
AliasOf string `json:"alias_of,omitempty"`
}
func ReadFlagMigrationManifest(r io.Reader) (FlagMigrationManifest, error) {
data, err := io.ReadAll(r)
if err != nil {
return FlagMigrationManifest{}, fmt.Errorf("read flag migration manifest: %w", err)
}
var manifest FlagMigrationManifest
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&manifest); err != nil {
return FlagMigrationManifest{}, err
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
if err == nil {
return FlagMigrationManifest{}, fmt.Errorf("flag migration manifest contains trailing multiple JSON values")
}
return FlagMigrationManifest{}, fmt.Errorf("read trailing flag migration manifest data: %w", err)
}
if err := validateFlagMigrationJSONSchema(data); err != nil {
return FlagMigrationManifest{}, err
}
if err := manifest.Validate(); err != nil {
return FlagMigrationManifest{}, err
}
return manifest, nil
}
func validateFlagMigrationJSONSchema(data []byte) error {
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.UseNumber()
if err := validateMigrationJSONValue(decoder, "$", reflect.TypeOf(FlagMigrationManifest{})); err != nil {
return err
}
if token, err := decoder.Token(); err != io.EOF {
if err == nil {
return fmt.Errorf("flag migration manifest contains trailing JSON value %v", token)
}
return fmt.Errorf("read trailing flag migration manifest data: %w", err)
}
return nil
}
func validateMigrationJSONValue(decoder *json.Decoder, path string, schema reflect.Type) error {
token, err := decoder.Token()
if err != nil {
return fmt.Errorf("read flag migration manifest value at %s: %w", path, err)
}
switch schema.Kind() {
case reflect.Struct:
if delimiter, ok := token.(json.Delim); !ok || delimiter != '{' {
return migrationJSONTypeError(path, schema, token)
}
fields := migrationJSONFields(schema)
seen := make(map[string]bool, len(fields))
for decoder.More() {
keyToken, keyErr := decoder.Token()
if keyErr != nil {
return fmt.Errorf("read flag migration manifest field at %s: %w", path, keyErr)
}
// encoding/json guarantees object member names are string tokens.
key := keyToken.(string)
fieldSchema, exists := fields[key]
if !exists {
for canonical := range fields {
if strings.EqualFold(key, canonical) {
return fmt.Errorf(
"flag migration manifest contains non-canonical field %q at %s (want %q)",
key,
path,
canonical,
)
}
}
return fmt.Errorf("flag migration manifest contains unknown field %q at %s", key, path)
}
if seen[key] {
return fmt.Errorf("flag migration manifest contains duplicate field %q at %s", key, path)
}
seen[key] = true
if err := validateMigrationJSONValue(decoder, path+"."+key, fieldSchema); err != nil {
return err
}
}
if _, closeErr := decoder.Token(); closeErr != nil {
return fmt.Errorf("close flag migration manifest object at %s: %w", path, closeErr)
}
return nil
case reflect.Slice:
if delimiter, ok := token.(json.Delim); !ok || delimiter != '[' {
return migrationJSONTypeError(path, schema, token)
}
for index := 0; decoder.More(); index++ {
if err := validateMigrationJSONValue(decoder, fmt.Sprintf("%s[%d]", path, index), schema.Elem()); err != nil {
return err
}
}
if _, closeErr := decoder.Token(); closeErr != nil {
return fmt.Errorf("close flag migration manifest array at %s: %w", path, closeErr)
}
return nil
case reflect.String:
if _, ok := token.(string); !ok {
return migrationJSONTypeError(path, schema, token)
}
return nil
case reflect.Int:
if _, ok := token.(json.Number); !ok {
return migrationJSONTypeError(path, schema, token)
}
return nil
case reflect.Bool:
if _, ok := token.(bool); !ok {
return migrationJSONTypeError(path, schema, token)
}
return nil
default:
return fmt.Errorf("flag migration manifest value at %s has unsupported Go schema type %s", path, schema)
}
}
func migrationJSONFields(schema reflect.Type) map[string]reflect.Type {
fields := make(map[string]reflect.Type, schema.NumField())
for index := 0; index < schema.NumField(); index++ {
field := schema.Field(index)
name := field.Tag.Get("json")
if comma := strings.IndexByte(name, ','); comma >= 0 {
name = name[:comma]
}
if name == "-" {
continue
}
if name == "" {
name = field.Name
}
fields[name] = field.Type
}
return fields
}
func migrationJSONTypeError(path string, want reflect.Type, token json.Token) error {
return fmt.Errorf(
"flag migration manifest value at %s must be %s, got %s",
path,
migrationJSONKindDescription(want),
migrationJSONTokenDescription(token),
)
}
func migrationJSONKindDescription(schema reflect.Type) string {
switch schema.Kind() {
case reflect.Struct:
return "an object"
case reflect.Slice:
return "an array"
case reflect.String:
return "a string"
case reflect.Int:
return "a number"
case reflect.Bool:
return "a boolean"
default:
return "the declared JSON type"
}
}
func migrationJSONTokenDescription(token json.Token) string {
if token == nil {
return "null"
}
if delimiter, ok := token.(json.Delim); ok {
switch delimiter {
case '{':
return "object"
case '[':
return "array"
default:
return fmt.Sprintf("delimiter %q", delimiter)
}
}
switch token.(type) {
case string:
return "string"
case json.Number, float64:
return "number"
case bool:
return "boolean"
default:
return fmt.Sprintf("%T", token)
}
}
func (m FlagMigrationManifest) Validate() error {
if m.Version != FlagMigrationManifestVersion {
return fmt.Errorf(
"unsupported flag migration manifest version %d (want %d)",
m.Version,
FlagMigrationManifestVersion,
)
}
if m.Migrations == nil {
return fmt.Errorf("flag migration manifest migrations must be an array")
}
seen := make(map[string]bool, len(m.Migrations))
legacyTargets := make(map[string]string, len(m.Migrations))
for index, migration := range m.Migrations {
if err := migration.validate(); err != nil {
return fmt.Errorf("flag migration %d: %w", index, err)
}
key := migration.key()
if seen[key] {
return fmt.Errorf("flag migration %d duplicates %s", index, key)
}
seen[key] = true
legacyKey := migration.Command + "\x00" + migration.Legacy.Name
if canonical, exists := legacyTargets[legacyKey]; exists && canonical != migration.Canonical.Name {
return fmt.Errorf(
"flag migration %d maps %s --%s to both --%s and --%s",
index,
migration.Command,
migration.Legacy.Name,
canonical,
migration.Canonical.Name,
)
}
legacyTargets[legacyKey] = migration.Canonical.Name
}
return nil
}
func (m FlagMigration) validate() error {
if !isExactCommandPath(m.Command) {
return fmt.Errorf("command must be an exact command path rooted at dws: %q", m.Command)
}
if !isExactFlagName(m.Legacy.Name) {
return fmt.Errorf("legacy name must be an exact legacy flag: %q", m.Legacy.Name)
}
if !isExactFlagName(m.Canonical.Name) {
return fmt.Errorf("canonical name must be an exact canonical flag: %q", m.Canonical.Name)
}
if m.Legacy.Name == m.Canonical.Name {
return fmt.Errorf("legacy and canonical flags must differ: --%s", m.Legacy.Name)
}
if strings.TrimSpace(m.Reason) == "" {
return fmt.Errorf("migration must include a non-empty reason")
}
if m.Reason != strings.TrimSpace(m.Reason) {
return fmt.Errorf("migration reason must be trimmed")
}
if m.State != FlagMigrationPending && m.State != FlagMigrationConsumed {
return fmt.Errorf("invalid state %q", m.State)
}
if err := m.Legacy.Before.validate("legacy before"); err != nil {
return err
}
if err := m.Legacy.After.validate("legacy after"); err != nil {
return err
}
if err := m.Canonical.Before.validate("canonical before"); err != nil {
return err
}
if err := m.Canonical.After.validate("canonical after"); err != nil {
return err
}
if !m.Legacy.Before.Present || !m.Legacy.After.Present {
return fmt.Errorf("legacy flag must remain present before and after migration")
}
if m.Legacy.Before.Hidden || !m.Legacy.After.Hidden {
return fmt.Errorf("legacy flag must migrate exactly from visible to hidden")
}
if !m.Canonical.After.Present {
return fmt.Errorf("canonical flag must be present after migration")
}
if m.Canonical.After.Hidden {
return fmt.Errorf("canonical flag must remain visible")
}
if !m.Canonical.After.Required {
return fmt.Errorf("canonical flag must be required after migration")
}
if m.Canonical.Before.Present && m.Canonical.Before.Required {
return fmt.Errorf("canonical flag must be absent or optional before migration")
}
if m.Legacy.After.AliasOf != m.Canonical.Name {
return fmt.Errorf(
"legacy flag after state must declare alias_of %q",
m.Canonical.Name,
)
}
if m.Legacy.Before.AliasOf != "" || m.Canonical.Before.AliasOf != "" || m.Canonical.After.AliasOf != "" {
return fmt.Errorf("alias_of is only valid on the legacy after state")
}
if m.Canonical.Before.Present {
if m.Canonical.Before.Type != m.Canonical.After.Type {
return fmt.Errorf("canonical flag type must remain unchanged")
}
if m.Canonical.Before.Shorthand != m.Canonical.After.Shorthand {
return fmt.Errorf("canonical flag shorthand must remain unchanged")
}
if m.Canonical.Before.NoOpt != m.Canonical.After.NoOpt {
return fmt.Errorf("canonical flag no_opt must remain unchanged")
}
if m.Canonical.Before.Scope != m.Canonical.After.Scope {
return fmt.Errorf("canonical flag scope must remain unchanged")
}
}
if m.Legacy.Before.Type != m.Legacy.After.Type || m.Legacy.After.Type != m.Canonical.After.Type {
return fmt.Errorf("legacy and canonical flag types must match exactly")
}
if m.Legacy.Before.Shorthand != m.Legacy.After.Shorthand {
return fmt.Errorf("legacy flag shorthand must remain unchanged")
}
if m.Legacy.Before.NoOpt != m.Legacy.After.NoOpt {
return fmt.Errorf("legacy flag no_opt must remain unchanged")
}
if m.Legacy.Before.Scope != m.Legacy.After.Scope {
return fmt.Errorf("legacy flag scope must remain unchanged")
}
return nil
}
func (s FlagMigrationState) validate(label string) error {
if !s.Present {
if s.Type != "" || s.Required || s.Hidden || s.Shorthand != "" || s.NoOpt != "" || s.Scope != "" || s.AliasOf != "" {
return fmt.Errorf("%s absent state must not declare flag attributes", label)
}
return nil
}
if strings.TrimSpace(s.Type) == "" {
return fmt.Errorf("%s present state requires type", label)
}
if s.Scope != "local" && s.Scope != "inherited" {
return fmt.Errorf("%s present state has invalid scope %q", label, s.Scope)
}
return nil
}
func (m FlagMigration) key() string {
return m.Command + "\x00" + m.Legacy.Name + "\x00" + m.Canonical.Name
}
func isExactCommandPath(path string) bool {
return path != "" &&
path == strings.TrimSpace(path) &&
strings.Join(strings.Fields(path), " ") == path &&
(path == "dws" || strings.HasPrefix(path, "dws ")) &&
!strings.ContainsAny(path, "*?[]{}")
}
func isExactFlagName(name string) bool {
return name != "" &&
name == strings.TrimSpace(name) &&
!strings.HasPrefix(name, "-") &&
!strings.ContainsAny(name, "*?[]{} /\t\r\n")
}
// CompareAllWithFlagMigrations applies the ordinary compatibility policy and
// then consumes only exact, merge-base-owned flag migrations. Candidate-owned
// records participate in the lifecycle check, but never authorize their own
// interface change.
func CompareAllWithFlagMigrations(
current Snapshot,
references map[string]Snapshot,
authority FlagMigrationManifest,
candidate FlagMigrationManifest,
) (Report, error) {
authorizations, err := AuthorizeFlagMigrations(current, references, authority, candidate)
if err != nil {
return Report{}, err
}
report := CompareAll(current, references)
if len(authorizations) == 0 {
return report, nil
}
for index := range report.Comparisons {
comparison := &report.Comparisons[index]
reference := references[comparison.Reference]
filtered := comparison.Blocking[:0]
for _, change := range comparison.Blocking {
if flagMigrationAuthorizesChange(current, reference, change, authorizations) {
continue
}
filtered = append(filtered, change)
}
comparison.Blocking = filtered
comparison.Compatible = len(filtered) == 0
}
report.Compatible = true
for _, comparison := range report.Comparisons {
if !comparison.Compatible {
report.Compatible = false
break
}
}
return report, nil
}
// AuthorizeFlagMigrations validates both snapshots and manifests, enforces the
// base-owned migration lifecycle, and returns only approvals that may authorize
// the current exact interface transition. A non-empty lifecycle requires both a
// main/merge-base authority reference and a stable reference. Candidate-added
// records never appear in the returned authorization set.
func AuthorizeFlagMigrations(
current Snapshot,
references map[string]Snapshot,
authority FlagMigrationManifest,
candidate FlagMigrationManifest,
) ([]FlagMigration, error) {
if err := current.Validate(); err != nil {
return nil, fmt.Errorf("validate current interface snapshot: %w", err)
}
for label, snapshot := range references {
if err := snapshot.Validate(); err != nil {
return nil, fmt.Errorf("validate %s interface snapshot: %w", label, err)
}
}
if err := authority.Validate(); err != nil {
return nil, fmt.Errorf("validate approved flag migrations: %w", err)
}
if err := candidate.Validate(); err != nil {
return nil, fmt.Errorf("validate candidate flag migrations: %w", err)
}
return evaluateFlagMigrationLifecycle(current, references, authority, candidate)
}
type flagMigrationPhase string
const (
flagMigrationBefore flagMigrationPhase = "before"
flagMigrationAfter flagMigrationPhase = "after"
flagMigrationPartial flagMigrationPhase = "partial"
)
func evaluateFlagMigrationLifecycle(
current Snapshot,
references map[string]Snapshot,
authority FlagMigrationManifest,
candidate FlagMigrationManifest,
) ([]FlagMigration, error) {
if len(authority.Migrations) == 0 && len(candidate.Migrations) == 0 {
return nil, nil
}
if _, ok := references["stable"]; !ok {
return nil, fmt.Errorf("flag migration lifecycle requires a stable reference")
}
mergeBase, label, ok := flagMigrationAuthoritySnapshot(references)
if !ok {
return nil, fmt.Errorf("flag migration lifecycle requires a main or merge-base reference")
}
authorityByKey := flagMigrationIndex(authority)
candidateByKey := flagMigrationIndex(candidate)
authorizations := make([]FlagMigration, 0, len(authority.Migrations))
for _, approved := range authority.Migrations {
basePhase := matchFlagMigrationPhase(mergeBase, approved)
wantBasePhase := flagMigrationBefore
if approved.State == FlagMigrationConsumed {
wantBasePhase = flagMigrationAfter
}
if basePhase != wantBasePhase {
return nil, fmt.Errorf(
"approved flag migration %s is %s in %s, want exact %s state for %s",
approved.displayKey(),
basePhase,
label,
wantBasePhase,
approved.State,
)
}
proposed, exists := candidateByKey[approved.key()]
if exists && !sameFlagMigrationApproval(approved, proposed) {
return nil, fmt.Errorf("candidate modified base-owned flag migration %s", approved.displayKey())
}
currentPhase := matchFlagMigrationPhase(current, approved)
switch approved.State {
case FlagMigrationPending:
if !exists {
return nil, fmt.Errorf("candidate removed pending flag migration %s", approved.displayKey())
}
switch currentPhase {
case flagMigrationBefore:
if proposed.State != FlagMigrationPending {
return nil, fmt.Errorf("candidate falsely consumed unchanged flag migration %s", approved.displayKey())
}
case flagMigrationAfter:
if proposed.State != FlagMigrationConsumed {
return nil, fmt.Errorf("candidate completed flag migration %s without marking it consumed", approved.displayKey())
}
authorizations = append(authorizations, approved)
default:
return nil, fmt.Errorf("candidate partially applied flag migration %s", approved.displayKey())
}
case FlagMigrationConsumed:
if currentPhase != flagMigrationAfter {
return nil, fmt.Errorf("candidate drifted from consumed flag migration %s", approved.displayKey())
}
allReferencesAfter := true
for _, reference := range references {
if matchFlagMigrationPhase(reference, approved) != flagMigrationAfter {
allReferencesAfter = false
break
}
}
if allReferencesAfter {
if exists {
return nil, fmt.Errorf("consumed flag migration %s is stale after all references reached the after state", approved.displayKey())
}
continue
}
if !exists {
return nil, fmt.Errorf("candidate removed consumed flag migration %s before every reference reached the after state", approved.displayKey())
}
if proposed.State != FlagMigrationConsumed {
return nil, fmt.Errorf("candidate changed consumed flag migration %s back to pending", approved.displayKey())
}
authorizations = append(authorizations, approved)
}
}
for _, proposed := range candidate.Migrations {
if _, exists := authorityByKey[proposed.key()]; exists {
continue
}
if proposed.State != FlagMigrationPending {
return nil, fmt.Errorf("candidate-added flag migration %s must start pending", proposed.displayKey())
}
if matchFlagMigrationPhase(mergeBase, proposed) != flagMigrationBefore {
return nil, fmt.Errorf("candidate-added flag migration %s does not match the merge-base before state", proposed.displayKey())
}
if matchFlagMigrationPhase(current, proposed) != flagMigrationBefore {
return nil, fmt.Errorf("candidate-added flag migration %s cannot authorize its own interface change", proposed.displayKey())
}
}
return authorizations, nil
}
func flagMigrationAuthoritySnapshot(references map[string]Snapshot) (Snapshot, string, bool) {
for _, label := range []string{"merge-base", "main"} {
if snapshot, ok := references[label]; ok {
return snapshot, label, true
}
}
return Snapshot{}, "", false
}
func flagMigrationIndex(manifest FlagMigrationManifest) map[string]FlagMigration {
index := make(map[string]FlagMigration, len(manifest.Migrations))
for _, migration := range manifest.Migrations {
index[migration.key()] = migration
}
return index
}
func sameFlagMigrationApproval(left, right FlagMigration) bool {
left.State = ""
right.State = ""
return reflect.DeepEqual(left, right)
}
func matchFlagMigrationPhase(snapshot Snapshot, migration FlagMigration) flagMigrationPhase {
command, exists := commandIndex(snapshot)[migration.Command]
if !exists {
return flagMigrationPartial
}
legacy := flagMigrationStateForCommand(command, migration.Legacy.Name)
canonical := flagMigrationStateForCommand(command, migration.Canonical.Name)
if legacy == migration.Legacy.Before && canonical == migration.Canonical.Before {
return flagMigrationBefore
}
if legacy == migration.Legacy.After && canonical == migration.Canonical.After {
return flagMigrationAfter
}
return flagMigrationPartial
}
func flagMigrationStateForCommand(command Command, name string) FlagMigrationState {
for _, flag := range command.LocalFlags {
if flag.Name == name {
return flagMigrationState(flag, "local")
}
}
for _, flag := range command.InheritedFlags {
if flag.Name == name {
return flagMigrationState(flag, "inherited")
}
}
return FlagMigrationState{}
}
func flagMigrationState(flag Flag, scope string) FlagMigrationState {
return FlagMigrationState{
Present: true,
Type: flag.Type,
Required: flag.Required,
Hidden: flag.Hidden,
Shorthand: flag.Shorthand,
NoOpt: flag.NoOpt,
Scope: scope,
AliasOf: flag.AliasOf,
}
}
func flagMigrationAuthorizesChange(
current Snapshot,
reference Snapshot,
change Change,
authorizations []FlagMigration,
) bool {
canonicalPath := acceptedPathIndex(reference)[change.Path]
if canonicalPath == "" {
canonicalPath = change.Path
}
for _, migration := range authorizations {
if canonicalPath != migration.Command ||
matchFlagMigrationPhase(reference, migration) != flagMigrationBefore ||
matchFlagMigrationPhase(current, migration) != flagMigrationAfter {
continue
}
if change.Flag == migration.Legacy.Name && change.Kind == "flag_became_hidden" {
return true
}
if change.Flag != migration.Canonical.Name {
continue
}
if !migration.Canonical.Before.Present &&
migration.Canonical.After.Required &&
change.Kind == "required_flag_added" {
return true
}
if migration.Canonical.Before.Present &&
!migration.Canonical.Before.Required &&
migration.Canonical.After.Required &&
change.Kind == "flag_became_required" {
return true
}
}
return false
}
func (m FlagMigration) displayKey() string {
return fmt.Sprintf("%q --%s -> --%s", m.Command, m.Legacy.Name, m.Canonical.Name)
}
@@ -0,0 +1,966 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package interfacesnapshot
import (
"encoding/json"
"reflect"
"strings"
"testing"
)
func TestCrossPlatformCoverageFlagMigrationStrictJSONSchemaErrors(t *testing.T) {
tests := []struct {
name string
input string
wantErr string
}{
{
name: "schema validator rejects a trailing value",
input: coverageManifestJSON() + ` {}`,
wantErr: "trailing JSON value",
},
{
name: "schema validator reports malformed trailing data",
input: coverageManifestJSON() + ` ?`,
wantErr: "read trailing flag migration manifest data",
},
{
name: "schema validator reports an empty document",
input: "",
wantErr: "read flag migration manifest value at $",
},
{
name: "schema validator rejects an unknown field",
input: `{"version":1,"migrations":[],"unexpected":true}`,
wantErr: `unknown field "unexpected"`,
},
{
name: "schema validator reports a malformed field token",
input: `{"`,
wantErr: "flag migration manifest",
},
{
name: "schema validator reports an unclosed object",
input: `{"version":1`,
wantErr: "close flag migration manifest object",
},
{
name: "schema validator reports an unclosed array",
input: `{"version":1,"migrations":[`,
wantErr: "close flag migration manifest array",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
err := validateFlagMigrationJSONSchema([]byte(test.input))
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("validateFlagMigrationJSONSchema() error = %v, want %q", err, test.wantErr)
}
})
}
}
func TestCrossPlatformCoverageFlagMigrationStrictJSONTypeDiagnostics(t *testing.T) {
tests := []struct {
name string
input string
schema reflect.Type
wantErr string
}{
{
name: "manifest must be an object",
input: `[]`,
schema: reflect.TypeOf(FlagMigrationManifest{}),
wantErr: "must be an object, got array",
},
{
name: "migrations must be an array",
input: `{}`,
schema: reflect.TypeOf([]FlagMigration{}),
wantErr: "must be an array, got object",
},
{
name: "integer rejects string",
input: `"one"`,
schema: reflect.TypeOf(int(0)),
wantErr: "must be a number, got string",
},
{
name: "string rejects number",
input: `1`,
schema: reflect.TypeOf(""),
wantErr: "must be a string, got number",
},
{
name: "string rejects boolean",
input: `false`,
schema: reflect.TypeOf(""),
wantErr: "must be a string, got boolean",
},
{
name: "unsupported schema fails closed",
input: `1`,
schema: reflect.TypeOf(float64(0)),
wantErr: "unsupported Go schema type float64",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
decoder := json.NewDecoder(strings.NewReader(test.input))
decoder.UseNumber()
err := validateMigrationJSONValue(decoder, "$", test.schema)
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("validateMigrationJSONValue() error = %v, want %q", err, test.wantErr)
}
})
}
}
func TestCrossPlatformCoverageFlagMigrationStrictJSONSchemaHelpers(t *testing.T) {
type fieldFixture struct {
DefaultName string
Ignored string `json:"-"`
Tagged string `json:"tagged,omitempty"`
}
fields := migrationJSONFields(reflect.TypeOf(fieldFixture{}))
if _, exists := fields["Ignored"]; exists {
t.Fatal("migrationJSONFields() retained a json:- field")
}
if fields["DefaultName"] != reflect.TypeOf("") || fields["tagged"] != reflect.TypeOf("") {
t.Fatalf("migrationJSONFields() = %#v, want default and tagged string fields", fields)
}
if got := migrationJSONKindDescription(reflect.TypeOf(float64(0))); got != "the declared JSON type" {
t.Fatalf("migrationJSONKindDescription(float64) = %q", got)
}
for _, test := range []struct {
name string
token json.Token
want string
}{
{name: "closing delimiter", token: json.Delim('}'), want: `delimiter "}"`},
{name: "unsupported token", token: struct{}{}, want: "struct {}"},
} {
t.Run(test.name, func(t *testing.T) {
if got := migrationJSONTokenDescription(test.token); got != test.want {
t.Fatalf("migrationJSONTokenDescription() = %q, want %q", got, test.want)
}
})
}
}
func TestCrossPlatformCoverageFlagMigrationManifestParserEdges(t *testing.T) {
tests := []struct {
name string
input string
wantErr string
}{
{
name: "initial malformed JSON",
input: `{`,
wantErr: "unexpected EOF",
},
{
name: "multiple JSON values",
input: coverageManifestJSON() + ` {}`,
wantErr: "trailing multiple JSON values",
},
{
name: "malformed trailing JSON",
input: coverageManifestJSON() + ` {`,
wantErr: "read trailing flag migration manifest data",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := ReadFlagMigrationManifest(strings.NewReader(test.input))
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("ReadFlagMigrationManifest() error = %v, want %q", err, test.wantErr)
}
})
}
}
func TestCrossPlatformCoverageFlagMigrationManifestRejectsEveryContractDrift(t *testing.T) {
optionalCanonical := func() FlagMigrationManifest {
manifest := coverageManifest(FlagMigrationPending)
manifest.Migrations[0].Canonical.Before = FlagMigrationState{
Present: true,
Type: "string",
Scope: "local",
}
return manifest
}
tests := []struct {
name string
make func() FlagMigrationManifest
mutate func(*FlagMigrationManifest)
wantErr string
}{
{
name: "canonical flag name is exact",
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.Name = "--message-id" },
wantErr: "canonical name must be an exact canonical flag",
},
{
name: "legacy and canonical names differ",
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.Name = m.Migrations[0].Legacy.Name },
wantErr: "legacy and canonical flags must differ",
},
{
name: "legacy before state validates",
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.Before.Scope = "global" },
wantErr: "legacy before present state has invalid scope",
},
{
name: "legacy after state validates",
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.After.Type = "" },
wantErr: "legacy after present state requires type",
},
{
name: "canonical before state validates",
make: optionalCanonical,
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.Before.Type = "" },
wantErr: "canonical before present state requires type",
},
{
name: "canonical after state validates",
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.After.Scope = "" },
wantErr: "canonical after present state has invalid scope",
},
{
name: "legacy before remains present",
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.Before = FlagMigrationState{} },
wantErr: "legacy flag must remain present before and after migration",
},
{
name: "legacy after remains present",
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.After = FlagMigrationState{} },
wantErr: "legacy flag must remain present before and after migration",
},
{
name: "legacy starts visible",
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.Before.Hidden = true },
wantErr: "legacy flag must migrate exactly from visible to hidden",
},
{
name: "legacy ends hidden",
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.After.Hidden = false },
wantErr: "legacy flag must migrate exactly from visible to hidden",
},
{
name: "canonical after remains present",
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.After = FlagMigrationState{} },
wantErr: "canonical flag must be present after migration",
},
{
name: "legacy after declares alias target",
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.After.AliasOf = "other-id" },
wantErr: `legacy flag after state must declare alias_of "message-id"`,
},
{
name: "legacy before cannot declare alias",
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.Before.AliasOf = "message-id" },
wantErr: "alias_of is only valid on the legacy after state",
},
{
name: "canonical before cannot declare alias",
make: optionalCanonical,
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.Before.AliasOf = "message-id" },
wantErr: "alias_of is only valid on the legacy after state",
},
{
name: "canonical after cannot declare alias",
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.After.AliasOf = "message-id" },
wantErr: "alias_of is only valid on the legacy after state",
},
{
name: "canonical type remains stable",
make: optionalCanonical,
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.Before.Type = "stringSlice" },
wantErr: "canonical flag type must remain unchanged",
},
{
name: "canonical shorthand remains stable",
make: optionalCanonical,
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.Before.Shorthand = "m" },
wantErr: "canonical flag shorthand must remain unchanged",
},
{
name: "canonical no-opt remains stable",
make: optionalCanonical,
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.Before.NoOpt = "auto" },
wantErr: "canonical flag no_opt must remain unchanged",
},
{
name: "canonical scope remains stable",
make: optionalCanonical,
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.Before.Scope = "inherited" },
wantErr: "canonical flag scope must remain unchanged",
},
{
name: "legacy before type matches legacy after",
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.Before.Type = "stringSlice" },
wantErr: "legacy and canonical flag types must match exactly",
},
{
name: "legacy after type matches canonical after",
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.After.Type = "stringSlice" },
wantErr: "legacy and canonical flag types must match exactly",
},
{
name: "legacy shorthand remains stable",
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.After.Shorthand = "x" },
wantErr: "legacy flag shorthand must remain unchanged",
},
{
name: "legacy no-opt remains stable",
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.After.NoOpt = "" },
wantErr: "legacy flag no_opt must remain unchanged",
},
{
name: "legacy scope remains stable",
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.After.Scope = "inherited" },
wantErr: "legacy flag scope must remain unchanged",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
manifest := test.make()
test.mutate(&manifest)
if err := manifest.Validate(); err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("Validate() error = %v, want %q", err, test.wantErr)
}
})
}
}
func TestCrossPlatformCoverageFlagMigrationManifestRejectsAbsentAttributesAndTargetConflicts(t *testing.T) {
stateMutations := []struct {
name string
mutate func(*FlagMigrationState)
}{
{name: "type", mutate: func(s *FlagMigrationState) { s.Type = "string" }},
{name: "required", mutate: func(s *FlagMigrationState) { s.Required = true }},
{name: "hidden", mutate: func(s *FlagMigrationState) { s.Hidden = true }},
{name: "shorthand", mutate: func(s *FlagMigrationState) { s.Shorthand = "m" }},
{name: "no-opt", mutate: func(s *FlagMigrationState) { s.NoOpt = "auto" }},
{name: "scope", mutate: func(s *FlagMigrationState) { s.Scope = "local" }},
{name: "alias", mutate: func(s *FlagMigrationState) { s.AliasOf = "message-id" }},
}
for _, test := range stateMutations {
t.Run("absent state rejects "+test.name, func(t *testing.T) {
manifest := coverageManifest(FlagMigrationPending)
test.mutate(&manifest.Migrations[0].Canonical.Before)
if err := manifest.Validate(); err == nil || !strings.Contains(err.Error(), "absent state must not declare flag attributes") {
t.Fatalf("Validate() error = %v, want absent-state rejection", err)
}
})
}
manifest := coverageManifest(FlagMigrationPending)
second := coverageManifest(FlagMigrationPending).Migrations[0]
second.Canonical.Name = "canonical-two"
second.Legacy.After.AliasOf = second.Canonical.Name
manifest.Migrations = append(manifest.Migrations, second)
if err := manifest.Validate(); err == nil || !strings.Contains(err.Error(), "to both --message-id and --canonical-two") {
t.Fatalf("Validate() error = %v, want one legacy to two canonical targets rejected", err)
}
}
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsValidatesEveryInput(t *testing.T) {
before := coverageMigrationSnapshot(coverageManifest(FlagMigrationPending).Migrations[0], false, false)
empty := coverageEmptyManifest()
tests := []struct {
name string
current Snapshot
references map[string]Snapshot
authority FlagMigrationManifest
candidate FlagMigrationManifest
wantErr string
}{
{
name: "current snapshot",
current: Snapshot{},
references: map[string]Snapshot{"merge-base": before},
authority: empty,
candidate: empty,
wantErr: "validate current interface snapshot",
},
{
name: "reference snapshot",
current: before,
references: map[string]Snapshot{"merge-base": {}},
authority: empty,
candidate: empty,
wantErr: "validate merge-base interface snapshot",
},
{
name: "authority manifest",
current: before,
references: map[string]Snapshot{"merge-base": before},
authority: FlagMigrationManifest{},
candidate: empty,
wantErr: "validate approved flag migrations",
},
{
name: "candidate manifest",
current: before,
references: map[string]Snapshot{"merge-base": before},
authority: empty,
candidate: FlagMigrationManifest{},
wantErr: "validate candidate flag migrations",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := CompareAllWithFlagMigrations(test.current, test.references, test.authority, test.candidate)
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("CompareAllWithFlagMigrations() error = %v, want %q", err, test.wantErr)
}
})
}
report, err := CompareAllWithFlagMigrations(before, map[string]Snapshot{"merge-base": before}, empty, empty)
if err != nil || !report.Compatible {
t.Fatalf("empty migration lifecycle = (%#v, %v), want compatible", report, err)
}
}
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsLifecycleErrors(t *testing.T) {
pending := coverageManifest(FlagMigrationPending)
consumed := coverageManifest(FlagMigrationConsumed)
empty := coverageEmptyManifest()
migration := pending.Migrations[0]
before := coverageMigrationSnapshot(migration, false, false)
after := coverageMigrationSnapshot(migration, true, false)
partial := coverageMigrationSnapshot(migration, false, false)
partial.Commands[len(partial.Commands)-1].LocalFlags[0].Hidden = true
modified := coverageManifest(FlagMigrationPending)
modified.Migrations[0].Reason = "A different reviewed reason."
tests := []struct {
name string
current Snapshot
references map[string]Snapshot
authority FlagMigrationManifest
candidate FlagMigrationManifest
wantErr string
}{
{
name: "requires merge-base or main",
current: before,
references: map[string]Snapshot{"stable": before},
authority: pending,
candidate: pending,
wantErr: "requires a main or merge-base reference",
},
{
name: "pending authority requires before base",
current: after,
references: map[string]Snapshot{"merge-base": after, "stable": after},
authority: pending,
candidate: pending,
wantErr: "want exact before state for pending",
},
{
name: "consumed authority requires after base",
current: before,
references: map[string]Snapshot{"merge-base": before, "stable": before},
authority: consumed,
candidate: consumed,
wantErr: "want exact after state for consumed",
},
{
name: "missing base command is partial",
current: before,
references: map[string]Snapshot{"merge-base": coverageRootOnlySnapshot(), "stable": before},
authority: pending,
candidate: pending,
wantErr: "is partial in merge-base",
},
{
name: "candidate cannot modify base receipt",
current: before,
references: map[string]Snapshot{"merge-base": before, "stable": before},
authority: pending,
candidate: modified,
wantErr: "candidate modified base-owned flag migration",
},
{
name: "candidate cannot remove pending receipt",
current: before,
references: map[string]Snapshot{"merge-base": before, "stable": before},
authority: pending,
candidate: empty,
wantErr: "candidate removed pending flag migration",
},
{
name: "candidate cannot falsely consume pending receipt",
current: before,
references: map[string]Snapshot{"merge-base": before, "stable": before},
authority: pending,
candidate: consumed,
wantErr: "candidate falsely consumed unchanged flag migration",
},
{
name: "candidate must consume completed receipt",
current: after,
references: map[string]Snapshot{"merge-base": before, "stable": before},
authority: pending,
candidate: pending,
wantErr: "without marking it consumed",
},
{
name: "candidate rejects partial application",
current: partial,
references: map[string]Snapshot{"merge-base": before, "stable": before},
authority: pending,
candidate: consumed,
wantErr: "candidate partially applied flag migration",
},
{
name: "consumed receipt rejects current revert",
current: before,
references: map[string]Snapshot{"merge-base": after, "stable": before},
authority: consumed,
candidate: consumed,
wantErr: "candidate drifted from consumed flag migration",
},
{
name: "consumed receipt cannot be removed early",
current: after,
references: map[string]Snapshot{"merge-base": after, "stable": before},
authority: consumed,
candidate: empty,
wantErr: "before every reference reached the after state",
},
{
name: "consumed receipt cannot revert to pending",
current: after,
references: map[string]Snapshot{"merge-base": after, "stable": before},
authority: consumed,
candidate: pending,
wantErr: "back to pending",
},
{
name: "consumed receipt becomes stale",
current: after,
references: map[string]Snapshot{"merge-base": after, "stable": after},
authority: consumed,
candidate: consumed,
wantErr: "is stale after all references reached the after state",
},
{
name: "candidate-added receipt starts pending",
current: before,
references: map[string]Snapshot{"merge-base": before, "stable": before},
authority: empty,
candidate: consumed,
wantErr: "must start pending",
},
{
name: "candidate-added receipt matches base before",
current: before,
references: map[string]Snapshot{"merge-base": after, "stable": after},
authority: empty,
candidate: pending,
wantErr: "does not match the merge-base before state",
},
{
name: "candidate-added receipt cannot self-authorize",
current: after,
references: map[string]Snapshot{"merge-base": before, "stable": before},
authority: empty,
candidate: pending,
wantErr: "cannot authorize its own interface change",
},
{
name: "missing current command is partial",
current: coverageRootOnlySnapshot(),
references: map[string]Snapshot{"merge-base": before, "stable": before},
authority: pending,
candidate: pending,
wantErr: "candidate partially applied flag migration",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := CompareAllWithFlagMigrations(test.current, test.references, test.authority, test.candidate)
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("CompareAllWithFlagMigrations() error = %v, want %q", err, test.wantErr)
}
})
}
}
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsMainAuthorityAndCleanup(t *testing.T) {
pending := coverageManifest(FlagMigrationPending)
consumed := coverageManifest(FlagMigrationConsumed)
empty := coverageEmptyManifest()
migration := pending.Migrations[0]
before := coverageMigrationSnapshot(migration, false, false)
after := coverageMigrationSnapshot(migration, true, false)
report, err := CompareAllWithFlagMigrations(
after,
map[string]Snapshot{"main": before, "stable": before},
pending,
consumed,
)
if err != nil || !report.Compatible {
t.Fatalf("main-owned migration = (%#v, %v), want compatible", report, err)
}
report, err = CompareAllWithFlagMigrations(
after,
map[string]Snapshot{"merge-base": before, "main": after, "stable": before},
pending,
consumed,
)
if err != nil || !report.Compatible {
t.Fatalf("merge-base precedence = (%#v, %v), want compatible", report, err)
}
report, err = CompareAllWithFlagMigrations(
after,
map[string]Snapshot{"merge-base": after, "stable": after},
consumed,
empty,
)
if err != nil || !report.Compatible {
t.Fatalf("consumed receipt cleanup = (%#v, %v), want compatible", report, err)
}
report, err = CompareAllWithFlagMigrations(
before,
map[string]Snapshot{"merge-base": before, "stable": before},
empty,
pending,
)
if err != nil || !report.Compatible {
t.Fatalf("pending receipt creation = (%#v, %v), want compatible", report, err)
}
}
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsRequiresStableForReceiptCleanup(t *testing.T) {
consumed := coverageManifest(FlagMigrationConsumed)
empty := coverageEmptyManifest()
after := coverageMigrationSnapshot(consumed.Migrations[0], true, false)
report, err := CompareAllWithFlagMigrations(
after,
map[string]Snapshot{"merge-base": after},
consumed,
empty,
)
if err == nil || !strings.Contains(err.Error(), "requires a stable reference") {
t.Fatalf("receipt cleanup without stable = (%#v, %v), want stable-reference error", report, err)
}
}
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsInheritedAndOptionalCanonical(t *testing.T) {
t.Run("inherited exact state", func(t *testing.T) {
pending := coverageManifest(FlagMigrationPending)
pending.Migrations[0].Legacy.Before.Scope = "inherited"
pending.Migrations[0].Legacy.After.Scope = "inherited"
pending.Migrations[0].Canonical.After.Scope = "inherited"
consumed := pending
consumed.Migrations = append([]FlagMigration(nil), pending.Migrations...)
consumed.Migrations[0].State = FlagMigrationConsumed
before := coverageMigrationSnapshot(pending.Migrations[0], false, false)
after := coverageMigrationSnapshot(pending.Migrations[0], true, false)
report, err := CompareAllWithFlagMigrations(
after,
map[string]Snapshot{"merge-base": before, "stable": before},
pending,
consumed,
)
if err != nil || !report.Compatible {
t.Fatalf("inherited migration = (%#v, %v), want compatible", report, err)
}
})
t.Run("existing optional canonical becomes required", func(t *testing.T) {
pending := coverageManifest(FlagMigrationPending)
pending.Migrations[0].Canonical.Before = FlagMigrationState{
Present: true,
Type: "string",
Scope: "local",
}
consumed := pending
consumed.Migrations = append([]FlagMigration(nil), pending.Migrations...)
consumed.Migrations[0].State = FlagMigrationConsumed
before := coverageMigrationSnapshot(pending.Migrations[0], false, false)
after := coverageMigrationSnapshot(pending.Migrations[0], true, false)
ordinary := Compare(after, before, "merge-base")
if !hasFlagChange(ordinary.Blocking, "flag_became_required", pending.Migrations[0].Command, pending.Migrations[0].Canonical.Name) {
t.Fatalf("fixture did not create flag_became_required: %#v", ordinary.Blocking)
}
report, err := CompareAllWithFlagMigrations(
after,
map[string]Snapshot{"merge-base": before, "stable": before},
pending,
consumed,
)
if err != nil || !report.Compatible {
t.Fatalf("optional-to-required migration = (%#v, %v), want compatible", report, err)
}
})
}
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsNormalizesAliasesAndKeepsOtherBreakage(t *testing.T) {
pending := coverageManifest(FlagMigrationPending)
consumed := coverageManifest(FlagMigrationConsumed)
migration := pending.Migrations[0]
before := coverageMigrationSnapshot(migration, false, true)
after := coverageMigrationSnapshot(migration, true, true)
ordinary := Compare(after, before, "merge-base")
if !hasFlagChange(ordinary.Blocking, "flag_became_hidden", "dws chat deliver", migration.Legacy.Name) {
t.Fatalf("alias fixture did not create alias-path finding: %#v", ordinary.Blocking)
}
report, err := CompareAllWithFlagMigrations(
after,
map[string]Snapshot{"merge-base": before, "stable": before},
pending,
consumed,
)
if err != nil || !report.Compatible {
t.Fatalf("alias-path migration = (%#v, %v), want compatible", report, err)
}
beforeWithOther := before
afterWithOther := after
beforeWithOther.Commands = append(beforeWithOther.Commands, coverageCommand("dws files", nil, []Flag{{Name: "format", Type: "string"}}, nil))
afterWithOther.Commands = append(afterWithOther.Commands, coverageCommand("dws files", nil, nil, nil))
report, err = CompareAllWithFlagMigrations(
afterWithOther,
map[string]Snapshot{"merge-base": beforeWithOther, "stable": beforeWithOther},
pending,
consumed,
)
if err != nil {
t.Fatalf("unrelated breakage returned lifecycle error: %v", err)
}
if report.Compatible || !hasFlagChange(report.Comparisons[0].Blocking, "flag_removed", "dws files", "format") {
t.Fatalf("unrelated breakage was hidden: %#v", report)
}
}
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsDoesNotAuthorizePartialReferenceOrRuleDrift(t *testing.T) {
pending := coverageManifest(FlagMigrationPending)
consumed := coverageManifest(FlagMigrationConsumed)
migration := pending.Migrations[0]
before := coverageMigrationSnapshot(migration, false, false)
after := coverageMigrationSnapshot(migration, true, false)
partialReference := coverageMigrationSnapshot(migration, false, false)
partialReference.Commands[len(partialReference.Commands)-1].LocalFlags[0].Hidden = true
report, err := CompareAllWithFlagMigrations(
after,
map[string]Snapshot{"merge-base": before, "stable": partialReference},
pending,
consumed,
)
if err != nil {
t.Fatalf("partial non-authority reference returned lifecycle error: %v", err)
}
if report.Compatible {
t.Fatalf("partial reference was incorrectly authorized: %#v", report)
}
beforeWithoutRoot := coverageMigrationSnapshot(migration, false, false)
afterWithoutRoot := coverageMigrationSnapshot(migration, true, false)
beforeWithoutRoot.Commands = beforeWithoutRoot.Commands[len(beforeWithoutRoot.Commands)-1:]
afterWithoutRoot.Commands = afterWithoutRoot.Commands[len(afterWithoutRoot.Commands)-1:]
afterWithoutRoot.Rules.ExcludedFlags = []string{"help", "version"}
report, err = CompareAllWithFlagMigrations(
afterWithoutRoot,
map[string]Snapshot{"merge-base": beforeWithoutRoot, "stable": beforeWithoutRoot},
pending,
consumed,
)
if err != nil {
t.Fatalf("rule drift returned lifecycle error: %v", err)
}
if report.Compatible || !hasChangeKind(report.Comparisons[0].Blocking, "snapshot_rules_changed") {
t.Fatalf("snapshot rule drift was incorrectly authorized: %#v", report)
}
}
func coverageManifestJSON() string {
return `{
"version": 1,
"migrations": [
{
"command": "dws chat send",
"legacy": {
"name": "legacy-id",
"before": {"present": true, "type": "string", "required": true, "shorthand": "l", "no_opt": "auto", "scope": "local"},
"after": {"present": true, "type": "string", "hidden": true, "shorthand": "l", "no_opt": "auto", "scope": "local", "alias_of": "message-id"}
},
"canonical": {
"name": "message-id",
"before": {"present": false},
"after": {"present": true, "type": "string", "required": true, "scope": "local"}
},
"state": "pending",
"reason": "Reviewed exact flag migration."
}
]
}`
}
func coverageManifest(state string) FlagMigrationManifest {
return FlagMigrationManifest{
Version: FlagMigrationManifestVersion,
Migrations: []FlagMigration{
{
Command: "dws chat send",
Legacy: FlagMigrationSide{
Name: "legacy-id",
Before: FlagMigrationState{
Present: true,
Type: "string",
Required: true,
Shorthand: "l",
NoOpt: "auto",
Scope: "local",
},
After: FlagMigrationState{
Present: true,
Type: "string",
Hidden: true,
Shorthand: "l",
NoOpt: "auto",
Scope: "local",
AliasOf: "message-id",
},
},
Canonical: FlagMigrationSide{
Name: "message-id",
Before: FlagMigrationState{},
After: FlagMigrationState{
Present: true,
Type: "string",
Required: true,
Scope: "local",
},
},
State: state,
Reason: "Reviewed exact flag migration.",
},
},
}
}
func coverageEmptyManifest() FlagMigrationManifest {
return FlagMigrationManifest{Version: FlagMigrationManifestVersion, Migrations: []FlagMigration{}}
}
func coverageMigrationSnapshot(migration FlagMigration, after, commandAlias bool) Snapshot {
legacyState := migration.Legacy.Before
canonicalState := migration.Canonical.Before
if after {
legacyState = migration.Legacy.After
canonicalState = migration.Canonical.After
}
local := []Flag{}
inherited := []Flag{}
add := func(name string, state FlagMigrationState) {
if !state.Present {
return
}
flag := Flag{
Name: name,
Shorthand: state.Shorthand,
Type: state.Type,
NoOpt: state.NoOpt,
Required: state.Required,
Hidden: state.Hidden,
AliasOf: state.AliasOf,
}
if state.Scope == "inherited" {
inherited = append(inherited, flag)
return
}
local = append(local, flag)
}
add(migration.Legacy.Name, legacyState)
add(migration.Canonical.Name, canonicalState)
aliases := []string{}
if commandAlias {
aliases = []string{"deliver"}
}
return coverageSnapshot(
coverageCommand("dws", nil, nil, nil),
coverageCommand("dws chat", nil, nil, nil),
coverageCommand(migration.Command, aliases, local, inherited),
)
}
func coverageRootOnlySnapshot() Snapshot {
return coverageSnapshot(coverageCommand("dws", nil, nil, nil))
}
func coverageSnapshot(commands ...Command) Snapshot {
return Snapshot{
SchemaVersion: SchemaVersion,
Rules: Rules{
ExcludedCommandSubtrees: append([]string(nil), excludedCommandSubtrees...),
ExcludedFlags: []string{"help"},
},
Commands: commands,
}
}
func coverageCommand(path string, aliases []string, local, inherited []Flag) Command {
if aliases == nil {
aliases = []string{}
}
if local == nil {
local = []Flag{}
}
if inherited == nil {
inherited = []Flag{}
}
return Command{
Path: path,
Runnable: true,
Aliases: aliases,
LocalFlags: local,
InheritedFlags: inherited,
}
}
@@ -0,0 +1,252 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package interfacesnapshot
import (
"errors"
"reflect"
"strings"
"testing"
)
type flagMigrationErrorReader struct{}
func (flagMigrationErrorReader) Read([]byte) (int, error) {
return 0, errors.New("injected read failure")
}
const validFlagMigrationManifestJSON = `{
"version": 1,
"migrations": [
{
"command": "dws chat message recall",
"legacy": {
"name": "msg-id",
"before": {"present": true, "type": "string", "required": true, "scope": "local"},
"after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "message-id"}
},
"canonical": {
"name": "message-id",
"before": {"present": false},
"after": {"present": true, "type": "string", "required": true, "scope": "local"}
},
"state": "pending",
"reason": "Reviewed message identifier flag migration."
}
]
}`
func TestCrossPlatformCoverageReadFlagMigrationManifestRejectsUnknownFields(t *testing.T) {
_, err := ReadFlagMigrationManifest(strings.NewReader(`{
"version": 1,
"migrations": [],
"allow_all_chat_flags": true
}`))
if err == nil || !strings.Contains(err.Error(), "unknown field") {
t.Fatalf("ReadFlagMigrationManifest() error = %v, want unknown field rejection", err)
}
}
func TestCrossPlatformCoverageReadFlagMigrationManifestRejectsDuplicateFields(t *testing.T) {
for _, input := range []string{
`{"version": 999, "version": 1, "migrations": []}`,
strings.Replace(validFlagMigrationManifestJSON, `"state": "pending"`, `"state": "consumed", "state": "pending"`, 1),
} {
_, err := ReadFlagMigrationManifest(strings.NewReader(input))
if err == nil || !strings.Contains(err.Error(), "duplicate field") {
t.Fatalf("ReadFlagMigrationManifest() error = %v, want duplicate field rejection", err)
}
}
}
func TestCrossPlatformCoverageReadFlagMigrationManifestRejectsNonCanonicalFields(t *testing.T) {
for _, input := range []string{
`{"Version": 1, "migrations": []}`,
`{"version": 1, "Version": 1, "migrations": []}`,
strings.Replace(validFlagMigrationManifestJSON, `"state": "pending"`, `"State": "pending"`, 1),
} {
_, err := ReadFlagMigrationManifest(strings.NewReader(input))
if err == nil || !strings.Contains(err.Error(), "non-canonical field") {
t.Fatalf("ReadFlagMigrationManifest() error = %v, want non-canonical field rejection", err)
}
}
}
func TestCrossPlatformCoverageReadFlagMigrationManifestRejectsNullScalars(t *testing.T) {
for _, input := range []string{
strings.Replace(validFlagMigrationManifestJSON, `"required": true`, `"required": null`, 1),
strings.Replace(validFlagMigrationManifestJSON, `"scope": "local"`, `"scope": "local", "shorthand": null`, 1),
`{"version": null, "migrations": []}`,
} {
_, err := ReadFlagMigrationManifest(strings.NewReader(input))
if err == nil || !strings.Contains(err.Error(), "must be") || !strings.Contains(err.Error(), "null") {
t.Fatalf("ReadFlagMigrationManifest() error = %v, want scalar null rejection", err)
}
}
}
func TestCrossPlatformCoverageReadFlagMigrationManifestRejectsWrongJSONTypes(t *testing.T) {
for _, input := range []string{
strings.Replace(validFlagMigrationManifestJSON, `"required": true`, `"required": "true"`, 1),
strings.Replace(validFlagMigrationManifestJSON, `"scope": "local"`, `"scope": false`, 1),
`{"version": "1", "migrations": []}`,
`{"version": 1, "migrations": {}}`,
} {
if _, err := ReadFlagMigrationManifest(strings.NewReader(input)); err == nil {
t.Fatal("ReadFlagMigrationManifest() accepted a field with the wrong JSON type")
}
}
}
func TestCrossPlatformCoverageReadFlagMigrationManifestReportsReaderFailure(t *testing.T) {
_, err := ReadFlagMigrationManifest(flagMigrationErrorReader{})
if err == nil || !strings.Contains(err.Error(), "injected read failure") {
t.Fatalf("ReadFlagMigrationManifest() error = %v, want reader failure", err)
}
}
func TestCrossPlatformCoverageReadFlagMigrationManifestValidatesExactEntries(t *testing.T) {
if _, err := ReadFlagMigrationManifest(strings.NewReader(validFlagMigrationManifestJSON)); err != nil {
t.Fatalf("ReadFlagMigrationManifest(valid) error = %v", err)
}
tests := []struct {
name string
input string
wantErr string
}{
{
name: "unsupported version",
input: strings.Replace(validFlagMigrationManifestJSON, `"version": 1`, `"version": 2`, 1),
wantErr: "unsupported flag migration manifest version",
},
{
name: "missing migrations array",
input: `{"version": 1}`,
wantErr: "migrations must be an array",
},
{
name: "null migrations array",
input: `{"version": 1, "migrations": null}`,
wantErr: "migrations must be an array",
},
{
name: "wildcard command",
input: strings.Replace(validFlagMigrationManifestJSON, "dws chat message recall", "dws chat *", 1),
wantErr: "exact command path",
},
{
name: "wildcard flag",
input: strings.Replace(validFlagMigrationManifestJSON, `"name": "msg-id"`, `"name": "msg-*"`, 1),
wantErr: "exact legacy flag",
},
{
name: "empty reason",
input: strings.Replace(validFlagMigrationManifestJSON, "Reviewed message identifier flag migration.", "", 1),
wantErr: "non-empty reason",
},
{
name: "invalid state",
input: strings.Replace(validFlagMigrationManifestJSON, `"state": "pending"`, `"state": "approved"`, 1),
wantErr: "invalid state",
},
{
name: "legacy removed",
input: strings.Replace(validFlagMigrationManifestJSON, `"after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "message-id"}`, `"after": {"present": false}`, 1),
wantErr: "legacy flag must remain present",
},
{
name: "canonical hidden",
input: strings.Replace(validFlagMigrationManifestJSON, `"after": {"present": true, "type": "string", "required": true, "scope": "local"}`, `"after": {"present": true, "type": "string", "required": true, "hidden": true, "scope": "local"}`, 1),
wantErr: "canonical flag must remain visible",
},
{
name: "canonical remains optional",
input: strings.Replace(
validFlagMigrationManifestJSON,
`"after": {"present": true, "type": "string", "required": true, "scope": "local"}`,
`"after": {"present": true, "type": "string", "scope": "local"}`,
1,
),
wantErr: "canonical flag must be required after migration",
},
{
name: "canonical was already required",
input: strings.Replace(
validFlagMigrationManifestJSON,
`"before": {"present": false}`,
`"before": {"present": true, "type": "string", "required": true, "scope": "local"}`,
1,
),
wantErr: "canonical flag must be absent or optional before migration",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := ReadFlagMigrationManifest(strings.NewReader(test.input))
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("ReadFlagMigrationManifest() error = %v, want %q", err, test.wantErr)
}
})
}
}
func TestCrossPlatformCoverageFlagMigrationManifestRejectsDuplicateAndInexactContracts(t *testing.T) {
manifest, err := ReadFlagMigrationManifest(strings.NewReader(validFlagMigrationManifestJSON))
if err != nil {
t.Fatal(err)
}
duplicate := manifest
duplicate.Migrations = append(duplicate.Migrations, duplicate.Migrations[0])
if err := duplicate.Validate(); err == nil || !strings.Contains(err.Error(), "duplicates") {
t.Fatalf("duplicate migration error = %v", err)
}
whitespaceReason := manifest
whitespaceReason.Migrations = append([]FlagMigration(nil), manifest.Migrations...)
whitespaceReason.Migrations[0].Reason = " reviewed "
if err := whitespaceReason.Validate(); err == nil || !strings.Contains(err.Error(), "trimmed") {
t.Fatalf("whitespace reason error = %v", err)
}
canonicalDrift := manifest
canonicalDrift.Migrations = append([]FlagMigration(nil), manifest.Migrations...)
canonicalDrift.Migrations[0].Canonical.Before = FlagMigrationState{
Present: true,
Type: "string",
Scope: "local",
}
canonicalDrift.Migrations[0].Canonical.After.Type = "stringSlice"
if err := canonicalDrift.Validate(); err == nil || !strings.Contains(err.Error(), "canonical flag type") {
t.Fatalf("canonical type drift error = %v", err)
}
_, err = ReadFlagMigrationManifest(strings.NewReader(validFlagMigrationManifestJSON + ` {}`))
if err == nil || !strings.Contains(err.Error(), "trailing") {
t.Fatalf("trailing JSON error = %v", err)
}
}
func TestCrossPlatformCoverageAuthorizeFlagMigrationsReturnsExactBaseOwnedApproval(t *testing.T) {
pending := coverageManifest(FlagMigrationPending)
consumed := coverageManifest(FlagMigrationConsumed)
migration := pending.Migrations[0]
before := coverageMigrationSnapshot(migration, false, false)
after := coverageMigrationSnapshot(migration, true, false)
got, err := AuthorizeFlagMigrations(
after,
map[string]Snapshot{"merge-base": before, "stable": before},
pending,
consumed,
)
if err != nil {
t.Fatalf("AuthorizeFlagMigrations() error = %v", err)
}
if !reflect.DeepEqual(got, pending.Migrations) {
t.Fatalf("AuthorizeFlagMigrations() = %#v, want exact base-owned approval %#v", got, pending.Migrations)
}
}
+3
View File
@@ -117,5 +117,8 @@ func flagContractOtherwiseChanged(oldFlag, newFlag Flag) bool {
if !oldFlag.Hidden && newFlag.Hidden {
return true
}
if oldFlag.AliasOf != "" && newFlag.AliasOf != oldFlag.AliasOf {
return true
}
return false
}
+118 -9
View File
@@ -22,11 +22,16 @@ import (
"sort"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
const SchemaVersion = 1
const SchemaVersion = 2
// FlagAliasOfAnnotation records a framework-originated reviewed relationship
// between a retained compatibility flag and an exact canonical Cobra flag.
const FlagAliasOfAnnotation = runtimeannotate.AnnotationFlagAliasOf
var (
// Framework-owned command subtrees are deliberately excluded. They are
@@ -80,6 +85,12 @@ type Flag struct {
Required bool `json:"required"`
Hidden bool `json:"hidden"`
Deprecated string `json:"deprecated,omitempty"`
AliasOf string `json:"alias_of,omitempty"`
// Capture cannot return an error, so it preserves malformed or non-framework
// relation evidence here for Validate/Write to reject. Valid snapshots never
// serialize this implementation detail.
malformedAliasEvidence bool
}
// Capture walks root without rendering help or executing any command. Both
@@ -193,6 +204,9 @@ func (s Snapshot) Validate() error {
if err := validateFlags(command.Path, "inherited", command.InheritedFlags); err != nil {
return err
}
if err := validateFlagAliases(command); err != nil {
return err
}
}
return nil
}
@@ -206,21 +220,46 @@ func captureFlags(set *pflag.FlagSet) []Flag {
if flag == nil || excludedFlags[flag.Name] {
return
}
aliasOf, malformedAliasEvidence := captureFlagAliasOf(flag)
flags = append(flags, Flag{
Name: flag.Name,
Shorthand: flag.Shorthand,
Type: flag.Value.Type(),
Default: flag.DefValue,
NoOpt: flag.NoOptDefVal,
Required: isRequired(flag),
Hidden: flag.Hidden,
Deprecated: strings.TrimSpace(flag.Deprecated),
Name: flag.Name,
Shorthand: flag.Shorthand,
Type: flag.Value.Type(),
Default: flag.DefValue,
NoOpt: flag.NoOptDefVal,
Required: isRequired(flag),
Hidden: flag.Hidden,
Deprecated: strings.TrimSpace(flag.Deprecated),
AliasOf: aliasOf,
malformedAliasEvidence: malformedAliasEvidence,
})
})
sort.Slice(flags, func(i, j int) bool { return flags[i].Name < flags[j].Name })
return flags
}
func captureFlagAliasOf(flag *pflag.Flag) (string, bool) {
aliasOf, aliasMalformed := exactFlagAnnotation(flag, FlagAliasOfAnnotation)
origin, originMalformed := exactFlagAnnotation(flag, runtimeannotate.AnnotationFlagAliasOrigin)
if aliasOf == "" && origin == "" && !aliasMalformed && !originMalformed {
return "", false
}
malformed := aliasMalformed || originMalformed || aliasOf == "" ||
origin != runtimeannotate.FlagAliasOriginCorecmdV1
return aliasOf, malformed
}
func exactFlagAnnotation(flag *pflag.Flag, key string) (string, bool) {
values, exists := flag.Annotations[key]
if !exists {
return "", false
}
if len(values) != 1 || values[0] == "" || values[0] != strings.TrimSpace(values[0]) {
return "", true
}
return values[0], false
}
func isRequired(flag *pflag.Flag) bool {
for _, value := range flag.Annotations[cobra.BashCompOneRequiredFlag] {
if value == "true" {
@@ -268,3 +307,73 @@ func validateFlags(path, scope string, flags []Flag) error {
}
return nil
}
func validateFlagAliases(command Command) error {
flags := make(map[string]Flag, len(command.LocalFlags)+len(command.InheritedFlags))
ordered := make([]Flag, 0, len(command.LocalFlags)+len(command.InheritedFlags))
for _, flag := range command.LocalFlags {
flags[flag.Name] = flag
ordered = append(ordered, flag)
}
for _, flag := range command.InheritedFlags {
if _, exists := flags[flag.Name]; exists {
return fmt.Errorf("command %q contains flag %q in both local and inherited scopes", command.Path, flag.Name)
}
flags[flag.Name] = flag
ordered = append(ordered, flag)
}
for _, flag := range ordered {
if flag.malformedAliasEvidence {
return fmt.Errorf(
"command %q flag --%s must declare one exact %q value and framework origin %q",
command.Path,
flag.Name,
FlagAliasOfAnnotation,
runtimeannotate.FlagAliasOriginCorecmdV1,
)
}
if flag.AliasOf == "" {
continue
}
if !isExactFlagName(flag.AliasOf) {
return fmt.Errorf(
"command %q flag --%s has non-exact alias_of target %q",
command.Path,
flag.Name,
flag.AliasOf,
)
}
if flag.AliasOf == flag.Name {
return fmt.Errorf("command %q flag --%s cannot alias itself", command.Path, flag.Name)
}
target, exists := flags[flag.AliasOf]
if !exists {
return fmt.Errorf(
"command %q flag --%s aliases missing flag --%s",
command.Path,
flag.Name,
flag.AliasOf,
)
}
if target.AliasOf != "" {
return fmt.Errorf(
"command %q flag --%s aliases --%s, which is itself an alias",
command.Path,
flag.Name,
flag.AliasOf,
)
}
if flag.Type != target.Type {
return fmt.Errorf(
"command %q alias flags --%s and --%s have different types %q and %q",
command.Path,
flag.Name,
flag.AliasOf,
flag.Type,
target.Type,
)
}
}
return nil
}
+164 -1
View File
@@ -15,9 +15,12 @@ package interfacesnapshot
import (
"bytes"
"encoding/json"
"reflect"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
"github.com/spf13/cobra"
)
@@ -95,7 +98,7 @@ func TestCrossPlatformCoverageCaptureUsesStableNoiseRulesAndFlagScopes(t *testin
func TestCrossPlatformCoverageReadRejectsUnknownSnapshotFields(t *testing.T) {
input := bytes.NewBufferString(`{
"schema_version": 1,
"schema_version": 2,
"rules": {"excluded_command_subtrees": [], "excluded_flags": []},
"commands": [],
"future_field": true
@@ -105,6 +108,166 @@ func TestCrossPlatformCoverageReadRejectsUnknownSnapshotFields(t *testing.T) {
}
}
func TestCrossPlatformCoverageCaptureRoundTripsFrameworkFlagAlias(t *testing.T) {
if FlagAliasOfAnnotation != runtimeannotate.AnnotationFlagAliasOf {
t.Fatalf(
"flag alias annotation key drifted: snapshot=%q runtime=%q",
FlagAliasOfAnnotation,
runtimeannotate.AnnotationFlagAliasOf,
)
}
root := &cobra.Command{Use: "dws"}
leaf := &cobra.Command{Use: "send", Run: func(*cobra.Command, []string) {}}
corecmd.RegisterFlags(leaf, []corecmd.FlagSpec{{
Name: "message-id",
Usage: "message ID",
Aliases: []string{"open-conversation-id"},
}})
root.AddCommand(leaf)
snapshot := Capture(root)
flag := flagIndex(commandIndex(snapshot)["dws send"].LocalFlags)["open-conversation-id"]
if flag.AliasOf != "message-id" {
t.Fatalf("captured alias_of = %q, want message-id", flag.AliasOf)
}
var encoded bytes.Buffer
if err := Write(&encoded, snapshot); err != nil {
t.Fatalf("Write: %v", err)
}
decoded, err := Read(bytes.NewReader(encoded.Bytes()))
if err != nil {
t.Fatalf("Read: %v", err)
}
decodedFlag := flagIndex(commandIndex(decoded)["dws send"].LocalFlags)["open-conversation-id"]
if decodedFlag.AliasOf != "message-id" {
t.Fatalf("round-tripped alias_of = %q, want message-id", decodedFlag.AliasOf)
}
}
func TestCrossPlatformCoverageCaptureRejectsUntrustedFlagAliasEvidence(t *testing.T) {
for _, origin := range []string{"", "handwritten.alias.v1"} {
t.Run(origin, func(t *testing.T) {
root := &cobra.Command{Use: "dws"}
leaf := &cobra.Command{Use: "send", Run: func(*cobra.Command, []string) {}}
leaf.Flags().String("message-id", "", "canonical message ID")
leaf.Flags().String("legacy-id", "", "legacy message ID")
legacy := leaf.Flags().Lookup("legacy-id")
legacy.Annotations = map[string][]string{
FlagAliasOfAnnotation: {"message-id"},
}
if origin != "" {
legacy.Annotations[runtimeannotate.AnnotationFlagAliasOrigin] = []string{origin}
}
root.AddCommand(leaf)
if err := Capture(root).Validate(); err == nil {
t.Fatal("Validate accepted alias evidence outside corecmd FlagSpec.Aliases")
}
})
}
}
func TestCrossPlatformCoverageCaptureRejectsMultipleNonEmptyFlagAliases(t *testing.T) {
root := &cobra.Command{Use: "dws"}
leaf := &cobra.Command{Use: "send", Run: func(*cobra.Command, []string) {}}
leaf.Flags().String("message-id", "", "canonical message ID")
leaf.Flags().String("other-id", "", "other ID")
leaf.Flags().String("legacy-id", "", "legacy ID")
legacy := leaf.Flags().Lookup("legacy-id")
legacy.Annotations = map[string][]string{
FlagAliasOfAnnotation: {"message-id", "other-id"},
}
root.AddCommand(leaf)
snapshot := Capture(root)
if err := snapshot.Validate(); err == nil {
t.Fatal("Validate accepted multiple non-empty alias annotation values")
}
var encoded bytes.Buffer
if err := Write(&encoded, snapshot); err == nil {
t.Fatal("Write accepted multiple non-empty alias annotation values")
}
}
func TestCrossPlatformCoverageValidateRejectsMalformedFlagAliasState(t *testing.T) {
tests := []struct {
name string
aliasOf string
}{
{name: "self", aliasOf: "legacy-id"},
{name: "leading dashes", aliasOf: "--message-id"},
{name: "non exact", aliasOf: "message id"},
{name: "missing target", aliasOf: "missing-id"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
snapshot := testSnapshot(
testCommand("dws"),
testCommandWithFlagScopes(
"dws send",
[]Flag{
{Name: "legacy-id", Type: "string", AliasOf: test.aliasOf},
{Name: "message-id", Type: "string"},
},
nil,
),
)
if err := snapshot.Validate(); err == nil {
t.Fatalf("Validate accepted alias_of %q", test.aliasOf)
}
encoded, err := json.Marshal(snapshot)
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
if _, err := Read(bytes.NewReader(encoded)); err == nil {
t.Fatalf("Read accepted alias_of %q", test.aliasOf)
}
})
}
}
func TestCrossPlatformCoverageValidateRejectsFlagAliasChainTypeAndScopeConflicts(t *testing.T) {
tests := []struct {
name string
local []Flag
inherited []Flag
}{
{
name: "alias chain",
local: []Flag{
{Name: "legacy-id", Type: "string", AliasOf: "middle-id"},
{Name: "middle-id", Type: "string", AliasOf: "message-id"},
{Name: "message-id", Type: "string"},
},
},
{
name: "different types",
local: []Flag{
{Name: "legacy-id", Type: "string", AliasOf: "message-id"},
{Name: "message-id", Type: "int"},
},
},
{
name: "same flag in local and inherited scopes",
local: []Flag{{Name: "legacy-id", Type: "string"}},
inherited: []Flag{{Name: "legacy-id", Type: "string"}},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
snapshot := testSnapshot(
testCommand("dws"),
testCommandWithFlagScopes("dws send", test.local, test.inherited),
)
if err := snapshot.Validate(); err == nil {
t.Fatalf("Validate accepted invalid alias contract: %#v", snapshot.Commands[1])
}
})
}
}
func TestCrossPlatformCoverageCompareBlocksCandidateSiblingAliasCollision(t *testing.T) {
base := testSnapshot(testCommand("dws"))
current := testSnapshot(
@@ -71,8 +71,8 @@ func TestCrossPlatformCoverageDocSemanticCatalogExactlyCoversRegisteredSurface(t
}
wantPrimaries := map[string]string{
"+find-doc": "+search", "+doc-append": "+update", "+version-save": "+history-save",
"+version-list": "+history-list", "+version-revert": "+history-revert", "+share-doc": "+share",
"+find-doc": "+search", "+doc-append": "+update", "+history-save": "+version-save",
"+history-list": "+version-list", "+history-revert": "+version-revert", "+share-doc": "+share",
}
for command, primary := range wantPrimaries {
item := registered[command]
+53 -18
View File
@@ -1439,8 +1439,8 @@ var MessagesSendCard = shortcut.Shortcut{
Service: "chat",
Command: "+messages-send-card",
Product: "im",
Description: "创建流式卡片,可在同一次调用中写入内容并结束",
Intent: "当你要发送一张流式文本卡片时使用;群 openConversationId、单聊 userId、单聊 openDingTalkId 严格三选一,分别使用 --group、--receiver、--receiver-open-dingtalk-id。--receiver 始终按 userId 通过通讯录关键词搜索做精确匹配,即使值以 D/d 开头也不会猜成 openDingTalkId;已有 openDingTalkId 时必须用显式参数直传。userId 包括在 --dry-run 时也会先解析。只传目标时创建卡片并返回 bizId,供后续 messages-update-card 流式更新;同时传 --content 时会自动串联创建和更新,默认以 flowStatus=3 完成。当前只支持 streaming text,不支持 Card JSON 组件或 action callback。",
Description: "创建流式卡片,可在同一次调用中写入内容并结束;群聊创建时可 @成员或 @所有人",
Intent: "当你要发送一张流式文本卡片时使用;群 openConversationId、单聊 userId、单聊 openDingTalkId 严格三选一,分别使用 --group、--receiver、--receiver-open-dingtalk-id。群聊可用 --at-open-dingtalk-ids 或 --at-all 在创建卡片时设置 @对象;同时传 --content 时,Runtime 会把创建响应中的 atTag 自动加在正文前,后续更新不重复传递 @参数。--receiver 始终按 userId 通过通讯录关键词搜索做精确匹配,即使值以 D/d 开头也不会猜成 openDingTalkId;已有 openDingTalkId 时必须用显式参数直传。userId 包括在 --dry-run 时也会先解析。只传目标时创建卡片并返回 bizId,供后续 messages-update-card 流式更新;同时传 --content 时会自动串联创建和更新,默认以 flowStatus=3 完成。当前只支持 streaming text,不支持 Card JSON 组件或 action callback。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
@@ -1454,30 +1454,34 @@ var MessagesSendCard = shortcut.Shortcut{
CLIPath: "chat +messages-send-card",
PrimaryCLIPath: "chat +messages-send-card",
},
Description: "创建流式卡片,可在同一次调用中写入内容并结束",
Description: "创建流式卡片,可在同一次调用中写入内容并结束;群聊创建时可 @成员或 @所有人",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed card lifecycle adapter: it can resolve a userId through contact search with exact matching, call create_and_send_card alone, or compose creation with update_streaming_card after extracting the returned bizId.",
Reason: "Reviewed card lifecycle adapter: it can resolve a userId through contact search with exact matching, call create_and_send_card alone, or compose creation with update_streaming_card after extracting the returned bizId and atTag.",
},
Selection: contract.SelectionSpec{
AgentSummary: "创建流式卡片,可在同一次调用中写入内容并结束",
UseWhen: []string{"当你要发送一张流式文本卡片时使用;群 openConversationId、单聊 userId、单聊 openDingTalkId 严格三选一,分别使用 --group、--receiver、--receiver-open-dingtalk-id。--receiver 始终按 userId 通过通讯录关键词搜索做精确匹配,即使值以 D/d 开头也不会猜成 openDingTalkId;已有 openDingTalkId 时必须用显式参数直传。userId 包括在 --dry-run 时也会先解析。只传目标时创建卡片并返回 bizId,供后续 messages-update-card 流式更新;同时传 --content 时会自动串联创建和更新,默认以 flowStatus=3 完成。当前只支持 streaming text,不支持 Card JSON 组件或 action callback。"},
AgentSummary: "创建流式卡片,可在同一次调用中写入内容并结束;群聊创建时可 @成员或 @所有人",
UseWhen: []string{"当你要发送一张流式文本卡片时使用;群 openConversationId、单聊 userId、单聊 openDingTalkId 严格三选一,分别使用 --group、--receiver、--receiver-open-dingtalk-id。群聊可用 --at-open-dingtalk-ids 或 --at-all 在创建卡片时设置 @对象;同时传 --content 时,Runtime 会把创建响应中的 atTag 自动加在正文前,后续更新不重复传递 @参数。--receiver 始终按 userId 通过通讯录关键词搜索做精确匹配,即使值以 D/d 开头也不会猜成 openDingTalkId;已有 openDingTalkId 时必须用显式参数直传。userId 包括在 --dry-run 时也会先解析。只传目标时创建卡片并返回 bizId,供后续 messages-update-card 流式更新;同时传 --content 时会自动串联创建和更新,默认以 flowStatus=3 完成。当前只支持 streaming text,不支持 Card JSON 组件或 action callback。"},
AvoidWhen: []string{"已有 bizId、只需要追加或更新现有卡片内容时使用 +messages-update-card"},
Examples: []string{
"dws chat +messages-send-card --group <openConversationId> --content \"任务已完成\"",
"dws chat +messages-send-card --group <openConversationId> --at-open-dingtalk-ids <openDingTalkId> --content \"任务已完成\"",
"dws chat +messages-send-card --receiver <userId>",
},
},
Parameters: []contract.ParamDecl{
{Name: "receiver-open-dingtalk-id", Property: "receiverOpenDingTalkId"},
{Name: "at-open-dingtalk-ids", Property: "atOpenDingTalkIds"},
{Name: "at-all", Property: "atAll"},
},
},
Flags: []shortcut.Flag{
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId(与两个单聊接收者参数互斥)"},
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId(与两个单聊接收者参数互斥);艾特参数仅支持群聊 --group"},
{Name: "receiver", Type: shortcut.FlagString, Desc: "单聊接收者 userId(与 --group/--receiver-open-dingtalk-id 互斥);始终通过通讯录搜索精确匹配 openDingTalkId,包括 --dry-run 和 D/d 开头的 userId"},
{Name: "receiver-open-dingtalk-id", Type: shortcut.FlagString, Desc: "单聊接收者 openDingTalkId(与 --group/--receiver 互斥);显式直传且不做通讯录解析"},
{Name: "content", Type: shortcut.FlagString, Desc: "创建后立即写入的卡片内容;省略时仅创建并返回 bizId"},
{Name: "at-open-dingtalk-ids", Type: shortcut.FlagStringSlice, Desc: "群聊创建卡片时 @ 的 openDingTalkId 列表;仅随 create_and_send_card 发送;艾特参数仅支持群聊 --group"},
{Name: "at-all", Type: shortcut.FlagBool, Desc: "群聊创建卡片时 @ 所有人;仅随 create_and_send_card 发送;艾特参数仅支持群聊 --group"},
{Name: "content", Type: shortcut.FlagString, Desc: "创建后立即写入的卡片正文;群聊 @ 时 Runtime 自动前置 create 返回的 atTag;省略时仅创建并返回 bizId"},
{Name: "flow-status", Type: shortcut.FlagInt, Default: "3", Desc: "自动更新状态:1处理中/2输入中/3完成/4执行中/5错误;--flow-status 必须在 1-5 之间,且显式指定时必须同时提供 --content"},
},
Constraints: []shortcut.Constraint{
@@ -1487,10 +1491,15 @@ var MessagesSendCard = shortcut.Shortcut{
Flags: []string{"flow-status"},
Description: "--flow-status 必须在 1-5 之间,且显式指定时必须同时提供 --content",
},
{
Kind: shortcut.ConstraintCustom,
Flags: []string{"group", "at-open-dingtalk-ids", "at-all"},
Description: "艾特参数仅支持群聊 --group",
},
},
Tips: []string{
`dws chat +messages-send-card --group <openConversationId>`,
`dws chat +messages-send-card --group <openConversationId> --content "任务已完成"`,
`dws chat +messages-send-card --group <openConversationId> --at-open-dingtalk-ids <openDingTalkId> --content "任务已完成"`,
},
Validate: func(rt *shortcut.RuntimeContext) error {
if status := rt.Int("flow-status"); !validCardFlowStatus(status) {
@@ -1499,6 +1508,9 @@ var MessagesSendCard = shortcut.Shortcut{
if rt.Changed("flow-status") && rt.Str("content") == "" {
return fmt.Errorf("--flow-status 只有与 --content 一起使用才有意义")
}
if rt.Str("group") == "" && (len(uniqueShortcutStrings(rt.StrSlice("at-open-dingtalk-ids"))) > 0 || rt.Bool("at-all")) {
return fmt.Errorf("--at-open-dingtalk-ids 和 --at-all 仅支持群聊 --group")
}
return nil
},
Execute: func(rt *shortcut.RuntimeContext) error {
@@ -1506,9 +1518,18 @@ var MessagesSendCard = shortcut.Shortcut{
receiver := rt.Str("receiver")
receiverOpenID := rt.Str("receiver-open-dingtalk-id")
params := map[string]any{}
mentionsRequested := false
switch {
case group != "":
params["openConversationId"] = group
if atOpenIDs := uniqueShortcutStrings(rt.StrSlice("at-open-dingtalk-ids")); len(atOpenIDs) > 0 {
params["atOpenDingTalkIds"] = atOpenIDs
mentionsRequested = true
}
if rt.Bool("at-all") {
params["atAll"] = true
mentionsRequested = true
}
case receiver != "":
openID, err := resolveUserOpenDingTalkID(rt, receiver)
if err != nil {
@@ -1524,6 +1545,10 @@ var MessagesSendCard = shortcut.Shortcut{
}
status := rt.Int("flow-status")
if rt.DryRun() {
plannedContent := content
if mentionsRequested {
plannedContent = "<atTag from create_and_send_card>" + content
}
return rt.Output(map[string]any{
"contractVersion": currentCardWorkflowContract.Version,
"dry_run": true,
@@ -1540,7 +1565,7 @@ var MessagesSendCard = shortcut.Shortcut{
"tool": "update_streaming_card",
"arguments": map[string]any{
"bizId": "<from create_and_send_card>",
"msgContent": content,
"msgContent": plannedContent,
"flowStatus": status,
},
},
@@ -1555,9 +1580,13 @@ var MessagesSendCard = shortcut.Shortcut{
if bizID == "" {
return fmt.Errorf("卡片已创建但下层未返回 bizId,无法自动更新;请检查 create_and_send_card 响应")
}
atTag := findCardAtTag(created)
if mentionsRequested && atTag == "" {
return fmt.Errorf("卡片已创建(bizId=%s),但下层未返回 atTag,无法保证请求的 @ 生效;未执行自动更新", bizID)
}
updated, err := rt.CallMCPWriteData("im", "update_streaming_card", map[string]any{
"bizId": bizID,
"msgContent": content,
"msgContent": atTag + content,
"flowStatus": status,
})
if err != nil {
@@ -1575,12 +1604,18 @@ var MessagesSendCard = shortcut.Shortcut{
}
func findCardBizID(value any) string {
return strings.TrimSpace(findCardResponseString(value, []string{"bizId", "bizID", "biz_id"}))
}
func findCardAtTag(value any) string {
return findCardResponseString(value, []string{"atTag"})
}
func findCardResponseString(value any, directKeys []string) string {
switch typed := value.(type) {
case map[string]any:
directKeys := []string{"bizId", "bizID", "biz_id"}
for _, key := range directKeys {
if candidate, ok := typed[key].(string); ok && strings.TrimSpace(candidate) != "" {
candidate = strings.TrimSpace(candidate)
return candidate
}
}
@@ -1595,7 +1630,7 @@ func findCardBizID(value any) string {
}
for _, key := range envelopeKeys {
visited[key] = struct{}{}
if candidate := findCardBizID(typed[key]); candidate != "" {
if candidate := findCardResponseString(typed[key], directKeys); candidate != "" {
return candidate
}
}
@@ -1608,13 +1643,13 @@ func findCardBizID(value any) string {
}
sort.Strings(remainingKeys)
for _, key := range remainingKeys {
if candidate := findCardBizID(typed[key]); candidate != "" {
if candidate := findCardResponseString(typed[key], directKeys); candidate != "" {
return candidate
}
}
case []any:
for _, child := range typed {
if candidate := findCardBizID(child); candidate != "" {
if candidate := findCardResponseString(child, directKeys); candidate != "" {
return candidate
}
}
@@ -1623,7 +1658,7 @@ func findCardBizID(value any) string {
if strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[") {
var nested any
if json.Unmarshal([]byte(trimmed), &nested) == nil {
return findCardBizID(nested)
return findCardResponseString(nested, directKeys)
}
}
}
+124 -2
View File
@@ -609,7 +609,7 @@ func TestCrossPlatformCoverageMessagesSendTextModesAndExecuteGuard(t *testing.T)
func TestCrossPlatformCoverageMessagesSendCardOneCallLifecycle(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/create_and_send_card": `{"result":{"card":{"biz_id":"biz-1"}}}`,
"im/create_and_send_card": `{"result":{"card":{"biz_id":"biz-1","atTag":"<a atId=D-one>甲</a> <a atId=D-two>乙</a> "}}}`,
"im/update_streaming_card": `{"result":{"updated":true}}`,
}}
helpers.InitDeps(fake)
@@ -619,6 +619,8 @@ func TestCrossPlatformCoverageMessagesSendCardOneCallLifecycle(t *testing.T) {
root.SetArgs([]string{
"chat", "+messages-send-card",
"--group", "cid",
"--at-open-dingtalk-ids", "D-one,D-two,D-one",
"--at-all",
"--content", "完成",
"--flow-status", "3",
"--yes",
@@ -630,7 +632,20 @@ func TestCrossPlatformCoverageMessagesSendCardOneCallLifecycle(t *testing.T) {
fake.calls[1].tool != "update_streaming_card" {
t.Fatalf("card calls = %#v", fake.calls)
}
if fake.calls[1].args["bizId"] != "biz-1" || fake.calls[1].args["msgContent"] != "完成" ||
if got, want := fake.calls[0].args["atOpenDingTalkIds"], []string{"D-one", "D-two"}; !reflect.DeepEqual(got, want) {
t.Fatalf("card create atOpenDingTalkIds = %#v, want %#v", got, want)
}
if fake.calls[0].args["atAll"] != true {
t.Fatalf("card create atAll = %#v", fake.calls[0].args["atAll"])
}
if _, exists := fake.calls[1].args["atOpenDingTalkIds"]; exists {
t.Fatalf("card update leaked atOpenDingTalkIds: %#v", fake.calls[1].args)
}
if _, exists := fake.calls[1].args["atAll"]; exists {
t.Fatalf("card update leaked atAll: %#v", fake.calls[1].args)
}
if fake.calls[1].args["bizId"] != "biz-1" ||
fake.calls[1].args["msgContent"] != "<a atId=D-one>甲</a> <a atId=D-two>乙</a> 完成" ||
fake.calls[1].args["flowStatus"] != 3 {
t.Fatalf("card update args = %#v", fake.calls[1].args)
}
@@ -643,6 +658,49 @@ func TestCrossPlatformCoverageMessagesSendCardOneCallLifecycle(t *testing.T) {
}
}
func TestCrossPlatformCoverageMessagesSendCardKeepsContentWithoutAtTag(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/create_and_send_card": `{"result":{"bizId":"biz-no-mention"}}`,
"im/update_streaming_card": `{"result":{"updated":true}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+messages-send-card",
"--group", "cid",
"--content", "正文",
"--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 2 || fake.calls[1].args["msgContent"] != "正文" {
t.Fatalf("card calls = %#v", fake.calls)
}
}
func TestCrossPlatformCoverageMessagesSendCardRejectsMissingRequestedAtTag(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/create_and_send_card": `{"result":{"bizId":"biz-missing-at-tag"}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+messages-send-card",
"--group", "cid",
"--at-open-dingtalk-ids", "D-mentioned",
"--content", "正文",
"--yes",
})
err := root.Execute()
if err == nil || !strings.Contains(err.Error(), "biz-missing-at-tag") || !strings.Contains(err.Error(), "atTag") {
t.Fatalf("error = %v, want recoverable missing-atTag error containing bizId", err)
}
if len(fake.calls) != 1 || fake.calls[0].tool != "create_and_send_card" {
t.Fatalf("card calls = %#v, want create only", fake.calls)
}
}
func TestCrossPlatformCoverageMessagesSendCardResolvesReceiverForLowerTool(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
@@ -748,6 +806,50 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test
}
})
t.Run("dry run mentions only in create", func(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+messages-send-card",
"--group", "cid",
"--at-open-dingtalk-ids", "D-mentioned",
"--at-all",
"--content", "处理中",
"--dry-run",
"--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 0 {
t.Fatalf("card group dry-run calls = %#v", fake.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
actions, _ := payload["actions"].([]any)
create, _ := actions[0].(map[string]any)
createArguments, _ := create["arguments"].(map[string]any)
update, _ := actions[1].(map[string]any)
updateArguments, _ := update["arguments"].(map[string]any)
if !reflect.DeepEqual(createArguments["atOpenDingTalkIds"], []any{"D-mentioned"}) || createArguments["atAll"] != true {
t.Fatalf("card create dry-run mentions = %#v", createArguments)
}
if _, exists := updateArguments["atOpenDingTalkIds"]; exists {
t.Fatalf("card update dry-run leaked atOpenDingTalkIds: %#v", updateArguments)
}
if _, exists := updateArguments["atAll"]; exists {
t.Fatalf("card update dry-run leaked atAll: %#v", updateArguments)
}
if updateArguments["msgContent"] != "<atTag from create_and_send_card>处理中" {
t.Fatalf("card update dry-run content = %#v", updateArguments["msgContent"])
}
})
t.Run("receiver resolution error", func(t *testing.T) {
fake := &larkAlignmentCaller{failProductTool: "contact/search_contact_by_key_word"}
helpers.InitDeps(fake)
@@ -815,6 +917,8 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test
{"--group", "cid", "--flow-status", "2"},
{"--group", "cid", "--receiver-open-dingtalk-id", "D-direct"},
{"--receiver", "user-id", "--receiver-open-dingtalk-id", "D-direct"},
{"--receiver", "user-id", "--at-open-dingtalk-ids", "D-mentioned"},
{"--receiver-open-dingtalk-id", "D-direct", "--at-all"},
} {
helpers.InitDeps(&larkAlignmentCaller{})
root := newPlatformCoverageRoot()
@@ -858,6 +962,24 @@ func TestCrossPlatformCoverageFindCardBizIDResponseShapes(t *testing.T) {
}
}
func TestCrossPlatformCoverageFindCardAtTagResponseShapes(t *testing.T) {
for _, tc := range []struct {
value any
want string
}{
{map[string]any{"atTag": "<a atId=D-direct>甲</a> "}, "<a atId=D-direct>甲</a> "},
{map[string]any{"result": map[string]any{"atTag": "<a atId=D-nested>乙</a> "}}, "<a atId=D-nested>乙</a> "},
{`{"result":{"card":{"atTag":"<a atId=D-json>丙</a> "}}}`, "<a atId=D-json>丙</a> "},
{map[string]any{"atTag": " "}, ""},
{map[string]any{"atTag": 42}, ""},
{map[string]any{"result": map[string]any{"created": true}}, ""},
} {
if got := findCardAtTag(tc.value); got != tc.want {
t.Errorf("findCardAtTag(%#v) = %q, want %q", tc.value, got, tc.want)
}
}
}
func TestCrossPlatformCoverageMessageResourceDownloadKeepsNestedMessageContext(t *testing.T) {
resetResourceDownloadHooks(t)
t.Chdir(t.TempDir())
+189 -15
View File
@@ -5,6 +5,7 @@ package doc
import (
"encoding/json"
"errors"
"fmt"
"io"
"path/filepath"
@@ -16,7 +17,11 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
const compositeInterfaceReason = "Reviewed Doc Shortcut composite: the executable CLI owns validation, multi-step orchestration, local I/O, output projection, and confirmation; no single MCP interface represents the complete command contract."
const (
compositeInterfaceReason = "Reviewed Doc Shortcut composite: the executable CLI owns validation, multi-step orchestration, local I/O, output projection, and confirmation; no single MCP interface represents the complete command contract."
docContentInputDescription = "内容字面量、@工作目录相对文件或 - 表示 stdin"
docRequiredContentInputDescription = docContentInputDescription + ";相关动作要求时不能为空"
)
func docContract(command, description, intent string, examples []string, params ...contract.ParamDecl) corecmd.ContractDecl {
name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_")
@@ -66,7 +71,7 @@ func readShortcutContent(rt *shortcut.RuntimeContext, flag string) (string, erro
}
path := strings.TrimSpace(strings.TrimPrefix(raw, "@"))
if path == "" || filepath.IsAbs(path) {
return "", apperrors.NewValidation(fmt.Sprintf("--%s 的 @file 只接受工作目录内的相对路径", flag))
return "", apperrors.NewValidation(fmt.Sprintf("--%s 的 @file 只接受工作目录内的相对路径;请先暂存到工作目录后传 @相对路径,或改用 --%s - 从 stdin 读取", flag, flag))
}
cwd, err := docGetwd()
if err != nil {
@@ -82,7 +87,7 @@ func readShortcutContent(rt *shortcut.RuntimeContext, flag string) (string, erro
}
rel, err := docRel(realBase, realPath)
if err != nil || rel == ".." || strings.HasPrefix(filepath.ToSlash(rel), "../") {
return "", apperrors.NewValidation(fmt.Sprintf("--%s 的 @file 不能逃逸工作目录", flag))
return "", apperrors.NewValidation(fmt.Sprintf("--%s 的 @file 不能逃逸工作目录;请先暂存到工作目录后传 @相对路径,或改用 --%s - 从 stdin 读取", flag, flag))
}
data, err := docReadFile(realPath)
if err != nil {
@@ -91,30 +96,198 @@ func readShortcutContent(rt *shortcut.RuntimeContext, flag string) (string, erro
return string(data), nil
}
func validateWorkspaceInputPath(flag, raw string) error {
path := strings.TrimSpace(raw)
if path == "" || filepath.IsAbs(path) {
return apperrors.NewValidation(fmt.Sprintf("--%s 只接受工作目录内已暂存文件的相对路径", flag))
}
cwd, err := docGetwd()
if err != nil {
return apperrors.NewInternal(fmt.Sprintf("读取工作目录失败: %v", err))
}
realBase, err := docEvalSymlinks(cwd)
if err != nil {
return apperrors.NewInternal(fmt.Sprintf("解析工作目录失败: %v", err))
}
realPath, err := docEvalSymlinks(filepath.Join(realBase, filepath.Clean(path)))
if err != nil {
return apperrors.NewValidation(fmt.Sprintf("--%s: 读取文件 %q 失败: %v", flag, path, err))
}
rel, err := docRel(realBase, realPath)
if err != nil || rel == ".." || strings.HasPrefix(filepath.ToSlash(rel), "../") {
return apperrors.NewValidation(fmt.Sprintf("--%s 不能逃逸工作目录;请先把文件暂存到工作目录", flag))
}
return nil
}
func validateJSONML(raw string) (string, error) {
var value any
if err := json.Unmarshal([]byte(raw), &value); err != nil {
return "", apperrors.NewValidation(fmt.Sprintf("JSONML 解析失败: %v", err))
}
if _, ok := value.([]any); !ok {
root, ok := value.([]any)
if !ok {
return "", apperrors.NewValidation("JSONML 顶层必须是数组")
}
if len(root) == 0 {
return "", apperrors.NewValidation("JSONML 顶层数组不能为空")
}
if _, nestedRoot := root[0].([]any); nestedRoot {
return "", apperrors.NewValidation("JSONML 顶层必须是单个元素,不能使用元素数组包裹;请传 [\"root\", {...}, ...] 或单个 block 元素")
}
if tag, ok := root[0].(string); !ok || strings.TrimSpace(tag) == "" {
return "", apperrors.NewValidation("JSONML 第一个元素必须是非空标签名")
}
normalized, _ := json.Marshal(value) // decoded JSON trees are always marshalable
return string(normalized), nil
}
func docEnvelope(operation string, data any, steps ...map[string]any) map[string]any {
return map[string]any{
"ok": true,
"status": "success",
"operation": operation,
"steps": steps,
"data": data,
"warnings": []string{},
"compensation": map[string]any{"available": false, "reason": ""},
"contractVersion": "doc.operation.v1",
"ok": true,
"status": "success",
"complete": true,
"operation": operation,
"steps": steps,
"data": data,
"warnings": []string{},
"compensation": map[string]any{"available": false, "reason": ""},
}
}
type docFailureState string
const (
docFailureFailed docFailureState = "failed"
docFailureRetryable docFailureState = "retryable"
docFailureUnknown docFailureState = "unknown"
)
type docFailureTransition struct {
state docFailureState
reason string
retryable bool
executionStarted bool
message string
actions []string
}
func classifyDocWriteFailure(cause error) docFailureTransition {
transition := docFailureTransition{
state: docFailureUnknown,
reason: "doc_write_commit_unknown",
retryable: false,
executionStarted: true,
message: "文档写入结果未知;为避免重复创建或重复追加,已停止自动重试",
actions: []string{"先读取目标文档确认实际写入状态", "仅在确认服务端未提交后重新执行"},
}
var typed *apperrors.Error
if !errors.As(cause, &typed) {
return transition
}
reason := strings.ToLower(strings.TrimSpace(typed.Reason))
message := strings.ToLower(typed.Message)
if typed.Category == apperrors.CategoryValidation {
if reason == "" {
reason = "invalid_input"
}
return docFailureTransition{
state: docFailureFailed,
reason: reason,
retryable: false,
executionStarted: false,
message: typed.Message,
actions: []string{"根据错误中的参数约束修正输入后重新执行"},
}
}
permissionDenied := strings.Contains(reason, "403") || strings.Contains(reason, "permission") ||
strings.Contains(message, "permission") || strings.Contains(message, "forbidden") || strings.Contains(message, "权限")
if permissionDenied {
return docFailureTransition{
state: docFailureFailed,
reason: "permission_denied",
retryable: false,
executionStarted: false,
message: "当前身份没有执行该文档操作的权限;已终止,不会重试或改走同义命令",
actions: []string{"检查当前 profile 与文档权限", "获得权限后重新发起一次原操作"},
}
}
if typed.Category == apperrors.CategoryAuth {
return docFailureTransition{
state: docFailureFailed,
reason: "authentication_required",
retryable: false,
executionStarted: false,
message: "当前身份认证不可用;已终止文档写入",
actions: []string{"检查当前 profile 与登录状态", "认证恢复后重新发起一次原操作"},
}
}
if typed.ExecutionStarted != nil && !*typed.ExecutionStarted {
transition.state = docFailureFailed
transition.executionStarted = false
transition.retryable = typed.RetryableSet && typed.Retryable
if transition.retryable {
transition.state = docFailureRetryable
}
if reason != "" {
transition.reason = reason
} else {
transition.reason = "doc_write_not_started"
}
transition.message = "文档写入已确认尚未开始"
transition.actions = []string{"仅在 retryable=true 时按服务端 retry-after 有界重试一次"}
}
return transition
}
func docUnknownWriteError(operation, stage, nodeID string, cause error) error {
var existing *apperrors.Error
if errors.As(cause, &existing) {
if existing.Category == apperrors.CategoryValidation {
return cause
}
if status, ok := existing.Details["status"].(string); ok && (status == "unknown" || status == "partial_success") {
return cause
}
}
transition := classifyDocWriteFailure(cause)
details := map[string]any{
"contractVersion": "doc.operation.v1",
"status": string(transition.state),
"state": string(transition.state),
"nodeId": nodeID,
"stage": stage,
}
options := []apperrors.Option{
apperrors.WithOperation(operation),
apperrors.WithReason(transition.reason),
apperrors.WithFailureStage(stage),
apperrors.WithExecutionStarted(transition.executionStarted),
apperrors.WithRetryable(transition.retryable),
apperrors.WithActions(transition.actions...),
apperrors.WithDetails(details),
apperrors.WithCause(cause),
}
if transition.reason == "permission_denied" || transition.reason == "authentication_required" {
return apperrors.NewAuth(transition.message, options...)
}
return apperrors.NewAPI(transition.message, options...)
}
func docVerificationError(operation, stage, nodeID string, cause error, steps []map[string]any) error {
return docPartialWriteError(
operation,
"doc_write_verification_failed",
stage,
"文档写入已经执行,但回读验证失败;请先检查当前内容,不要直接重试写入",
cause,
map[string]any{"nodeId": nodeID, "verified": false},
steps,
map[string]any{"available": false, "reason": "inspect the current document before choosing recovery"},
)
}
func docPartialWriteError(operation, reason, stage, message string, cause error, data map[string]any, steps []map[string]any, compensation map[string]any) error {
return apperrors.NewAPI(
message,
@@ -125,10 +298,11 @@ func docPartialWriteError(operation, reason, stage, message string, cause error,
apperrors.WithRetryable(false),
apperrors.WithActions("inspect the completed steps before retrying", "use the compensation details to clean up or restore the document"),
apperrors.WithDetails(map[string]any{
"status": "partial_success",
"data": data,
"steps": steps,
"compensation": compensation,
"contractVersion": "doc.operation.v1",
"status": "partial_success",
"data": data,
"steps": steps,
"compensation": compensation,
}),
apperrors.WithCause(cause),
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,339 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package doc
import (
"context"
"errors"
"os"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestCrossPlatformCoverageDocFinalCommonAndCanonicalBranches(t *testing.T) {
t.Chdir(t.TempDir())
if err := os.WriteFile("staged.md", []byte("body"), 0o600); err != nil {
t.Fatal(err)
}
if err := validateWorkspaceInputPath("file", "staged.md"); err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
name string
stub func(*testing.T)
}{
{"getwd", func(t *testing.T) {
testseam.Swap(t, &docGetwd, func() (string, error) { return "", errors.New("getwd") })
}},
{"base symlink", func(t *testing.T) {
testseam.Swap(t, &docEvalSymlinks, func(string) (string, error) { return "", errors.New("base") })
}},
{"file symlink", func(t *testing.T) {
calls := 0
testseam.Swap(t, &docEvalSymlinks, func(path string) (string, error) {
calls++
if calls == 2 {
return "", errors.New("file")
}
return path, nil
})
}},
{"relative escape", func(t *testing.T) {
testseam.Swap(t, &docRel, func(string, string) (string, error) { return "../escape", nil })
}},
{"relative error", func(t *testing.T) {
testseam.Swap(t, &docRel, func(string, string) (string, error) { return "", errors.New("rel") })
}},
} {
t.Run(tc.name, func(t *testing.T) {
tc.stub(t)
if err := validateWorkspaceInputPath("file", "staged.md"); err == nil {
t.Fatal("expected validation failure")
}
})
}
if _, err := validateJSONML(`["",{}]`); err == nil {
t.Fatal("empty JSONML tag succeeded")
}
validation := classifyDocWriteFailure(apperrors.NewValidation("bad"))
if validation.reason != "invalid_input" || validation.executionStarted {
t.Fatalf("validation transition = %#v", validation)
}
permission := classifyDocWriteFailure(apperrors.NewAPI("forbidden"))
if permission.reason != "permission_denied" {
t.Fatalf("permission transition = %#v", permission)
}
auth := classifyDocWriteFailure(apperrors.NewAuth("login"))
if auth.reason != "authentication_required" {
t.Fatalf("auth transition = %#v", auth)
}
notStarted := classifyDocWriteFailure(apperrors.NewAPI("later", apperrors.WithExecutionStarted(false), apperrors.WithRetryable(true)))
if notStarted.state != docFailureRetryable || notStarted.reason != "doc_write_not_started" {
t.Fatalf("retryable transition = %#v", notStarted)
}
for _, cause := range []error{apperrors.NewAPI("permission denied"), apperrors.NewAuth("login")} {
var typed *apperrors.Error
if err := docUnknownWriteError("doc.test", "write", "n", cause); !errors.As(err, &typed) || typed.Category != apperrors.CategoryAuth {
t.Fatalf("auth-classified error = %#v", err)
}
}
for _, existing := range []error{
apperrors.NewValidation("existing"),
apperrors.NewAPI("existing", apperrors.WithDetails(map[string]any{"status": "partial_success"})),
} {
if got := docUnknownWriteError("doc.test", "write", "n", existing); got != existing {
t.Fatalf("existing classified error was wrapped: %p != %p", got, existing)
}
}
if !containsText(nil, "") || !containsText("alpha", "ph") || containsText("alpha", "z") ||
!containsText(map[string]any{"nested": "alpha"}, "alpha") || containsText(map[string]any{"nested": "beta"}, "alpha") ||
!containsText([]any{"alpha"}, "alpha") || containsText([]any{"beta"}, "alpha") || containsText(3, "alpha") {
t.Fatal("containsText branch contract failed")
}
jsonElement := []any{"p", map[string]any{"uuid": "b"}, "text"}
jsonPayload := map[string]any{"jsonml": `["p",{"uuid":"b"},"text"]`}
for _, value := range []any{jsonPayload, `["p",{"uuid":"b"},"text"]`, jsonElement} {
if canonicalBlockContent(value, "jsonml") == "" {
t.Fatalf("empty canonical JSONML for %#v", value)
}
}
for _, value := range []any{
map[string]any{"text": "alpha"},
map[string]any{"id": "ignored", "nested": map[string]any{"text": "beta"}},
[]any{"p", map[string]any{"text": "gamma"}},
[]any{map[string]any{"text": "delta"}},
"epsilon",
} {
if canonicalBlockContent(value, "markdown") == "" {
t.Fatalf("empty canonical markdown for %#v", value)
}
}
jsonTree := map[string]any{
"jsonml": `["root",{},["p",{"uuid":"ref"},"before"],["p",{"uuid":"new"},"after"]]`,
"content": "not-json",
"nested": []any{"p", map[string]any{"uuid": "direct"}, "direct"},
}
if len(orderedCanonicalBlocks(jsonTree, "jsonml")) < 3 || findJSONMLBlock(jsonTree, "new") == nil {
t.Fatalf("JSONML traversal failed: %#v", orderedJSONMLBlocks(jsonTree))
}
if findJSONMLBlock(jsonTree, "missing") != nil || jsonMLBlockIdentity([]any{"p"}) != "" || jsonMLBlockIdentity([]any{"p", "attrs"}) != "" {
t.Fatal("JSONML identity negative contract failed")
}
if canonicalBlockIdentity(jsonElement, "jsonml") != "b" || canonicalBlockIdentity("bad", "jsonml") != "" {
t.Fatal("JSONML canonical identity failed")
}
elementTree := map[string]any{"wrapper": []any{map[string]any{"id": "ref", "text": "before"}, map[string]any{"id": "new", "text": "after"}}}
if len(orderedCanonicalBlocks(elementTree, "element")) != 2 || canonicalBlockIdentity(elementTree, "element") != "" || canonicalBlockIdentity("bad", "element") != "" {
t.Fatal("element canonical traversal failed")
}
if !verifyInsertedCanonicalBlock(map[string]any{"blockId": "new"}, elementTree, "ref", "after", "element") ||
!verifyInsertedCanonicalBlock(map[string]any{}, elementTree, "ref", "after", "element") ||
verifyInsertedCanonicalBlock(map[string]any{}, elementTree, "new", "after", "element") {
t.Fatal("inserted block verification branch contract failed")
}
if blockContentEquals(map[string]any{}, "missing", "after", "element") {
t.Fatal("missing block matched")
}
if blockContentEquals(nil, "missing", "after", "element") {
t.Fatal("nil block tree matched")
}
if blockContentEquals(map[string]any{}, "missing", "after", "jsonml") {
t.Fatal("missing JSONML block matched")
}
if got := documentContentCandidates("root", "markdown"); len(got) != 1 || got[0] != "root" {
t.Fatalf("root content candidates = %#v", got)
}
if got := documentContentCandidates([]any{map[string]any{"content": "nested"}}, "markdown"); len(got) != 1 || got[0] != "nested" {
t.Fatalf("nested content candidates = %#v", got)
}
if got := splitDocMarkdown("a\nb", 0); len(got) != 1 {
t.Fatalf("disabled split = %#v", got)
}
if got := splitDocMarkdown("ab\ncd", 4); len(got) != 2 || got[0] != "ab\n" {
t.Fatalf("newline split = %#v", got)
}
}
func TestCrossPlatformCoverageDocFinalExecutionFailureBranches(t *testing.T) {
longContent := strings.Repeat("x", 10001)
if err := runDocCoverage(t, Create, &docCoverageCaller{failAt: 2, responses: map[string][]map[string]any{}}, "--name", "n", "--content", longContent); err == nil {
t.Fatal("partial chunk create succeeded")
}
for _, failAt := range []int{1, 2} {
err := runDocCoverage(t, Update, &docCoverageCaller{failAt: failAt, responses: map[string][]map[string]any{}},
"--node", "n", "--command", "block_insert_after", "--after-block-id", "block-1", "--content", "x", "--yes")
if err == nil {
t.Fatalf("block mutation failAt=%d succeeded", failAt)
}
}
mismatch := &docCoverageCaller{responses: map[string][]map[string]any{
"insert_document_block": {{"blockId": "new"}},
"list_document_blocks": {{"blocks": []any{map[string]any{"id": "new", "text": "wrong"}}}},
}}
if err := runDocCoverage(t, Update, mismatch, "--node", "n", "--command", "block_insert_after", "--after-block-id", "block-1", "--content", "x", "--yes"); err == nil {
t.Fatal("mismatched block verification succeeded")
}
for _, tc := range []struct {
name string
responses map[string][]map[string]any
failAt int
args []string
}{
{"query error", map[string][]map[string]any{}, 1, []string{"--job-id", "j"}},
{"unknown", map[string][]map[string]any{"query_export_job": {{}}}, 0, []string{"--job-id", "j"}},
{"failed", map[string][]map[string]any{"query_export_job": {{"status": "CANCELLED"}}}, 0, []string{"--job-id", "j"}},
{"missing url", map[string][]map[string]any{"query_export_job": {{"status": "SUCCESS"}}}, 0, []string{"--job-id", "j", "--output", "out"}},
} {
t.Run(tc.name, func(t *testing.T) {
_ = runDocCoverage(t, ExportGet, &docCoverageCaller{failAt: tc.failAt, responses: tc.responses}, tc.args...)
})
}
t.Run("export getwd", func(t *testing.T) {
testseam.Swap(t, &docGetwd, func() (string, error) { return "", errors.New("getwd") })
_ = runDocCoverage(t, ExportGet, &docCoverageCaller{responses: map[string][]map[string]any{"query_export_job": {{"status": "SUCCESS", "downloadUrl": "https://example.com/x"}}}}, "--job-id", "j", "--output", "out")
})
t.Run("export download", func(t *testing.T) {
testseam.Swap(t, &docDownload, func(context.Context, string, localio.DownloadOptions) (localio.DownloadResult, error) {
return localio.DownloadResult{}, errors.New("download")
})
_ = runDocCoverage(t, ExportGet, &docCoverageCaller{responses: map[string][]map[string]any{"query_export_job": {{"status": "SUCCESS", "downloadUrl": "https://example.com/x"}}}}, "--job-id", "j", "--output", "out")
})
searchCaller := &docCoverageCaller{responses: map[string][]map[string]any{"search_documents": {{"documents": []any{}, "hasMore": false}}}}
if err := runDocCoverage(t, Search, searchCaller, "--query", "q", "--creator-uids", "u", "--editor-uids", "e", "--mentioned-uids", "m", "--workspace-ids", "w"); err != nil {
t.Fatal(err)
}
if got := searchCaller.history[0].params["pageSize"]; got != 10 {
t.Fatalf("default search page size = %#v", got)
}
listCaller := &docCoverageCaller{responses: map[string][]map[string]any{"list_nodes": {{"nodes": []any{}, "hasMore": false}}}}
if err := runDocCoverage(t, List, listCaller, "--folder", "f", "--workspace", "w"); err != nil {
t.Fatal(err)
}
if got := listCaller.history[0].params["pageSize"]; got != 50 {
t.Fatalf("default list page size = %#v", got)
}
_ = runDocCoverage(t, CommentReply, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--comment-key", "c", "--content", "x", "--emoji", "--mention", "[bad", "--yes")
if err := runDocCoverage(t, CommentReply, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--comment-key", "c", "--content", "x", "--mention", "u", "--yes"); err != nil {
t.Fatal(err)
}
_ = runDocCoverage(t, CommentUpdate, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--comment-key", "c", "--content", "x", "--mention", "[bad")
_ = runDocCoverage(t, ExportSubmit, &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}}, "--node", "n", "--export-format", "docx")
if err := runDocCoverage(t, ExportSubmit, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--export-format", "docx"); err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
name string
responses map[string][]map[string]any
failAt int
args []string
}{
{"template search error", map[string][]map[string]any{}, 1, []string{"--query", "q"}},
{"template not found", map[string][]map[string]any{"search_doc_templates": {{"templates": []any{}, "hasMore": false}}}, 0, []string{"--query", "q"}},
{"template resolved", map[string][]map[string]any{"search_doc_templates": {{"templates": []any{map[string]any{"templateId": "t"}}, "hasMore": false}}}, 0, []string{"--query", "q", "--source", "PUBLIC"}},
{"template continue", map[string][]map[string]any{"search_doc_templates": {{"templates": []any{map[string]any{"templateId": "t"}}, "nextCursor": "p2"}}}, 0, []string{"--query", "q", "--cursor", "p1"}},
} {
t.Run(tc.name, func(t *testing.T) {
_ = runDocCoverage(t, TemplateSearch, &docCoverageCaller{responses: tc.responses, failAt: tc.failAt}, tc.args...)
})
}
for _, raw := range [][]string{{"[bad"}, {"", " "}} {
if _, err := normalizeMentionUserIDs(raw); err == nil {
t.Fatalf("invalid mentions %#v succeeded", raw)
}
}
if _, ok := sliceUTF16Range("A😀B", 1, 2); ok {
t.Fatal("selection ending inside surrogate pair succeeded")
}
if _, ok := sliceUTF16Range("abc", -1, 1); ok {
t.Fatal("negative selection range succeeded")
}
commentCases := []struct {
name string
caller *docCoverageCaller
args []string
wantError bool
}{
{"block read", &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}}, []string{"--node", "n", "--content", "c", "--block-id", "b", "--start", "0", "--end", "1", "--yes"}, true},
{"range", &docCoverageCaller{responses: map[string][]map[string]any{"list_document_blocks": {{"id": "b", "text": ""}}}}, []string{"--node", "n", "--content", "c", "--block-id", "b", "--start", "0", "--end", "1", "--yes"}, true},
{"selected mismatch", &docCoverageCaller{responses: map[string][]map[string]any{"list_document_blocks": {{"id": "b", "text": "alpha"}}}}, []string{"--node", "n", "--content", "c", "--block-id", "b", "--start", "0", "--end", "1", "--selected-text", "z", "--yes"}, true},
{"write error", &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}}, []string{"--node", "n", "--content", "c", "--yes"}, true},
{"mention", &docCoverageCaller{responses: map[string][]map[string]any{}}, []string{"--node", "n", "--content", "c", "--mention", "[bad", "--yes"}, true},
}
for _, tc := range commentCases {
t.Run(tc.name, func(t *testing.T) {
err := runDocCoverage(t, CommentCreate, tc.caller, tc.args...)
if (err != nil) != tc.wantError {
t.Fatalf("err=%v wantError=%v", err, tc.wantError)
}
})
}
if !versionEvidenceMatches([]any{map[string]any{"target_version": 3}}, 3, map[string]bool{"targetversion": true}) {
t.Fatal("array version evidence did not match")
}
if !versionEvidenceMatches(map[string]any{"wrapper": map[string]any{"target_version": 3}}, 3, map[string]bool{"targetversion": true}) {
t.Fatal("nested version evidence did not match")
}
withDescription := collectTemplateCandidates(map[string]any{"templateId": "t", "description": "d"})
if len(withDescription) != 1 || withDescription[0]["description"] != "d" {
t.Fatalf("template description = %#v", withDescription)
}
duplicateTemplates := &docCoverageCaller{responses: map[string][]map[string]any{"search_doc_templates": {
{"templates": []any{map[string]any{"templateId": "t"}}, "hasMore": true, "nextCursor": "p2"},
{"templates": []any{map[string]any{"templateId": "t"}}, "hasMore": false},
}}}
_ = runDocCoverage(t, CreateFromTemplate, duplicateTemplates, "--query", "q")
stalledTemplates := &docCoverageCaller{responses: map[string][]map[string]any{"search_doc_templates": {
{"templates": []any{map[string]any{"templateId": "t"}}, "hasMore": true, "nextCursor": "p2"},
{"templates": []any{map[string]any{"templateId": "t"}}, "hasMore": true, "nextCursor": "p2"},
}}}
if err := runDocCoverage(t, CreateFromTemplate, stalledTemplates, "--query", "q"); err == nil {
t.Fatal("stalled template cursor succeeded")
}
maxPages := make([]map[string]any, 20)
for index := range maxPages {
maxPages[index] = map[string]any{"templates": []any{map[string]any{"templateId": "t"}}, "hasMore": true, "nextCursor": "p" + strings.Repeat("x", index+1)}
}
if err := runDocCoverage(t, CreateFromTemplate, &docCoverageCaller{responses: map[string][]map[string]any{"search_doc_templates": maxPages}}, "--query", "q"); err == nil {
t.Fatal("template max-pages succeeded")
}
t.Chdir(t.TempDir())
if err := os.WriteFile("media.bin", []byte("media"), 0o600); err != nil {
t.Fatal(err)
}
_ = runDocCoverage(t, ResourceUpdate, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--file", "media.bin", "--dry-run", "--yes")
if err := runDocCoverage(t, Import, &docCoverageCaller{dryRun: true, responses: map[string][]map[string]any{}}, "--file", "media.bin", "--folder", "f", "--dry-run"); err != nil {
t.Fatal(err)
}
t.Run("empty preview", func(t *testing.T) {
testseam.Swap(t, &docDownload, func(context.Context, string, localio.DownloadOptions) (localio.DownloadResult, error) {
return localio.DownloadResult{}, nil
})
_ = runDocCoverage(t, MediaPreview, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--resource-id", "r")
})
t.Run("empty download", func(t *testing.T) {
testseam.Swap(t, &docDownload, func(context.Context, string, localio.DownloadOptions) (localio.DownloadResult, error) {
return localio.DownloadResult{}, nil
})
_ = runDocCoverage(t, MediaDownload, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--resource-id", "r", "--output", "out")
})
}
+142 -108
View File
@@ -17,8 +17,11 @@
package doc
import (
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
@@ -77,6 +80,9 @@ var Search = shortcut.Shortcut{
{Name: "workspace-ids", Type: shortcut.FlagStringSlice, Desc: "按知识库 ID 过滤"},
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页数量 (默认 10,最大 30)"},
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标 (上次结果的 nextPageToken)"},
{Name: "page-all", Type: shortcut.FlagBool, Desc: "有界读取全部后续页"},
{Name: "max-pages", Type: shortcut.FlagInt, Default: "20", Desc: "--page-all 最大页数"},
{Name: "max-items", Type: shortcut.FlagInt, Default: "500", Desc: "最多返回文档数"},
},
Tips: []string{`dws doc +search --query "会议纪要"`, `dws doc +search --extensions pdf,docx`},
Execute: func(rt *shortcut.RuntimeContext) error {
@@ -111,18 +117,17 @@ var Search = shortcut.Shortcut{
if rt.Changed("workspace-ids") {
params["workspaceIds"] = rt.StrSlice("workspace-ids")
}
if rt.Changed("limit") {
params["pageSize"] = rt.Int("limit")
pageSize := rt.Int("limit")
if pageSize == 0 {
pageSize = 10
}
if rt.Changed("cursor") {
params["pageToken"] = rt.Str("cursor")
}
data, err := rt.CallMCPData(productDoc, "search_documents", params)
result, err := collectDocPages(rt, "search_documents", "documents", params, searchDocsProject, docPageOptions{
PageAll: rt.Bool("page-all"), PageSize: pageSize, MaxPages: rt.Int("max-pages"), MaxItems: rt.Int("max-items"), Cursor: rt.Str("cursor"),
})
if err != nil {
return err
}
docs := searchDocsProject(data)
return rt.Output(map[string]any{"count": len(docs), "documents": docs})
return rt.Output(result)
},
}
@@ -241,6 +246,9 @@ var List = shortcut.Shortcut{
{Name: "workspace", Type: shortcut.FlagString, Desc: "知识库 ID"},
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页数量 (默认 50,最大 50)"},
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标 (上次结果的 nextPageToken)"},
{Name: "page-all", Type: shortcut.FlagBool, Desc: "有界读取全部后续页"},
{Name: "max-pages", Type: shortcut.FlagInt, Default: "20", Desc: "--page-all 最大页数"},
{Name: "max-items", Type: shortcut.FlagInt, Default: "500", Desc: "最多返回节点数"},
},
Tips: []string{`dws doc +list --folder DOC_FOLDER_NODE_ID`, `dws doc +list --workspace WS_ID --limit 20`},
Execute: func(rt *shortcut.RuntimeContext) error {
@@ -251,18 +259,17 @@ var List = shortcut.Shortcut{
if rt.Changed("workspace") {
params["workspaceId"] = rt.Str("workspace")
}
if rt.Changed("limit") {
params["pageSize"] = rt.Int("limit")
pageSize := rt.Int("limit")
if pageSize == 0 {
pageSize = 50
}
if rt.Changed("cursor") {
params["pageToken"] = rt.Str("cursor")
}
data, err := rt.CallMCPData(productDoc, "list_nodes", params)
result, err := collectDocPages(rt, "list_nodes", "nodes", params, listNodesProject, docPageOptions{
PageAll: rt.Bool("page-all"), PageSize: pageSize, MaxPages: rt.Int("max-pages"), MaxItems: rt.Int("max-items"), Cursor: rt.Str("cursor"),
})
if err != nil {
return err
}
nodes := listNodesProject(data)
return rt.Output(map[string]any{"count": len(nodes), "nodes": nodes})
return rt.Output(result)
},
}
@@ -414,7 +421,7 @@ var CommentList = shortcut.Shortcut{
Command: "+comment-list",
Product: productComment,
Description: "查询文档评论列表",
Intent: "当你想查看某篇文档上已有的评论、了解有哪些反馈或待处理意见(可按全文/划词、已解决/未解决过滤)时使用;输入 node,返回评论列表及其 commentKey 以便后续回复。",
Intent: "当你想查看某篇文档上已有的评论、了解有哪些反馈或待处理意见(可按全文/划词、已解决/未解决过滤)时使用;输入 node,可用 --limit/--cursor 分页,返回评论列表及其 commentKey 以便后续回复。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
@@ -436,26 +443,26 @@ var CommentList = shortcut.Shortcut{
},
Selection: contract.SelectionSpec{
AgentSummary: "查询文档评论列表",
UseWhen: []string{"当你想查看某篇文档上已有的评论、了解有哪些反馈或待处理意见(可按全文/划词、已解决/未解决过滤)时使用;输入 node,返回评论列表及其 commentKey 以便后续回复。"},
UseWhen: []string{"当你想查看某篇文档上已有的评论、了解有哪些反馈或待处理意见(可按全文/划词、已解决/未解决过滤)时使用;输入 node,可用 --limit/--cursor 分页,返回评论列表及其 commentKey 以便后续回复。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{
"dws doc +comment-list --node DOC_ID",
"dws doc +comment-list --node DOC_ID --type inline --resolve-status unresolved",
"dws doc +comment-list --node DOC_ID --limit 20",
"dws doc +comment-list --node DOC_ID --limit 20 --cursor NEXT_TOKEN",
},
},
},
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页数量 (默认 50,最大 50)"},
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页数量 (默认 50,最大 50)", Aliases: []string{"page-size"}},
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标 (上一页返回的 nextToken)"},
{Name: "type", Type: shortcut.FlagString, Desc: "评论类型: global (全文) / inline (划词)", Enum: []string{"global", "inline"}},
{Name: "resolve-status", Type: shortcut.FlagString, Desc: "解决状态: resolved / unresolved", Enum: []string{"resolved", "unresolved"}},
},
Tips: []string{`dws doc +comment-list --node DOC_ID`, `dws doc +comment-list --node DOC_ID --type inline --resolve-status unresolved`},
Tips: []string{`dws doc +comment-list --node DOC_ID --limit 20`, `dws doc +comment-list --node DOC_ID --limit 20 --cursor NEXT_TOKEN`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{"nodeId": rt.Str("node")}
if rt.Changed("limit") {
params["pageSize"] = rt.Int("limit")
if rt.Changed("limit") || rt.Changed("page-size") {
params["pageSize"] = rt.IntFirst("limit", "page-size")
}
if v := rt.Str("cursor"); v != "" {
params["nextToken"] = v
@@ -470,54 +477,44 @@ var CommentList = shortcut.Shortcut{
},
}
const commentCreateTargetConstraint = "selection 与 block-id/start/end 高级通道互斥;block-id/start/end 必须一起提供;selection 必须唯一匹配"
var CommentCreate = shortcut.Shortcut{
Service: "doc",
Command: "+comment-create",
Product: productComment,
Description: "在文档上创建一条评论",
Intent: "当你想对整篇文档留一条全文评论、给出反馈或 @ 相关同事时使用;输入 node 与评论 content(可带 mention),会实际在文档上发布一条新评论。",
Description: "创建全文评论,或按 selection 创建划词评论",
Intent: "当用户要对整篇文档留言,或针对文档中唯一匹配的一段文字创建精确划词评论时使用;已知 block/start/end 时也可直接走高级通道。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "user_required", Idempotency: "unknown",
},
Contract: corecmd.ContractDecl{
Identity: contract.ToolIdentitySpec{
ProductID: "doc",
Name: "shortcut_comment_create",
CanonicalPath: "doc.shortcut_comment_create",
CLIPath: "doc +comment-create",
PrimaryCLIPath: "doc +comment-create",
},
Description: "在文档上创建一条评论",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
},
Selection: contract.SelectionSpec{
AgentSummary: "在文档上创建一条评论",
UseWhen: []string{"当你想对整篇文档留一条全文评论、给出反馈或 @ 相关同事时使用;输入 node 与评论 content(可带 mention),会实际在文档上发布一条新评论。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws doc +comment-create --node DOC_ID --content \"这里需要修改\""},
},
},
Contract: docContract("+comment-create", "创建全文评论,或按 selection 创建划词评论",
"当用户要对整篇文档留言,或针对文档中唯一匹配的一段文字创建精确划词评论时使用;已知 block/start/end 时也可直接走高级通道。",
[]string{`dws doc +comment-create --node <DOC_ID> --content "请补充数据来源"`, `dws doc +comment-create --node <DOC_ID> --selection "计划下周发布" --content "请确认日期"`},
contract.ParamDecl{Name: "node", Property: "node"},
contract.ParamDecl{Name: "content", Property: "content"},
contract.ParamDecl{Name: "selection", Property: "selection"},
contract.ParamDecl{Name: "block-id", Property: "blockId"},
contract.ParamDecl{Name: "start", Property: "start"},
contract.ParamDecl{Name: "end", Property: "end"},
contract.ParamDecl{Name: "selected-text", Property: "selectedText"},
contract.ParamDecl{Name: "mention", Property: "mention"}),
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
{Name: "content", Type: shortcut.FlagString, Desc: "评论文字内容 (纯文本)", Required: true},
{Name: "mention", Type: shortcut.FlagStringSlice, Desc: "被 @ 的用户 uid 列表"},
},
Tips: []string{`dws doc +comment-create --node DOC_ID --content "这里需要修改"`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{
"nodeId": rt.Str("node"),
"content": rt.Str("content"),
}
if rt.Changed("mention") {
params["mentionedUserIds"] = rt.StrSlice("mention")
}
return rt.CallMCP("create_comment", params)
{Name: "content", Type: shortcut.FlagString, Desc: "评论文字内容", Required: true},
{Name: "selection", Type: shortcut.FlagString, Desc: "完整文字或 前缀...后缀;" + commentCreateTargetConstraint},
{Name: "block-id", Type: shortcut.FlagString, Desc: "高级通道 block ID;" + commentCreateTargetConstraint},
{Name: "start", Type: shortcut.FlagInt, Desc: "块内 UTF-16 起始偏移;" + commentCreateTargetConstraint},
{Name: "end", Type: shortcut.FlagInt, Desc: "块内 UTF-16 结束偏移;" + commentCreateTargetConstraint},
{Name: "selected-text", Type: shortcut.FlagString, Desc: "可选引用原文;CLI 会从 block 回读并交叉校验"},
{Name: "mention", Type: shortcut.FlagStringSlice, Desc: "被 @ 的用户 uid,多个值用逗号分隔;不要传 JSON 数组"},
},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"selection", "block-id", "start", "end"}, Description: commentCreateTargetConstraint}},
Tips: []string{`dws doc +comment-create --node <DOC_ID> --content "请补充数据来源"`, `dws doc +comment-create --node <DOC_ID> --selection "计划下周发布" --content "请确认日期"`},
Validate: validateCommentCreate,
Execute: executeCommentCreate,
}
var CommentReply = shortcut.Shortcut{
@@ -557,7 +554,7 @@ var CommentReply = shortcut.Shortcut{
{Name: "content", Type: shortcut.FlagString, Desc: "回复文字内容 (表情回复时填表情名称)", Required: true},
{Name: "comment-key", Type: shortcut.FlagString, Desc: "被回复评论的 commentKey (从 list/create 获取)", Required: true},
{Name: "emoji", Type: shortcut.FlagBool, Desc: "作为表情贴图回复"},
{Name: "mention", Type: shortcut.FlagStringSlice, Desc: "被 @ 的用户 uid 列表"},
{Name: "mention", Type: shortcut.FlagStringSlice, Desc: "被 @ 的用户 uid,多个值用逗号分隔;不要传 JSON 数组"},
},
Tips: []string{`dws doc +comment-reply --node DOC_ID --comment-key COMMENT_KEY --content "同意"`},
Execute: func(rt *shortcut.RuntimeContext) error {
@@ -570,7 +567,11 @@ var CommentReply = shortcut.Shortcut{
params["emoji"] = true
}
if rt.Changed("mention") {
params["mentionedUserIds"] = rt.StrSlice("mention")
mentions, err := normalizeMentionUserIDs(rt.StrSlice("mention"))
if err != nil {
return err
}
params["mentionedUserIds"] = mentions
}
return rt.CallMCP("reply_comment", params)
},
@@ -580,9 +581,10 @@ var CommentCreateInline = shortcut.Shortcut{
Service: "doc",
Command: "+comment-create-inline",
Product: productComment,
Description: "在文档选中文本区域上创建划词评论",
Intent: "当你想针对文档里某段具体文字(而非整篇)留评论、做精确批注时使用;需先用 +block-list 定位块,再输入 node、block-id 及该块内的 start/end 字符偏移量,会实际在选中文本上创建划词评论。",
Description: "兼容入口:按 block/start/end 创建划词评论",
Intent: "仅兼容既有调用;新任务统一使用 +comment-create 的 selection 或 block/start/end 通道。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
{Name: "content", Type: shortcut.FlagString, Desc: "评论文字内容 (纯文本)", Required: true},
@@ -590,25 +592,12 @@ var CommentCreateInline = shortcut.Shortcut{
{Name: "start", Type: shortcut.FlagInt, Desc: "块内文本起始字符偏移量 (从 0 开始)", Required: true},
{Name: "end", Type: shortcut.FlagInt, Desc: "块内文本结束字符偏移量 (须大于 start)", Required: true},
{Name: "selected-text", Type: shortcut.FlagString, Desc: "选中文本内容 (展示引用原文)"},
{Name: "mention", Type: shortcut.FlagStringSlice, Desc: "被 @ 的用户 uid 列表"},
},
Tips: []string{`dws doc +comment-create-inline --node DOC_ID --block-id BLOCK_ID --start 0 --end 10 --content "这里需要修改"`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{
"nodeId": rt.Str("node"),
"content": rt.Str("content"),
"blockId": rt.Str("block-id"),
"start": rt.Int("start"),
"end": rt.Int("end"),
}
if v := rt.Str("selected-text"); v != "" {
params["selectedText"] = v
}
if rt.Changed("mention") {
params["mentionedUserIds"] = rt.StrSlice("mention")
}
return rt.CallMCP("create_inline_comment", params)
{Name: "mention", Type: shortcut.FlagStringSlice, Desc: "被 @ 的用户 uid,多个值用逗号分隔;不要传 JSON 数组"},
},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"block-id", "start", "end"}, Description: "block-id/start/end 必须一起提供,CLI 回读并校验 selectedText"}},
Tips: []string{`dws doc +comment-create --node DOC_ID --block-id BLOCK_ID --start 0 --end 10 --content "这里需要修改"`},
Validate: validateCommentCreate,
Execute: executeCommentCreate,
}
// ── 协作权限 ─────────────────────────────────────────────────
@@ -620,7 +609,7 @@ var ExportSubmit = shortcut.Shortcut{
Command: "+export-submit",
Product: productDoc,
Description: "提交在线文档导出任务 (docx/markdown/pdf),返回 jobId",
Intent: "当你想把在线文档导出成 docx/markdown/pdf 文件(例如离线保存或外发)时使用;这是异步任务的第一步,输入 node 与 export-format 提交导出,返回 jobId,随后用 +export-get 轮询结果。",
Intent: "仅当用户明确要求手工接管异步导出 job,且不需要当前命令下载文件时使用;返回 jobId 后只能用 +export-get 恢复。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
@@ -642,25 +631,25 @@ var ExportSubmit = shortcut.Shortcut{
},
Selection: contract.SelectionSpec{
AgentSummary: "提交在线文档导出任务 (docx/markdown/pdf),返回 jobId",
UseWhen: []string{"当你想把在线文档导出成 docx/markdown/pdf 文件(例如离线保存或外发)时使用;这是异步任务的第一步,输入 node 与 export-format 提交导出,返回 jobId,随后用 +export-get 轮询结果。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
UseWhen: []string{"仅当用户明确要求手工接管异步导出 job,且不需要当前命令下载文件时使用;返回 jobId 后只能用 +export-get 恢复。"},
AvoidWhen: []string{"正常导出和保存本地文件使用 doc +export;不要手工编排 submit/get,也不要用它绕过 +export 的安全下载"},
Examples: []string{"dws doc +export-submit --node DOC_ID --export-format markdown"},
},
},
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "要导出的文档 ID 或 URL", Required: true},
{Name: "export-format", Type: shortcut.FlagString, Default: "docx", Desc: "导出格式", Enum: []string{"docx", "markdown", "pdf"}},
{Name: "export-format", Type: shortcut.FlagString, Default: "docx", Desc: "导出格式;省略时默认为 docx", Enum: []string{"docx", "markdown", "pdf"}},
},
Tips: []string{`dws doc +export-submit --node DOC_ID --export-format markdown`},
Execute: func(rt *shortcut.RuntimeContext) error {
format := rt.Str("export-format")
if format == "" {
format = "docx"
}
return rt.CallMCP("submit_export_job", map[string]any{
result, err := rt.CallMCPWriteData(productDoc, "submit_export_job", map[string]any{
"nodeId": rt.Str("node"),
"exportFormat": format,
"exportFormat": rt.Str("export-format"),
})
if err != nil {
return docUnknownWriteError("doc.export_submit", "submit_export_job", rt.Str("node"), err)
}
return rt.Output(docEnvelope("doc.export_submit", map[string]any{"nodeId": rt.Str("node"), "result": result}, map[string]any{"name": "submit_export_job", "status": "success"}))
},
}
@@ -669,7 +658,7 @@ var ExportGet = shortcut.Shortcut{
Command: "+export-get",
Product: productDoc,
Description: "根据 jobId 查询文档导出任务结果",
Intent: "当你已用 +export-submit 提交了导出任务、想查询它是否完成并拿到导出文件的下载链接时使用;输入上一步返回的 job-id,返回任务状态与结果。",
Intent: "当 +export 已返回 jobId 但轮询、中断或下载失败时使用;复用同一 job 查询,给 output 时通过 CLI 安全下载,禁止重新提交或 curl 临时链接。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
@@ -691,18 +680,24 @@ var ExportGet = shortcut.Shortcut{
},
Selection: contract.SelectionSpec{
AgentSummary: "根据 jobId 查询文档导出任务结果",
UseWhen: []string{"当你已用 +export-submit 提交了导出任务、想查询它是否完成并拿到导出文件的下载链接时使用;输入上一步返回的 job-id,返回任务状态与结果。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws doc +export-get --job-id JOB_ID"},
UseWhen: []string{"当 +export 已返回 jobId 但轮询、中断或下载失败时使用;复用同一 job 查询,给 output 时通过 CLI 安全下载,禁止重新提交或 curl 临时链接。"},
AvoidWhen: []string{"尚未提交导出或没有真实 jobId 时不要使用;正常首次导出使用 doc +export"},
Examples: []string{"dws doc +export-get --job-id JOB_ID", "dws doc +export-get --job-id JOB_ID --output ./exports/"},
},
},
Flags: []shortcut.Flag{
{Name: "job-id", Type: shortcut.FlagString, Desc: "导出任务 ID", Required: true},
{Name: "output", Shorthand: "o", Type: shortcut.FlagString, Desc: "可选:任务完成后安全下载到工作目录内相对路径"},
},
Tips: []string{`dws doc +export-get --job-id JOB_ID`},
Execute: func(rt *shortcut.RuntimeContext) error {
return rt.CallMCP("query_export_job", map[string]any{"jobId": rt.Str("job-id")})
Validate: func(rt *shortcut.RuntimeContext) error {
if rt.Str("output") == "" {
return nil
}
return localio.ValidateOutput(rt.Str("output"))
},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"output"}, Description: "提供 --output 时必须是工作目录内相对路径;默认 no-clobber"}},
Tips: []string{`dws doc +export-get --job-id JOB_ID`, `dws doc +export-get --job-id JOB_ID --output ./exports/`},
Execute: executeExportGet,
}
// ── 历史版本 (server: doc) ───────────────────────────────────
@@ -938,17 +933,57 @@ var TemplateSearch = shortcut.Shortcut{
},
Tips: []string{`dws doc +template-search --query "周报"`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{"searchName": rt.Str("query")}
pageSize := rt.Int("limit")
if pageSize <= 0 {
pageSize = 50
}
params := map[string]any{"searchName": rt.Str("query"), "maxResults": pageSize}
if v := rt.Str("source"); v != "" {
params["templateSource"] = v
}
if rt.Changed("limit") {
params["maxResults"] = rt.Int("limit")
}
if v := rt.Str("cursor"); v != "" {
params["nextCursor"] = v
}
return rt.CallMCP("search_doc_templates", params)
found, err := rt.CallMCPData(productDoc, "search_doc_templates", params)
if err != nil {
return err
}
candidates := collectTemplateCandidates(found)
hasMore, hasMoreKnown, nextCursor := docPageState(found)
nextCursor = strings.TrimSpace(nextCursor)
complete := hasMoreKnown && !hasMore
if !hasMoreKnown && nextCursor == "" && len(candidates) < pageSize {
complete = true
}
globalComplete := complete && rt.Str("cursor") == ""
status := "selection_required"
if globalComplete && len(candidates) == 0 {
status = "not_found"
} else if globalComplete && len(candidates) == 1 {
status = "resolved"
}
selectedTemplateID := ""
if status == "resolved" {
selectedTemplateID = candidates[0]["templateId"].(string)
}
nextAction := map[string]string{"resolved": "create_once", "not_found": "stop", "selection_required": "ask_user"}[status]
if !complete {
nextAction = "continue_search"
}
return rt.Output(docEnvelope("doc.template_search", map[string]any{
"query": rt.Str("query"),
"source": rt.Str("source"),
"count": len(candidates),
"candidates": candidates,
"complete": complete,
"hasMore": !complete,
"nextCursor": nextCursor,
"selection": map[string]any{
"status": status,
"templateId": selectedTemplateID,
"nextAction": nextAction,
},
}))
},
}
@@ -986,7 +1021,6 @@ func init() {
CommentCreateInline.Contract = corecmd.ContractDecl{}
TemplateApply.Contract = corecmd.ContractDecl{}
canonicalizeHistoryShortcuts()
canonicalizeCommentShortcuts()
shortcut.Register(
Search,
List,
@@ -998,9 +1032,9 @@ func init() {
CommentCreateInline,
ExportSubmit,
ExportGet,
legacyVersionSave,
legacyVersionList,
legacyVersionRevert,
compatHistorySave,
compatHistoryList,
compatHistoryRevert,
VersionSave,
VersionList,
VersionRevert,
+695 -40
View File
@@ -7,15 +7,18 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"reflect"
"strings"
"sync"
"testing"
"unicode/utf8"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio"
@@ -26,8 +29,10 @@ import (
)
type docCoverageCaller struct {
mu sync.Mutex
failAt int
calls int
dryRun bool
responses map[string][]map[string]any
ctx context.Context
history []docCoverageCall
@@ -43,12 +48,17 @@ type docCoverageErrorReader struct{}
func (docCoverageErrorReader) Read([]byte) (int, error) { return 0, errors.New("stdin failed") }
func (f *docCoverageCaller) CallTool(_ context.Context, _, tool string, params map[string]any) (*edition.ToolResult, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.calls++
f.history = append(f.history, docCoverageCall{tool: tool, params: params})
if f.failAt == f.calls {
return nil, errors.New("injected doc coverage failure")
}
value := docCoveragePayload(tool)
if tool == "revert_doc_version" {
value = map[string]any{"version": params["version"]}
}
if queue := f.responses[tool]; len(queue) > 0 {
value = queue[0]
f.responses[tool] = queue[1:]
@@ -61,7 +71,7 @@ func (f *docCoverageCaller) CallTool(_ context.Context, _, tool string, params m
}
func (f *docCoverageCaller) Format() string { return "json" }
func (f *docCoverageCaller) DryRun() bool { return false }
func (f *docCoverageCaller) DryRun() bool { return f.dryRun }
func (f *docCoverageCaller) Fields() string { return "" }
func (f *docCoverageCaller) JQ() string { return "" }
@@ -70,9 +80,9 @@ func docCoveragePayload(tool string) map[string]any {
case "create_document":
return map[string]any{"data": map[string]any{"nodeId": "node-1"}}
case "get_document_content":
return map[string]any{"data": map[string]any{"revision": 1, "jsonml": `["root",{},["p",{"uuid":"block-1"},"alpha beta"]]`}}
return map[string]any{"data": map[string]any{"revision": 1, "markdown": "alpha beta x body gamma legacy []", "jsonml": `["root",{},["p",{"uuid":"block-1"},"alpha beta x body gamma legacy []"]]`}}
case "list_document_blocks":
return map[string]any{"blocks": []any{map[string]any{"element": map[string]any{"id": "block-1", "paragraph": map[string]any{"text": "alpha beta"}}}}}
return map[string]any{"blocks": []any{map[string]any{"element": map[string]any{"id": "block-1", "paragraph": map[string]any{"text": "alpha beta x body gamma []"}}}}}
case "submit_export_job":
return map[string]any{"jobId": "job-1"}
case "query_export_job":
@@ -156,6 +166,224 @@ func TestCrossPlatformCoverageRevisionSelectionAndKeywordUseLiveShapes(t *testin
}
}
func TestCrossPlatformCoverageFetchResolvesUniqueTitleBeforeRead(t *testing.T) {
caller := &docCoverageCaller{responses: map[string][]map[string]any{
"search_documents": {{
"documents": []any{map[string]any{"nodeId": "resolved-node", "name": "项目周报", "docType": "adoc"}},
"hasMore": false,
}},
}}
if err := runDocCoverage(t, Fetch, caller, "--query", "项目周报"); err != nil {
t.Fatal(err)
}
if len(caller.history) != 2 || caller.history[0].tool != "search_documents" || caller.history[1].tool != "get_document_content" {
t.Fatalf("fetch resolution calls = %#v", caller.history)
}
if caller.history[1].params["nodeId"] != "resolved-node" {
t.Fatalf("resolved fetch params = %#v", caller.history[1].params)
}
ambiguous := &docCoverageCaller{responses: map[string][]map[string]any{
"search_documents": {{
"documents": []any{
map[string]any{"nodeId": "a", "name": "项目周报", "docType": "adoc"},
map[string]any{"nodeId": "b", "name": "项目周报", "docType": "adoc"},
},
"hasMore": false,
}},
}}
if err := runDocCoverage(t, Fetch, ambiguous, "--query", "项目周报"); err == nil {
t.Fatal("ambiguous title unexpectedly succeeded")
}
if len(ambiguous.history) != 1 || ambiguous.history[0].tool != "search_documents" {
t.Fatalf("ambiguous fetch reached content read: %#v", ambiguous.history)
}
}
func TestCrossPlatformCoverageTemplateGoldenRouteStopsBeforeAmbiguousCreate(t *testing.T) {
payload := map[string]any{
"templates": []any{
map[string]any{"templateId": "template-b", "templateName": "团队周报", "templateSource": "PUBLIC"},
map[string]any{"templateId": "template-a", "templateName": "运营周报", "templateSource": "PUBLIC"},
},
}
candidates := collectTemplateCandidates(payload)
if len(candidates) != 2 || candidates[0]["templateId"] != "template-b" || candidates[1]["templateId"] != "template-a" {
t.Fatalf("template candidates = %#v", candidates)
}
search := &docCoverageCaller{responses: map[string][]map[string]any{"search_doc_templates": {payload}}}
if err := runDocCoverage(t, TemplateSearch, search, "--query", "周报", "--source", "PUBLIC"); err != nil {
t.Fatal(err)
}
if len(search.history) != 1 || search.history[0].tool != "search_doc_templates" {
t.Fatalf("template search calls = %#v", search.history)
}
ambiguous := &docCoverageCaller{responses: map[string][]map[string]any{"search_doc_templates": {payload}}}
err := runDocCoverage(t, CreateFromTemplate, ambiguous, "--query", "周报", "--source", "PUBLIC")
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "template_selection_required" || typed.ExecutionStarted == nil || *typed.ExecutionStarted || !typed.RetryableSet || typed.Retryable {
t.Fatalf("ambiguous template error = %#v", err)
}
if len(ambiguous.history) != 1 || ambiguous.history[0].tool != "search_doc_templates" {
t.Fatalf("ambiguous template reached write: %#v", ambiguous.history)
}
if got, ok := typed.Details["candidates"].([]map[string]any); !ok || len(got) != 2 {
t.Fatalf("ambiguous template details = %#v", typed.Details)
}
notFound := &docCoverageCaller{responses: map[string][]map[string]any{"search_doc_templates": {{"templates": []any{}}}}}
err = runDocCoverage(t, CreateFromTemplate, notFound, "--query", "不存在")
if !errors.As(err, &typed) || typed.Reason != "template_not_found" {
t.Fatalf("not-found template error = %#v", err)
}
if selection := CreateFromTemplate.Contract.Selection; len(selection.AvoidWhen) < 2 || !strings.Contains(strings.Join(selection.AvoidWhen, " "), "+template-search") {
t.Fatalf("create-from-template selection = %#v", selection)
}
mutualRoutes := []struct {
name string
selection contract.SelectionSpec
siblings []string
}{
{name: "+template-list", selection: TemplateList.Contract.Selection, siblings: []string{"+template-search", "+create-from-template"}},
{name: "+template-search", selection: TemplateSearch.Contract.Selection, siblings: []string{"+template-list", "+create-from-template"}},
{name: "+create-from-template", selection: CreateFromTemplate.Contract.Selection, siblings: []string{"+template-list", "+template-search"}},
}
for _, route := range mutualRoutes {
avoid := strings.Join(route.selection.AvoidWhen, " ")
for _, sibling := range route.siblings {
if !strings.Contains(avoid, sibling) {
t.Errorf("%s Selection does not exclude sibling %s: %#v", route.name, sibling, route.selection)
}
}
}
}
func TestCrossPlatformCoverageTemplateCreateProvesUniquenessAcrossPages(t *testing.T) {
firstPage := map[string]any{
"templates": []any{map[string]any{"templateId": "template-a", "templateName": "周报"}},
"hasMore": true,
"nextCursor": "page-2",
}
ambiguous := &docCoverageCaller{responses: map[string][]map[string]any{
"search_doc_templates": {
firstPage,
{"templates": []any{map[string]any{"templateId": "template-b", "templateName": "周报二"}}, "hasMore": false},
},
}}
err := runDocCoverage(t, CreateFromTemplate, ambiguous, "--query", "周报")
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "template_selection_required" {
t.Fatalf("cross-page ambiguity = %#v", err)
}
if len(ambiguous.history) != 2 || ambiguous.history[1].params["nextCursor"] != "page-2" {
t.Fatalf("cross-page search calls = %#v", ambiguous.history)
}
unique := &docCoverageCaller{responses: map[string][]map[string]any{
"search_doc_templates": {
firstPage,
{"templates": []any{}, "hasMore": false},
},
}}
if err := runDocCoverage(t, CreateFromTemplate, unique, "--query", "周报"); err != nil {
t.Fatal(err)
}
if len(unique.history) != 3 || unique.history[2].tool != "apply_doc_template" || unique.history[2].params["templateId"] != "template-a" {
t.Fatalf("unique cross-page create calls = %#v", unique.history)
}
missingCursor := &docCoverageCaller{responses: map[string][]map[string]any{"search_doc_templates": {firstPage}}}
missingCursor.responses["search_doc_templates"][0] = map[string]any{
"templates": []any{map[string]any{"templateId": "template-a"}},
"hasMore": true,
}
err = runDocCoverage(t, CreateFromTemplate, missingCursor, "--query", "周报")
if !errors.As(err, &typed) || typed.Reason != "template_pagination_missing_next_cursor" || len(missingCursor.history) != 1 {
t.Fatalf("missing template cursor = %#v history=%#v", err, missingCursor.history)
}
}
func TestCrossPlatformCoverageExportRecoveryReusesJobAndSafeDownloader(t *testing.T) {
for _, status := range []string{"INIT", "init", " PROCESSING "} {
if !docExportStatusPollable(status) {
t.Errorf("export status %q should remain pollable", status)
}
}
for _, status := range []string{"", "FAILED", "CANCELLED", "SUCCESS"} {
if docExportStatusPollable(status) {
t.Errorf("export status %q should not remain pollable", status)
}
}
init := &docCoverageCaller{responses: map[string][]map[string]any{"query_export_job": {{"status": "INIT"}}}}
err := runDocCoverage(t, Export, init, "--node", "n", "--export-format", "docx", "--output", "export.docx", "--max-polls", "1")
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "doc_export_poll_timeout" {
t.Fatalf("INIT export status = %#v, want poll timeout", err)
}
pollFailure := &docCoverageCaller{failAt: 2, responses: map[string][]map[string]any{}}
err = runDocCoverage(t, Export, pollFailure, "--node", "n", "--export-format", "docx", "--output", "export.docx")
if !errors.As(err, &typed) || typed.Reason != "doc_export_poll_failed" || typed.Details["jobId"] != "job-1" || typed.ExecutionStarted == nil || !*typed.ExecutionStarted {
t.Fatalf("export poll error = %#v", err)
}
if !strings.Contains(strings.Join(typed.Actions, " "), "+export-get --job-id job-1") {
t.Fatalf("export recovery actions = %#v", typed.Actions)
}
t.Chdir(t.TempDir())
testseam.Swap(t, &docDownload, func(_ context.Context, _ string, opts localio.DownloadOptions) (localio.DownloadResult, error) {
if opts.Output != "recovered.docx" {
t.Fatalf("recovery output = %#v", opts)
}
return localio.DownloadResult{RelativePath: "recovered.docx", SizeBytes: 42}, nil
})
recovery := &docCoverageCaller{responses: map[string][]map[string]any{
"query_export_job": {{"status": "SUCCESS", "downloadUrl": "https://download.dingtalk.com/recovered.docx"}},
}}
if err := runDocCoverage(t, ExportGet, recovery, "--job-id", "job-1", "--output", "recovered.docx"); err != nil {
t.Fatal(err)
}
if len(recovery.history) != 1 || recovery.history[0].tool != "query_export_job" {
t.Fatalf("export recovery calls = %#v", recovery.history)
}
if err := runDocCoverage(t, ExportGet, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--job-id", "job-1", "--output", "/tmp/unsafe.docx"); err == nil {
t.Fatal("absolute recovery output unexpectedly accepted")
}
}
func TestCrossPlatformCoverageCommentListPaginationFlagsAndAlias(t *testing.T) {
caller := &docCoverageCaller{responses: map[string][]map[string]any{}}
if err := runDocCoverage(t, CommentList, caller, "--node", "n", "--page-size", "20", "--cursor", "next-1"); err != nil {
t.Fatal(err)
}
if len(caller.history) != 1 || caller.history[0].tool != "list_comments" || caller.history[0].params["pageSize"] != 20 || caller.history[0].params["nextToken"] != "next-1" {
t.Fatalf("comment pagination call = %#v", caller.history)
}
}
func TestCrossPlatformCoverageMediaFailuresKeepStableIDsAndForbidPathEscape(t *testing.T) {
if err := runDocCoverage(t, MediaInsert, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--file", "/tmp/media.bin", "--dry-run", "--yes"); err == nil {
t.Fatal("absolute media input unexpectedly accepted")
}
if err := runDocCoverage(t, Import, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--file", "/tmp/report.docx", "--workspace", "workspace-1"); err == nil {
t.Fatal("absolute import input unexpectedly accepted")
}
resolveFailure := &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}}
err := runDocCoverage(t, MediaDownload, resolveFailure, "--node", "node-1", "--resource-id", "resource-1", "--output", "media.bin")
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "doc_media_resolve_failed" || typed.Details["nodeId"] != "node-1" || typed.Details["resourceId"] != "resource-1" {
t.Fatalf("media recovery error = %#v", err)
}
if !strings.Contains(strings.Join(typed.Actions, " "), "不要 curl/wget") {
t.Fatalf("media recovery actions = %#v", typed.Actions)
}
}
func TestCrossPlatformCoverageDocCompositePartialWriteContracts(t *testing.T) {
assertPartial := func(t *testing.T, err error, reason, stage, nodeID string, wantSteps int) *apperrors.Error {
t.Helper()
@@ -184,7 +412,7 @@ func TestCrossPlatformCoverageDocCompositePartialWriteContracts(t *testing.T) {
}
create := &docCoverageCaller{failAt: 2, responses: map[string][]map[string]any{}}
err := runDocCoverage(t, Create, create, "--name", "n", "--content", `[]`, "--doc-format", "jsonml")
err := runDocCoverage(t, Create, create, "--name", "n", "--content", `["root",{}]`, "--doc-format", "jsonml")
typed := assertPartial(t, err, "doc_create_initial_content_failed", "write_jsonml", "node-1", 2)
compensation, _ := typed.Details["compensation"].(map[string]any)
if compensation["available"] != true || compensation["nodeId"] != "node-1" || len(create.history) != 2 {
@@ -206,6 +434,13 @@ func TestCrossPlatformCoverageDocCompositePartialWriteContracts(t *testing.T) {
err = runDocCoverage(t, CheckpointUpdate, checkpointVerify, "--node", "n", "--content", "body", "--yes")
assertPartial(t, err, "doc_checkpoint_verification_failed", "verify", "n", 3)
checkpointMismatch := &docCoverageCaller{responses: map[string][]map[string]any{
"save_doc_version": {{"version": 8.0}},
"get_document_content": {{"markdown": "different content"}},
}}
err = runDocCoverage(t, CheckpointUpdate, checkpointMismatch, "--node", "n", "--content", "body", "--yes")
assertPartial(t, err, "doc_checkpoint_verification_failed", "verify", "n", 3)
historyVerify := &docCoverageCaller{failAt: 3, responses: map[string][]map[string]any{}}
err = runDocCoverage(t, VersionRevert, historyVerify, "--node", "n", "--version", "3", "--yes")
assertPartial(t, err, "doc_history_revert_verification_failed", "verify", "n", 3)
@@ -215,22 +450,31 @@ func TestCrossPlatformCoverageDocUpdateAliasReachesNestedBranches(t *testing.T)
tests := []struct {
name string
args []string
responses map[string][]map[string]any
wantTools []string
}{
{
name: "plain text replace",
args: []string{"--doc", "alias-node", "--command", "str_replace", "--old", "alpha", "--new", "gamma", "--yes"},
wantTools: []string{"list_document_blocks", "update_document_block"},
name: "plain text replace",
args: []string{"--doc", "alias-node", "--command", "str_replace", "--old", "alpha", "--new", "gamma", "--yes"},
responses: map[string][]map[string]any{"list_document_blocks": {
{"blocks": []any{map[string]any{"element": map[string]any{"id": "block-1", "paragraph": map[string]any{"text": "alpha beta"}}}}},
{"blocks": []any{map[string]any{"element": map[string]any{"id": "block-1", "paragraph": map[string]any{"text": "gamma beta"}}}}},
}},
wantTools: []string{"list_document_blocks", "update_document_block", "list_document_blocks"},
},
{
name: "block copy",
args: []string{"--doc", "alias-node", "--command", "block_copy_insert_after", "--block-id", "block-1", "--after-block-id", "after", "--yes"},
wantTools: []string{"list_document_blocks", "insert_document_block"},
name: "block copy",
args: []string{"--doc", "alias-node", "--command", "block_copy_insert_after", "--block-id", "block-1", "--after-block-id", "after", "--yes"},
responses: map[string][]map[string]any{"list_document_blocks": {
{"blocks": []any{map[string]any{"element": map[string]any{"id": "block-1", "paragraph": map[string]any{"text": "alpha"}}}}},
{"blocks": []any{map[string]any{"element": map[string]any{"id": "id-1", "paragraph": map[string]any{"text": "alpha"}}}}},
}},
wantTools: []string{"list_document_blocks", "insert_document_block", "list_document_blocks"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
caller := &docCoverageCaller{responses: map[string][]map[string]any{}}
caller := &docCoverageCaller{responses: tc.responses}
if err := runDocCoverage(t, Update, caller, tc.args...); err != nil {
t.Fatal(err)
}
@@ -246,6 +490,246 @@ func TestCrossPlatformCoverageDocUpdateAliasReachesNestedBranches(t *testing.T)
}
}
func TestCrossPlatformCoverageDocWritesStopOnUnknownCommitAndRequireVerification(t *testing.T) {
unknown := &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}}
err := runDocCoverage(t, Update, unknown, "--node", "n", "--command", "append", "--content", "x", "--yes")
var typed *apperrors.Error
if err == nil || !errors.As(err, &typed) || typed.Reason != "doc_write_commit_unknown" || !typed.RetryableSet || typed.Retryable {
t.Fatalf("unknown write error = %#v", err)
}
if len(unknown.history) != 1 || unknown.history[0].tool != "update_document" {
t.Fatalf("unknown write was replayed or verified: %#v", unknown.history)
}
verificationFailure := &docCoverageCaller{failAt: 2, responses: map[string][]map[string]any{}}
err = runDocCoverage(t, Update, verificationFailure, "--node", "n", "--command", "append", "--content", "x", "--yes")
if err == nil || !errors.As(err, &typed) || typed.Reason != "doc_write_verification_failed" || typed.Details["status"] != "partial_success" {
t.Fatalf("verification failure = %#v", err)
}
if len(verificationFailure.history) != 2 || verificationFailure.history[1].tool != "get_document_content" {
t.Fatalf("verification calls = %#v", verificationFailure.history)
}
}
func TestCrossPlatformCoverageDocCreateRejectsSuccessfulMismatchedReadback(t *testing.T) {
caller := &docCoverageCaller{responses: map[string][]map[string]any{
"get_document_content": {{"markdown": "truncated"}},
}}
err := runDocCoverage(t, Create, caller, "--name", "n", "--content", "complete body")
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "doc_write_verification_failed" || typed.FailureStage != "verify" {
t.Fatalf("create mismatch error = %#v, want verification failure", err)
}
if len(caller.history) != 2 || caller.history[0].tool != "create_document" || caller.history[1].tool != "get_document_content" {
t.Fatalf("create mismatch calls = %#v, want one write followed by one read", caller.history)
}
}
func TestCrossPlatformCoverageDocJSONMLBlockVerificationUsesJSONMLReadback(t *testing.T) {
for _, test := range []struct {
name string
command string
blockArg string
response string
}{
{name: "insert", command: "block_insert_after", blockArg: "--after-block-id", response: `["root",{},["p",{"uuid":"ref"},"before"],["p",{"uuid":"id-1"},"after"]]`},
{name: "replace", command: "block_replace", blockArg: "--block-id", response: `["root",{},["p",{"uuid":"target"},"after"]]`},
} {
t.Run(test.name, func(t *testing.T) {
blockID := "ref"
if test.command == "block_replace" {
blockID = "target"
}
caller := &docCoverageCaller{responses: map[string][]map[string]any{
"list_document_blocks": {{"jsonml": test.response}},
}}
err := runDocCoverage(t, Update, caller,
"--node", "n", "--command", test.command, test.blockArg, blockID,
"--content", `["p",{},"after"]`, "--doc-format", "jsonml", "--yes")
if err != nil {
t.Fatal(err)
}
if got := caller.history[len(caller.history)-1].params["format"]; got != "jsonml" {
t.Fatalf("verification format = %#v, want jsonml", got)
}
})
}
}
func TestCrossPlatformCoverageDocVersionRevertPaginationAndVerification(t *testing.T) {
t.Run("target on second page", func(t *testing.T) {
caller := &docCoverageCaller{responses: map[string][]map[string]any{
"list_doc_versions": {
{"versions": []any{map[string]any{"version": 1.0}}, "hasMore": true, "nextCursor": "page-2"},
{"versions": []any{map[string]any{"version": 3.0}}, "hasMore": false},
},
}}
if err := runDocCoverage(t, VersionRevert, caller, "--node", "n", "--version", "3", "--yes"); err != nil {
t.Fatal(err)
}
if len(caller.history) != 4 || caller.history[1].params["nextCursor"] != "page-2" {
t.Fatalf("version pagination calls = %#v", caller.history)
}
})
t.Run("empty write response and ordinary document info", func(t *testing.T) {
caller := &docCoverageCaller{responses: map[string][]map[string]any{
"revert_doc_version": {{}},
"get_document_info": {{"nodeId": "n", "revision": 99.0}},
}}
if err := runDocCoverage(t, VersionRevert, caller, "--node", "n", "--version", "3", "--yes"); err != nil {
t.Fatal(err)
}
})
for _, test := range []struct {
name string
responses []map[string]any
want string
}{
{name: "not found", responses: []map[string]any{{"versions": []any{}, "hasMore": false}}, want: "目标版本 3 不存在"},
{name: "missing cursor", responses: []map[string]any{{"versions": []any{}, "hasMore": true}}, want: "无法证明分页已经完整"},
{name: "stalled cursor", responses: []map[string]any{
{"versions": []any{}, "hasMore": true, "nextCursor": "same"},
{"versions": []any{}, "hasMore": true, "nextCursor": "same"},
}, want: "无法证明分页已经完整"},
} {
t.Run(test.name, func(t *testing.T) {
caller := &docCoverageCaller{responses: map[string][]map[string]any{"list_doc_versions": test.responses}}
err := runDocCoverage(t, VersionRevert, caller, "--node", "n", "--version", "3", "--yes")
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("version preflight error = %v, want %q", err, test.want)
}
for _, call := range caller.history {
if call.tool == "revert_doc_version" {
t.Fatalf("preflight failure executed revert: %#v", caller.history)
}
}
})
}
pages := make([]map[string]any, 20)
for index := range pages {
pages[index] = map[string]any{"versions": []any{}, "hasMore": true, "nextCursor": fmt.Sprintf("page-%d", index+1)}
}
caller := &docCoverageCaller{responses: map[string][]map[string]any{"list_doc_versions": pages}}
if err := runDocCoverage(t, VersionRevert, caller, "--node", "n", "--version", "3", "--yes"); err == nil || !strings.Contains(err.Error(), "无法证明分页已经完整") {
t.Fatalf("max-pages preflight error = %v", err)
}
for _, value := range []any{json.Number("3"), 3, "3"} {
if !versionNumberMatches(value, 3) {
t.Errorf("version value %#v did not match", value)
}
}
for _, value := range []any{json.Number("bad"), 4, "bad", true} {
if versionNumberMatches(value, 3) {
t.Errorf("version value %#v unexpectedly matched", value)
}
}
if !versionEvidenceMatches([]any{map[string]any{"target-version": "3"}}, 3, map[string]bool{"targetversion": true}) {
t.Fatal("nested version evidence did not match")
}
if versionEvidenceMatches(map[string]any{"version": 4}, 3, map[string]bool{"version": true}) {
t.Fatal("mismatched version evidence unexpectedly matched")
}
}
func TestCrossPlatformCoverageDocWriteErrorStateMachine(t *testing.T) {
tests := []struct {
name string
cause error
wantState string
wantReason string
wantRetry bool
}{
{
name: "permission is terminal",
cause: apperrors.NewAuth("forbidden", apperrors.WithReason("http_403"), apperrors.WithRetryable(false)),
wantState: "failed",
wantReason: "permission_denied",
},
{
name: "confirmed not started may retry",
cause: apperrors.NewAPI("rate limited", apperrors.WithReason("http_429"),
apperrors.WithExecutionStarted(false), apperrors.WithRetryable(true)),
wantState: "retryable",
wantReason: "http_429",
wantRetry: true,
},
{
name: "unknown write never replays",
cause: errors.New("connection reset"),
wantState: "unknown",
wantReason: "doc_write_commit_unknown",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := docUnknownWriteError("doc.update", "update_document", "n", tc.cause)
var typed *apperrors.Error
if !errors.As(err, &typed) {
t.Fatalf("error = %#v", err)
}
if typed.Details["state"] != tc.wantState || typed.Reason != tc.wantReason || typed.Retryable != tc.wantRetry {
t.Fatalf("transition = reason=%q retry=%v details=%#v", typed.Reason, typed.Retryable, typed.Details)
}
})
}
}
func TestCrossPlatformCoverageDocLongWritesChunkOnceAndVerify(t *testing.T) {
long := strings.Repeat("段落😀", 4000)
chunks := splitDocMarkdown(long, 10000)
if len(chunks) < 2 || strings.Join(chunks, "") != long {
t.Fatalf("split chunks=%d roundtrip=%v", len(chunks), strings.Join(chunks, "") == long)
}
update := &docCoverageCaller{responses: map[string][]map[string]any{
"get_document_content": {{"markdown": long}},
}}
if err := runDocCoverage(t, Update, update, "--node", "n", "--command", "overwrite", "--content", long, "--yes"); err != nil {
t.Fatal(err)
}
if len(update.history) != len(chunks)+1 || update.history[0].params["mode"] != "overwrite" || update.history[1].params["mode"] != "append" || update.history[len(update.history)-1].tool != "get_document_content" {
t.Fatalf("long update calls = %#v", update.history)
}
partial := &docCoverageCaller{failAt: 2, responses: map[string][]map[string]any{}}
err := runDocCoverage(t, Update, partial, "--node", "n", "--command", "append", "--content", long, "--yes")
var typed *apperrors.Error
if err == nil || !errors.As(err, &typed) || typed.Reason != "doc_update_chunk_commit_unknown" || len(partial.history) != 2 {
t.Fatalf("partial long update err=%#v calls=%#v", err, partial.history)
}
create := &docCoverageCaller{responses: map[string][]map[string]any{
"get_document_content": {{"markdown": long}},
}}
if err := runDocCoverage(t, Create, create, "--name", "long", "--content", long); err != nil {
t.Fatal(err)
}
if len(create.history) != len(chunks)+1 || create.history[0].tool != "create_document" || create.history[1].tool != "update_document" || create.history[len(create.history)-1].tool != "get_document_content" {
t.Fatalf("long create calls = %#v", create.history)
}
}
func TestCrossPlatformCoverageUpdateVerificationNormalizesMarkdownRoundTrip(t *testing.T) {
expected := "# 值班表\r\n\r\n| 姓名 | 班次 |\r\n| --- | --- |\r\n| 小王 | 早班 |\r\n"
serverMarkdown := "# 值班表\n\n|姓名|班次|\n|---|---|\n|小王|早班|\n"
caller := &docCoverageCaller{responses: map[string][]map[string]any{
"get_document_content": {{"data": map[string]any{"markdown": serverMarkdown}}},
}}
if err := runDocCoverage(t, Update, caller, "--node", "n", "--command", "overwrite", "--content", expected, "--yes"); err != nil {
t.Fatalf("normalized Markdown verification failed: %v", err)
}
if !verifyUpdatedDocumentContent(map[string]any{"markdown": "已有内容\n" + serverMarkdown}, expected, "append", "markdown") {
t.Fatal("normalized append verification did not find the appended content")
}
if verifyUpdatedDocumentContent(map[string]any{"markdown": "# 值班表\n|姓名|班次|"}, expected, "overwrite", "markdown") {
t.Fatal("incomplete overwrite content passed normalized verification")
}
}
func TestCrossPlatformCoverageSelectionMatchesEnumerateEveryCandidate(t *testing.T) {
tests := []struct {
name string
@@ -378,20 +862,20 @@ func TestCrossPlatformCoverageDocContentCommandsAndFailureBoundaries(t *testing.
{"inspect base", Inspect, []string{"--node", "n"}},
{"inspect all", Inspect, []string{"--node", "n", "--include-style", "--include-permissions", "--include-history", "--include-media", "--include-comments"}},
{"update append", Update, []string{"--node", "n", "--command", "append", "--content", "x", "--yes"}},
{"update overwrite jsonml", Update, []string{"--node", "n", "--command", "overwrite", "--content", `[]`, "--doc-format", "jsonml", "--yes"}},
{"update overwrite jsonml", Update, []string{"--node", "n", "--command", "overwrite", "--content", `["root",{}]`, "--doc-format", "jsonml", "--yes"}},
{"update insert text", Update, []string{"--node", "n", "--command", "block_insert_after", "--after-block-id", "b", "--content", "x", "--yes"}},
{"update insert jsonml", Update, []string{"--node", "n", "--command", "block_insert_after", "--after-block-id", "b", "--content", `[]`, "--doc-format", "jsonml", "--yes"}},
{"update insert jsonml", Update, []string{"--node", "n", "--command", "block_insert_after", "--after-block-id", "b", "--content", `["p",{},"x"]`, "--doc-format", "jsonml", "--yes"}},
{"update replace text", Update, []string{"--node", "n", "--command", "block_replace", "--block-id", "b", "--content", "x", "--yes"}},
{"update replace jsonml", Update, []string{"--node", "n", "--command", "block_replace", "--block-id", "b", "--content", `[]`, "--doc-format", "jsonml", "--yes"}},
{"update replace jsonml", Update, []string{"--node", "n", "--command", "block_replace", "--block-id", "b", "--content", `["p",{},"x"]`, "--doc-format", "jsonml", "--yes"}},
{"update delete", Update, []string{"--node", "n", "--command", "block_delete", "--block-id", "b", "--yes"}},
{"update replace", Update, []string{"--node", "n", "--command", "str_replace", "--old", "alpha", "--new", "gamma", "--yes"}},
{"update copy", Update, []string{"--node", "n", "--command", "block_copy_insert_after", "--block-id", "block-1", "--after-block-id", "b", "--yes"}},
{"update revision", Update, []string{"--node", "n", "--command", "append", "--content", "x", "--expected-revision", "1", "--yes"}},
{"update revision", Update, []string{"--node", "n", "--command", "overwrite", "--content", `["root",{}]`, "--doc-format", "jsonml", "--expected-revision", "1", "--yes"}},
{"update dry", Update, []string{"--node", "n", "--command", "append", "--content", "x", "--dry-run", "--yes"}},
{"checkpoint dry", CheckpointUpdate, []string{"--node", "n", "--content", "x", "--dry-run", "--yes"}},
{"checkpoint success", CheckpointUpdate, []string{"--node", "n", "--content", "x", "--yes"}},
{"export dry", Export, []string{"--node", "n", "--output", "out.docx", "--dry-run"}},
{"export success", Export, []string{"--node", "n", "--output", "out.docx"}},
{"export dry", Export, []string{"--node", "n", "--export-format", "docx", "--output", "out.docx", "--dry-run"}},
{"export success", Export, []string{"--node", "n", "--export-format", "docx", "--output", "out.docx"}},
}
testseam.Swap(t, &docDownload, func(_ context.Context, _ string, _ localio.DownloadOptions) (localio.DownloadResult, error) {
@@ -400,6 +884,43 @@ func TestCrossPlatformCoverageDocContentCommandsAndFailureBoundaries(t *testing.
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
caller := &docCoverageCaller{responses: map[string][]map[string]any{}}
switch tc.name {
case "create markdown":
caller.responses["get_document_content"] = []map[string]any{{"markdown": "body"}}
case "create jsonml file":
caller.responses["get_document_content"] = []map[string]any{{"jsonml": `["root",{},"body"]`}}
case "create stdin":
caller.responses["get_document_content"] = []map[string]any{{"markdown": "stdin body"}}
}
if tc.name == "update overwrite jsonml" || tc.name == "update revision" {
caller.responses["get_document_content"] = []map[string]any{{"jsonml": `["root",{}]`}}
}
switch tc.name {
case "update append":
caller.responses["get_document_content"] = []map[string]any{{"markdown": "existing\nx"}}
case "update insert text":
caller.responses["list_document_blocks"] = []map[string]any{{"items": []any{map[string]any{"id": "b", "text": "reference"}, map[string]any{"id": "id-1", "text": "x"}}}}
case "update insert jsonml":
caller.responses["list_document_blocks"] = []map[string]any{{"jsonml": `["root",{},["p",{"uuid":"b"},"reference"],["p",{"uuid":"id-1"},"x"]]`}}
case "update replace text":
caller.responses["list_document_blocks"] = []map[string]any{{"items": []any{map[string]any{"id": "b", "text": "x"}}}}
case "update replace jsonml":
caller.responses["list_document_blocks"] = []map[string]any{{"jsonml": `["root",{},["p",{"uuid":"b"},"x"]]`}}
case "update delete":
caller.responses["list_document_blocks"] = []map[string]any{{"items": []any{map[string]any{"id": "other", "text": "x"}}}}
case "update replace":
caller.responses["list_document_blocks"] = []map[string]any{
{"items": []any{map[string]any{"id": "block-1", "text": "alpha beta"}}},
{"items": []any{map[string]any{"id": "block-1", "text": "gamma beta"}}},
}
case "update copy":
caller.responses["list_document_blocks"] = []map[string]any{
{"items": []any{map[string]any{"id": "block-1", "text": "alpha beta"}}},
{"items": []any{map[string]any{"id": "b", "text": "reference"}, map[string]any{"id": "id-1", "text": "alpha beta"}}},
}
case "checkpoint success":
caller.responses["get_document_content"] = []map[string]any{{"markdown": "existing\nx"}}
}
var err error
if tc.name == "create stdin" {
err = runDocCoverageInput(t, tc.cmd, caller, strings.NewReader("stdin body"), tc.args...)
@@ -415,18 +936,73 @@ func TestCrossPlatformCoverageDocContentCommandsAndFailureBoundaries(t *testing.
for _, command := range []shortcut.Shortcut{Create, Fetch, Inspect, Update, CheckpointUpdate, Export} {
for failAt := 1; failAt <= 7; failAt++ {
args := map[string][]string{
"+create": {"--name", "n", "--content", `[]`, "--doc-format", "jsonml"},
"+create": {"--name", "n", "--content", `["root",{}]`, "--doc-format", "jsonml"},
"+fetch": {"--node", "n", "--scope", "keyword", "--keyword", "x"},
"+inspect": {"--node", "n", "--include-style", "--include-permissions", "--include-history", "--include-media", "--include-comments"},
"+update": {"--node", "n", "--command", "append", "--content", "x", "--expected-revision", "1", "--yes"},
"+update": {"--node", "n", "--command", "overwrite", "--content", `["root",{}]`, "--doc-format", "jsonml", "--expected-revision", "1", "--yes"},
"+checkpoint-update": {"--node", "n", "--content", "x", "--yes"},
"+export": {"--node", "n", "--output", "out.docx"},
"+export": {"--node", "n", "--export-format", "docx", "--output", "out.docx"},
}[command.Command]
_ = runDocCoverage(t, command, &docCoverageCaller{failAt: failAt, responses: map[string][]map[string]any{}}, args...)
}
}
}
func TestCrossPlatformCoverageUpdateContractAndPreflight(t *testing.T) {
flags := make(map[string]shortcut.Flag, len(Update.Flags))
for _, flag := range Update.Flags {
flags[flag.Name] = flag
}
if !flags["node"].Required || flags["command"].Required {
t.Fatalf("unconditional required flags: node=%v command=%v", flags["node"].Required, flags["command"].Required)
}
for _, name := range []string{"content", "block-id", "after-block-id", "old", "new"} {
if got := flags[name].RequiredWhen; got != "" {
t.Errorf("--%s RequiredWhen = %q, want compatibility-safe custom constraint", name, got)
}
}
if len(Update.Constraints) != 1 || Update.Constraints[0].Kind != shortcut.ConstraintCustom ||
!strings.Contains(Update.Constraints[0].Description, "依 command 校验") {
t.Fatalf("update custom constraint = %#v", Update.Constraints)
}
cmd := corecmd.New(shortcut.FromShortcut(Update))
for _, alias := range []string{"doc", "text"} {
flag := cmd.Flags().Lookup(alias)
if flag == nil {
t.Errorf("compatibility alias --%s is not mounted", alias)
}
}
invalid := []struct {
name string
args []string
want string
}{
{name: "missing command", args: []string{"--node", "n"}, want: "--command"},
{name: "insert missing reference", args: []string{"--node", "n", "--command", "block_insert_after", "--content", "x"}, want: "--after-block-id"},
{name: "copy missing reference", args: []string{"--node", "n", "--command", "block_copy_insert_after", "--block-id", "b"}, want: "--after-block-id"},
{name: "jsonml append", args: []string{"--node", "n", "--command", "append", "--content", `["root",{}]`, "--doc-format", "jsonml"}, want: "JSONML 当前不支持 append"},
{name: "revision without server CAS path", args: []string{"--node", "n", "--command", "append", "--content", "x", "--expected-revision", "1"}, want: "仅支持 --command overwrite --doc-format jsonml"},
}
for _, test := range invalid {
t.Run(test.name, func(t *testing.T) {
caller := &docCoverageCaller{responses: map[string][]map[string]any{}}
err := runDocCoverage(t, Update, caller, test.args...)
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("error = %v, want %q", err, test.want)
}
if strings.Contains(err.Error(), "confirmation_required") || caller.calls != 0 {
t.Fatalf("invalid input reached confirmation or execution: err=%v calls=%d", err, caller.calls)
}
})
}
if err := runDocCoverage(t, Update, &docCoverageCaller{responses: map[string][]map[string]any{}},
"--node", "n", "--command", "str_replace", "--old", "x", "--new", "", "--dry-run"); err != nil {
t.Fatalf("explicit empty --new should satisfy str_replace contract: %v", err)
}
}
func TestCrossPlatformCoverageDocContentValidationAndPureHelpers(t *testing.T) {
t.Chdir(t.TempDir())
if err := os.WriteFile("body.md", []byte("body"), 0o600); err != nil {
@@ -442,6 +1018,8 @@ func TestCrossPlatformCoverageDocContentValidationAndPureHelpers(t *testing.T) {
{Create, []string{"--name", "n", "--content", "@/absolute"}},
{Create, []string{"--name", "n", "--content", "not-json", "--doc-format", "jsonml"}},
{Create, []string{"--name", "n", "--content", `{}`, "--doc-format", "jsonml"}},
{Create, []string{"--name", "n", "--content", `[]`, "--doc-format", "jsonml"}},
{Create, []string{"--name", "n", "--content", `[["p",{},"x"]]`, "--doc-format", "jsonml"}},
{Fetch, []string{"--node", "n", "--revision", "1"}},
{Fetch, []string{"--node", "n", "--scope", "keyword"}},
{Update, []string{"--node", "n"}},
@@ -449,25 +1027,28 @@ func TestCrossPlatformCoverageDocContentValidationAndPureHelpers(t *testing.T) {
{Update, []string{"--node", "n", "--command", "append", "--yes"}},
{Update, []string{"--node", "n", "--command", "block_delete", "--yes"}},
{Update, []string{"--node", "n", "--command", "str_replace", "--old", "x", "--yes"}},
{Update, []string{"--node", "n", "--command", "append", "--content", `[]`, "--doc-format", "jsonml", "--yes"}},
{Update, []string{"--node", "n", "--command", "append", "--content", `["root",{}]`, "--doc-format", "jsonml", "--yes"}},
}
for _, tc := range badCases {
if err := runDocCoverage(t, tc.cmd, &docCoverageCaller{responses: map[string][]map[string]any{}}, tc.args...); err == nil {
t.Errorf("%s %#v unexpectedly succeeded", tc.cmd.Command, tc.args)
}
}
absoluteInputErr := runDocCoverage(t, Create, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--name", "n", "--content", "@/absolute")
if absoluteInputErr == nil || !strings.Contains(absoluteInputErr.Error(), "暂存到工作目录") || !strings.Contains(absoluteInputErr.Error(), "stdin") {
t.Fatalf("absolute @file guidance = %v", absoluteInputErr)
}
createNoNode := &docCoverageCaller{responses: map[string][]map[string]any{"create_document": {{"ok": true}}}}
if err := runDocCoverage(t, Create, createNoNode, "--name", "n", "--content", `[]`, "--doc-format", "jsonml"); err == nil {
if err := runDocCoverage(t, Create, createNoNode, "--name", "n", "--content", `["root",{}]`, "--doc-format", "jsonml"); err == nil {
t.Fatal("jsonml create without node id succeeded")
}
conflict := &docCoverageCaller{responses: map[string][]map[string]any{"get_document_content": {{"revision": 2}}}}
if err := runDocCoverage(t, Update, conflict, "--node", "n", "--command", "append", "--content", "x", "--expected-revision", "1", "--yes"); err == nil {
t.Fatal("revision conflict succeeded")
revision := &docCoverageCaller{responses: map[string][]map[string]any{"get_document_content": {{"jsonml": `["root",{}]`}}}}
if err := runDocCoverage(t, Update, revision, "--node", "n", "--command", "overwrite", "--content", `["root",{}]`, "--doc-format", "jsonml", "--expected-revision", "7", "--yes"); err != nil {
t.Fatalf("server revision update failed: %v", err)
}
missingRevision := &docCoverageCaller{responses: map[string][]map[string]any{"get_document_content": {{"ok": true}}}}
if err := runDocCoverage(t, Update, missingRevision, "--node", "n", "--command", "append", "--content", "x", "--expected-revision", "1", "--yes"); err == nil {
t.Fatal("missing revision succeeded")
if len(revision.history) < 1 || revision.history[0].tool != "update_document" || revision.history[0].params["revision"] != 7 {
t.Fatalf("expected revision was not forwarded atomically: %#v", revision.history)
}
for _, value := range []any{
@@ -482,6 +1063,9 @@ func TestCrossPlatformCoverageDocContentValidationAndPureHelpers(t *testing.T) {
if _, err := validateJSONML(`{}`); err == nil {
t.Fatal("object jsonml succeeded")
}
if _, err := validateJSONML(`[["p",{},"x"]]`); err == nil {
t.Fatal("nested element-array jsonml succeeded")
}
if nestedMap(map[string]any{"result": map[string]any{"data": map[string]any{"x": 1}}})["x"] != 1 {
t.Fatal("nestedMap did not unwrap")
}
@@ -496,8 +1080,11 @@ func TestCrossPlatformCoverageDocContentValidationAndPureHelpers(t *testing.T) {
_ = containsResourceReference([]any{map[string]any{"x": "y"}})
stripBlockIDs(blocks)
if err := runDocCoveragePath(t, Update, &docCoverageCaller{responses: map[string][]map[string]any{}}, strings.NewReader(""), "+update", "--doc", "n", "--command", "append", "--text", "legacy", "--yes"); err != nil {
t.Fatalf("visible update flag aliases: %v", err)
aliasCaller := &docCoverageCaller{responses: map[string][]map[string]any{
"get_document_content": {{"markdown": "legacy"}},
}}
if err := runDocCoveragePath(t, Update, aliasCaller, strings.NewReader(""), "+update", "--doc", "n", "--command", "append", "--text", "legacy", "--yes"); err != nil {
t.Fatalf("compatibility aliases --doc/--text must remain executable: %v", err)
}
missingNode := Update
missingNode.Flags = append([]shortcut.Flag(nil), Update.Flags...)
@@ -562,19 +1149,19 @@ func TestCrossPlatformCoverageDocContentValidationAndPureHelpers(t *testing.T) {
{"jobId": "j"},
} {
caller := &docCoverageCaller{responses: map[string][]map[string]any{"submit_export_job": {response}, "query_export_job": {{"status": "FAILED", "message": "bad"}}}}
_ = runDocCoverage(t, Export, caller, "--node", "n", "--output", "x", "--max-polls", "1")
_ = runDocCoverage(t, Export, caller, "--node", "n", "--export-format", "docx", "--output", "x", "--max-polls", "1")
}
timeout := &docCoverageCaller{responses: map[string][]map[string]any{"query_export_job": {{"status": "PROCESSING"}}}}
_ = runDocCoverage(t, Export, timeout, "--node", "n", "--output", "x", "--max-polls", "1")
_ = runDocCoverage(t, Export, timeout, "--node", "n", "--export-format", "docx", "--output", "x", "--max-polls", "1")
processingThenSuccess := &docCoverageCaller{responses: map[string][]map[string]any{"query_export_job": {{"status": "PROCESSING"}, {"status": "SUCCESS", "downloadUrl": "https://download.dingtalk.com/x"}}}}
_ = runDocCoverage(t, Export, processingThenSuccess, "--node", "n", "--output", "x", "--max-polls", "2")
_ = runDocCoverage(t, Export, processingThenSuccess, "--node", "n", "--export-format", "docx", "--output", "x", "--max-polls", "2")
cancelled, cancel := context.WithCancel(context.Background())
cancel()
cancelledCaller := &docCoverageCaller{ctx: cancelled, responses: map[string][]map[string]any{"query_export_job": {{"status": "PROCESSING"}}}}
_ = runDocCoverage(t, Export, cancelledCaller, "--node", "n", "--output", "x", "--max-polls", "2")
_ = runDocCoverage(t, Export, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--output", "x", "--max-polls", "0")
_ = runDocCoverage(t, Export, cancelledCaller, "--node", "n", "--export-format", "docx", "--output", "x", "--max-polls", "2")
_ = runDocCoverage(t, Export, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--export-format", "docx", "--output", "x", "--max-polls", "0")
missingURL := &docCoverageCaller{responses: map[string][]map[string]any{"query_export_job": {{"status": "SUCCESS"}}}}
_ = runDocCoverage(t, Export, missingURL, "--node", "n", "--output", "x")
_ = runDocCoverage(t, Export, missingURL, "--node", "n", "--export-format", "docx", "--output", "x")
}
func TestCrossPlatformCoverageDocHistoryTemplateReviewAndMedia(t *testing.T) {
@@ -596,7 +1183,7 @@ func TestCrossPlatformCoverageDocHistoryTemplateReviewAndMedia(t *testing.T) {
{CommentUpdate, []string{"--node", "n", "--comment-key", "c", "--content", "x", "--mention", "u"}},
{CommentDelete, []string{"--node", "n", "--comment-key", "c", "--dry-run", "--yes"}},
{CommentCreate, []string{"--node", "n", "--content", "x", "--yes"}},
{CommentCreate, []string{"--node", "n", "--content", "x", "--block-id", "b", "--start", "0", "--end", "1", "--selected-text", "a", "--mention", "u", "--yes"}},
{CommentCreate, []string{"--node", "n", "--content", "x", "--block-id", "block-1", "--start", "0", "--end", "1", "--selected-text", "a", "--mention", "u", "--yes"}},
{CommentCreate, []string{"--node", "n", "--content", "x", "--selection", "alpha", "--yes"}},
{MediaList, []string{"--node", "n"}},
{MediaPreview, []string{"--node", "n", "--resource-id", "r"}},
@@ -620,7 +1207,7 @@ func TestCrossPlatformCoverageDocHistoryTemplateReviewAndMedia(t *testing.T) {
for _, declaration := range []shortcut.Shortcut{VersionRevert, CreateFromTemplate, Review, MediaList, MediaDownload, ResourceDownload} {
args := map[string][]string{
"+history-revert": {"--node", "n", "--version", "3", "--yes"},
"+version-revert": {"--node", "n", "--version", "3", "--yes"},
"+create-from-template": {"--query", "q"},
"+review": {"--node", "n"},
"+media-list": {"--node", "n"},
@@ -676,6 +1263,27 @@ func TestCrossPlatformCoverageDocHistoryTemplateReviewAndMedia(t *testing.T) {
_ = runDocCoverage(t, MediaInsert, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--file", "media.bin", "--dry-run", "--yes")
_ = runDocCoverage(t, ResourceUpdate, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--image", "https://example.com/cover.png", "--dry-run", "--yes")
_ = runDocCoverage(t, Import, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--file", "media.bin", "--folder", "f", "--dry-run")
if err := runDocCoverage(t, Import, &docCoverageCaller{dryRun: true, responses: map[string][]map[string]any{}}, "--file", "media.bin", "--dry-run"); err != nil {
t.Fatalf("import to default root failed: %v", err)
}
if err := runDocCoverage(t, Export, &docCoverageCaller{dryRun: true, responses: map[string][]map[string]any{}}, "--node", "n", "--output", "out.docx", "--dry-run"); err != nil {
t.Fatalf("export default format failed: %v", err)
}
if selected, ok := sliceUTF16Range("A😀B", 1, 3); !ok || selected != "😀" {
t.Fatalf("UTF-16 selection = %q/%v", selected, ok)
}
if _, ok := sliceUTF16Range("A😀B", 2, 3); ok {
t.Fatal("selection splitting a surrogate pair succeeded")
}
for _, raw := range []string{`["550582"]`, `[550582]`} {
mentions, err := normalizeMentionUserIDs([]string{raw})
if err != nil || len(mentions) != 1 || mentions[0] != "550582" {
t.Fatalf("mention normalization %q = %#v/%v", raw, mentions, err)
}
}
if _, err := normalizeMentionUserIDs([]string{`[{"uid":"550582"}]`}); err == nil {
t.Fatal("object mention unexpectedly succeeded")
}
resourceOnly := &docCoverageCaller{responses: map[string][]map[string]any{"get_document_style": {{"resourceId": "r"}}}}
_ = runDocCoverage(t, ResourceDownload, resourceOnly, "--node", "n", "--output", "cover.png")
@@ -704,6 +1312,53 @@ func TestCrossPlatformCoverageDocHistoryTemplateReviewAndMedia(t *testing.T) {
})
}
func TestCrossPlatformCoverageVersionRoutesAreCanonicalAndHistoryRoutesAreCompatible(t *testing.T) {
registered := map[string]shortcut.Shortcut{}
for _, item := range shortcut.All() {
if item.Service == "doc" {
registered[item.Command] = item
}
}
pairs := map[string]string{
"+history-save": "+version-save",
"+history-list": "+version-list",
"+history-revert": "+version-revert",
}
for compatibility, canonical := range pairs {
compatItem, ok := registered[compatibility]
if !ok {
t.Fatalf("missing compatibility route %s", compatibility)
}
canonicalItem, ok := registered[canonical]
if !ok {
t.Fatalf("missing canonical route %s", canonical)
}
if compatItem.Disposition != shortcut.DispositionAliasInternal || compatItem.PrimaryCommand != canonical {
t.Errorf("%s routing = %s/%s, want alias_internal/%s", compatibility, compatItem.Disposition, compatItem.PrimaryCommand, canonical)
}
if !strings.Contains(strings.Join(compatItem.Contract.Selection.AvoidWhen, " "), canonical) {
t.Errorf("%s compatibility Selection does not route Agent to %s", compatibility, canonical)
}
if canonicalItem.Contract.Selection.UseWhen[0] != canonicalItem.Intent {
t.Errorf("%s UseWhen and Intent drifted", canonical)
}
if strings.Contains(strings.Join(canonicalItem.Contract.Selection.AvoidWhen, " "), "+history-") {
t.Errorf("%s canonical Selection routes back to history compatibility commands", canonical)
}
}
if VersionSave.Command != "+version-save" || VersionSave.Safety.Confirmation != "user_required" {
t.Errorf("version-save command/confirmation = %s/%s", VersionSave.Command, VersionSave.Safety.Confirmation)
}
if compatHistorySave.Safety.Confirmation != "not_required" {
t.Errorf("history-save compatibility confirmation = %s", compatHistorySave.Safety.Confirmation)
}
if VersionRevert.Command != "+version-revert" || VersionRevert.Execute == nil {
t.Errorf("version-revert canonical smart route is incomplete")
}
}
func TestCrossPlatformCoverageDocDownloadAndWorkingDirectoryErrors(t *testing.T) {
t.Chdir(t.TempDir())
testseam.Swap(t, &docDownload, func(_ context.Context, _ string, _ localio.DownloadOptions) (localio.DownloadResult, error) {
@@ -713,7 +1368,7 @@ func TestCrossPlatformCoverageDocDownloadAndWorkingDirectoryErrors(t *testing.T)
decl shortcut.Shortcut
args []string
}{
{Export, []string{"--node", "n", "--output", "out.docx"}},
{Export, []string{"--node", "n", "--export-format", "docx", "--output", "out.docx"}},
{MediaDownload, []string{"--node", "n", "--resource-id", "r", "--output", "out.bin"}},
{ResourceDownload, []string{"--node", "n", "--output", "out.png"}},
} {
@@ -727,7 +1382,7 @@ func TestCrossPlatformCoverageDocDownloadAndWorkingDirectoryErrors(t *testing.T)
decl shortcut.Shortcut
args []string
}{
{Export, []string{"--node", "n", "--output", "out.docx"}},
{Export, []string{"--node", "n", "--export-format", "docx", "--output", "out.docx"}},
{MediaDownload, []string{"--node", "n", "--resource-id", "r", "--output", "out.bin"}},
{ResourceDownload, []string{"--node", "n", "--output", "out.png"}},
} {
@@ -4,112 +4,165 @@
package doc
import (
"encoding/json"
"fmt"
"sort"
"strconv"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
var (
legacyVersionSave shortcut.Shortcut
legacyVersionList shortcut.Shortcut
legacyVersionRevert shortcut.Shortcut
compatHistorySave shortcut.Shortcut
compatHistoryList shortcut.Shortcut
compatHistoryRevert shortcut.Shortcut
)
func canonicalizeHistoryShortcuts() {
legacyVersionSave = VersionSave
legacyVersionList = VersionList
legacyVersionRevert = VersionRevert
VersionSave.Command = "+history-save"
VersionSave.Command = "+version-save"
VersionSave.Aliases = nil
VersionSave.Description = "手动保存当前文档版本快照"
VersionSave.Intent = "当用户要在重要修改前后手动建立一个可回滚的文档历史快照时使用;保存快照本身无需交互确认。"
VersionSave.Safety = contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown"}
VersionSave.Contract = docContract("+history-save", VersionSave.Description, VersionSave.Intent, []string{`dws doc +history-save --node <DOC_ID>`})
VersionSave.Tips = []string{`dws doc +history-save --node <DOC_ID>`}
VersionSave.Intent = "当用户要求保存、创建或建立当前文档版本快照时使用;只保存快照,不更新正文。"
VersionSave.Safety = contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"}
VersionSave.Contract = versionSaveContract()
VersionSave.Tips = []string{`dws doc +version-save --node <DOC_ID>`}
VersionList.Command = "+history-list"
VersionList.Command = "+version-list"
VersionList.Aliases = nil
VersionList.Description = "分页列出文档历史版本"
VersionList.Intent = "当用户要查看文档已有版本、选择回滚目标或审计版本时间线时使用;返回版本号和分页游标。"
VersionList.Contract = docContract("+history-list", VersionList.Description, VersionList.Intent, []string{`dws doc +history-list --node <DOC_ID>`, `dws doc +history-list --node <DOC_ID> --page-size 20`})
VersionList.Contract = versionListContract()
VersionList.Flags = []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
{Name: "page-size", Type: shortcut.FlagInt, Desc: "每页版本数量"},
{Name: "page-token", Type: shortcut.FlagString, Desc: "分页游标"},
{Name: "limit", Type: shortcut.FlagInt, Desc: "--page-size 的兼容别名"},
{Name: "cursor", Type: shortcut.FlagString, Desc: "--page-token 的兼容别名"},
{Name: "limit", Type: shortcut.FlagInt, Desc: "返回版本数量上限", Aliases: []string{"page-size"}, AliasesVisible: true},
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标", Aliases: []string{"page-token"}, AliasesVisible: true},
}
VersionList.Tips = []string{`dws doc +history-list --node <DOC_ID>`, `dws doc +history-list --node <DOC_ID> --page-size 20`}
VersionList.Tips = []string{`dws doc +version-list --node <DOC_ID>`, `dws doc +version-list --node <DOC_ID> --limit 20`}
VersionList.Execute = func(rt *shortcut.RuntimeContext) error {
params := map[string]any{"nodeId": rt.Str("node")}
if size := rt.IntFirst("page-size", "limit"); size > 0 {
if size := rt.IntFirst("limit", "page-size"); size > 0 {
params["maxResults"] = size
}
if token := rt.StrFirst("page-token", "cursor"); token != "" {
if token := rt.StrFirst("cursor", "page-token"); token != "" {
params["nextCursor"] = token
}
return rt.CallMCP("list_doc_versions", params)
}
VersionRevert.Command = "+history-revert"
VersionRevert.Command = "+version-revert"
VersionRevert.Aliases = nil
VersionRevert.Description = "预检并回滚文档到指定历史版本"
VersionRevert.Intent = "当用户明确要把整篇文档恢复到某个历史版本时使用;先确认目标版本存在,再执行高风险回滚并读回验证。"
VersionRevert.Contract = docContract("+history-revert", VersionRevert.Description, VersionRevert.Intent, []string{`dws doc +history-revert --node <DOC_ID> --version 3`})
VersionRevert.Tips = []string{`dws doc +history-revert --node <DOC_ID> --version 3`}
VersionRevert.Contract = versionRevertContract()
VersionRevert.Tips = []string{`dws doc +version-revert --node <DOC_ID> --version 3`}
VersionRevert.Execute = executeHistoryRevert
compatHistorySave = compatibilityHistoryShortcut(VersionSave, "+history-save", "+version-save")
compatHistorySave.Safety = contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown"}
compatHistoryList = compatibilityHistoryShortcut(VersionList, "+history-list", "+version-list")
compatHistoryRevert = compatibilityHistoryShortcut(VersionRevert, "+history-revert", "+version-revert")
TemplateList.Description = "浏览当前用户可用的 MY/PUBLIC 文档模板"
TemplateList.Intent = "当用户要浏览自己的或公开的文档模板并获取 templateId 时使用;若已知名称可改用 template-search。"
TemplateList.Contract = docContract("+template-list", TemplateList.Description, TemplateList.Intent, []string{`dws doc +template-list --source PUBLIC`})
TemplateSearch.Description = "按名称检索文档模板"
TemplateSearch.Intent = "当用户知道模板名称关键词、要快速定位唯一 templateId 后继续创建文档时使用。"
TemplateSearch.Contract = docContract("+template-search", TemplateSearch.Description, TemplateSearch.Intent, []string{`dws doc +template-search --query "周报"`})
TemplateList.Intent = "当用户没有明确模板名称或关键词,只要浏览自己的或公开模板并获取 templateId 时使用。"
TemplateList.Contract = templateListContract()
TemplateSearch.Description = "按名称或关键词检索文档模板"
TemplateSearch.Intent = "当用户已提供明确模板名称或关键词时使用;返回结构化候选和 resolved/not_found/selection_required 状态,零命中或多候选时停止创建。"
TemplateSearch.Contract = templateSearchContract()
}
func canonicalizeCommentShortcuts() {
// Preserve the historical confirmation contract for comment writes. The
// richer canonical implementations must not silently weaken that gate.
CommentCreate.Safety = contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"}
CommentReply.Safety = contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"}
CommentCreate.Description = "创建全文评论,或按 selection 创建划词评论"
CommentCreate.Intent = "当用户要对整篇文档留言,或针对文档中唯一匹配的一段文字创建精确划词评论时使用;已知 block/start/end 时也可直接走高级通道。"
CommentCreate.Contract = docContract("+comment-create", CommentCreate.Description, CommentCreate.Intent, []string{`dws doc +comment-create --node <DOC_ID> --content "请补充数据来源"`, `dws doc +comment-create --node <DOC_ID> --selection "计划下周发布" --content "请确认日期"`})
CommentCreate.Flags = []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
{Name: "content", Type: shortcut.FlagString, Desc: "评论文字内容", Required: true},
{Name: "selection", Type: shortcut.FlagString, Desc: "完整文字或 前缀...后缀 selection;使用时不能为空且必须唯一匹配"},
{Name: "block-id", Type: shortcut.FlagString, Desc: "高级通道 block ID;使用时不能为空且须与 start/end 一起提供"},
{Name: "start", Type: shortcut.FlagInt, Desc: "块内起始字符偏移;高级通道参数不能为空且须一起提供"},
{Name: "end", Type: shortcut.FlagInt, Desc: "块内结束字符偏移;高级通道参数不能为空且须一起提供"},
{Name: "selected-text", Type: shortcut.FlagString, Desc: "高级通道引用原文"},
{Name: "mention", Type: shortcut.FlagStringSlice, Desc: "被 @ 的用户 uid 列表"},
func versionSaveContract() corecmd.ContractDecl {
decl := docContract("+version-save", VersionSave.Description, VersionSave.Intent, []string{`dws doc +version-save --node <DOC_ID>`})
decl.Selection.AvoidWhen = []string{
"只查看历史版本时使用 doc +version-list;需要恢复历史内容时使用 doc +version-revert",
"需要保存恢复点后继续修改正文时使用 doc +checkpoint-update",
}
CommentCreate.Constraints = []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"selection", "block-id", "start", "end"}, Description: "selection/高级通道参数不能为空;selection 必须唯一匹配,block-id/start/end 必须一起提供"}}
CommentCreate.Validate = validateCommentCreate
CommentCreate.Execute = executeCommentCreate
CommentCreate.Tips = []string{`dws doc +comment-create --node <DOC_ID> --content "请补充数据来源"`, `dws doc +comment-create --node <DOC_ID> --selection "计划下周发布" --content "请确认日期"`}
return decl
}
func versionListContract() corecmd.ContractDecl {
decl := docContract("+version-list", VersionList.Description, VersionList.Intent, []string{`dws doc +version-list --node <DOC_ID>`, `dws doc +version-list --node <DOC_ID> --limit 20`})
decl.Selection.AvoidWhen = []string{
"需要创建当前版本快照时使用 doc +version-save",
"已经明确目标版本并要求恢复正文时使用 doc +version-revert",
}
return decl
}
func versionRevertContract() corecmd.ContractDecl {
decl := docContract("+version-revert", VersionRevert.Description, VersionRevert.Intent, []string{`dws doc +version-revert --node <DOC_ID> --version 3`})
decl.Selection.AvoidWhen = []string{
"只需要创建当前版本快照时使用 doc +version-save",
"尚未确定目标版本号时先使用 doc +version-list",
}
return decl
}
func compatibilityHistoryShortcut(source shortcut.Shortcut, command, canonical string) shortcut.Shortcut {
result := source
result.Command = command
result.Aliases = nil
result.Description = fmt.Sprintf("兼容入口:%s", source.Description)
result.Intent = fmt.Sprintf("仅当调用方明确使用既有 doc %s 路径时兼容执行。", command)
result.Contract = docContract(command, result.Description, result.Intent, []string{fmt.Sprintf("dws doc %s --node <DOC_ID>", command)})
result.Contract.Selection.AvoidWhen = []string{fmt.Sprintf("新的 Agent 任务统一使用 doc %s;不要因自然语言版本意图选择本兼容入口", canonical)}
result.Tips = []string{fmt.Sprintf("dws doc %s --node <DOC_ID>", command)}
if command == "+history-revert" {
result.Contract.Selection.Examples = []string{`dws doc +history-revert --node <DOC_ID> --version 3`}
result.Tips = []string{`dws doc +history-revert --node <DOC_ID> --version 3`}
}
return result
}
func templateSearchContract() corecmd.ContractDecl {
decl := docContract("+template-search", TemplateSearch.Description, TemplateSearch.Intent, []string{`dws doc +template-search --query "周报"`})
decl.Selection.AvoidWhen = []string{
"没有明确模板名称或关键词、只是浏览可用模板时使用 doc +template-list",
"已经拿到唯一 templateId 时不要重复搜索,直接使用 doc +create-from-template --template-id",
"不要为了预览多个候选而逐个创建文档;多候选必须让用户选择",
}
return decl
}
func templateListContract() corecmd.ContractDecl {
decl := docContract("+template-list", TemplateList.Description, TemplateList.Intent, []string{`dws doc +template-list --source PUBLIC`})
decl.Selection.AvoidWhen = []string{
"已经有明确模板名称或关键词时使用 doc +template-search --query",
"已经拿到 templateId 且要创建文档时使用 doc +create-from-template --template-id",
}
return decl
}
func createFromTemplateContract() corecmd.ContractDecl {
decl := docContract("+create-from-template", "使用已选定的 templateId 创建文档",
"当模板搜索已经唯一解析或用户明确提供 templateId 时使用;只创建一次并返回稳定 nodeId。",
[]string{`dws doc +create-from-template --template-id <TEMPLATE_ID> --name "我的周报"`})
decl.Selection.AvoidWhen = []string{
"只有模板名称或关键词时先使用 doc +template-search;零命中或多候选时停止并让用户选择",
"没有模板名称、关键词或 templateId,只想浏览可用模板时使用 doc +template-list",
"不要通过创建多个候选文档来预览模板,也不要反复改写关键词自动重试",
}
return decl
}
func executeHistoryRevert(rt *shortcut.RuntimeContext) error {
nodeID := rt.Str("node")
target := rt.Int("version")
versions, err := rt.CallMCPData(productDoc, "list_doc_versions", map[string]any{"nodeId": nodeID})
found, err := findHistoryVersion(rt, nodeID, target)
if err != nil {
return err
}
if !containsVersion(versions, target) {
if !found {
return apperrors.NewValidation(fmt.Sprintf("目标版本 %d 不存在,已停止回滚", target))
}
if rt.DryRun() {
return rt.Output(docEnvelope("doc.history_revert", map[string]any{"executed": false, "nodeId": nodeID, "version": target, "preflight": "version_exists"}))
}
if _, err := rt.CallMCPWriteData(productDoc, "revert_doc_version", map[string]any{"nodeId": nodeID, "version": target}); err != nil {
reverted, err := rt.CallMCPWriteData(productDoc, "revert_doc_version", map[string]any{"nodeId": nodeID, "version": target})
if err != nil {
return err
}
current, err := rt.CallMCPData(productDoc, "get_document_info", map[string]any{"nodeId": nodeID})
@@ -125,28 +178,111 @@ func executeHistoryRevert(rt *shortcut.RuntimeContext) error {
map[string]any{"available": false, "reason": "the requested revert completed; verify the current document before any further write"},
)
}
return rt.Output(docEnvelope("doc.history_revert", map[string]any{"version": target, "current": current},
return rt.Output(docEnvelope("doc.history_revert", map[string]any{
"version": target, "revertResult": reverted, "current": current, "verified": true,
"verification": "revert_acknowledged_and_document_readable",
},
map[string]any{"name": "preflight", "status": "success"},
map[string]any{"name": "revert", "status": "success"},
map[string]any{"name": "verify", "status": "success"}))
}
func findHistoryVersion(rt *shortcut.RuntimeContext, nodeID string, target int) (bool, error) {
const maxPages = 20
cursor := ""
seenCursors := map[string]bool{}
for page := 1; page <= maxPages; page++ {
params := map[string]any{"nodeId": nodeID}
if cursor != "" {
params["nextCursor"] = cursor
}
versions, err := rt.CallMCPData(productDoc, "list_doc_versions", params)
if err != nil {
return false, err
}
if containsVersion(versions, target) {
return true, nil
}
hasMore, hasMoreKnown, nextCursor := docPageState(versions)
nextCursor = strings.TrimSpace(nextCursor)
if hasMoreKnown && !hasMore {
return false, nil
}
if nextCursor == "" {
if hasMoreKnown && hasMore {
return false, historyVersionPaginationError("missing_next_cursor", page, cursor)
}
return false, nil
}
if seenCursors[nextCursor] {
return false, historyVersionPaginationError("stalled_cursor", page, nextCursor)
}
seenCursors[nextCursor] = true
cursor = nextCursor
}
return false, historyVersionPaginationError("max_pages", maxPages, cursor)
}
func historyVersionPaginationError(reason string, page int, cursor string) error {
return apperrors.NewAPI(
"文档版本预检无法证明分页已经完整,已停止回滚",
apperrors.WithOperation("doc.history_revert"),
apperrors.WithReason("doc_history_version_"+reason),
apperrors.WithFailureStage("preflight"),
apperrors.WithExecutionStarted(false),
apperrors.WithRetryable(true),
apperrors.WithDetails(map[string]any{"page": page, "cursor": cursor, "targetVerified": false}),
)
}
func versionEvidenceMatches(value any, target int, acceptedKeys map[string]bool) bool {
switch typed := value.(type) {
case map[string]any:
for key, child := range typed {
normalized := strings.ToLower(strings.NewReplacer("_", "", "-", "").Replace(key))
if acceptedKeys[normalized] {
if versionNumberMatches(child, target) {
return true
}
}
if versionEvidenceMatches(child, target, acceptedKeys) {
return true
}
}
case []any:
for _, child := range typed {
if versionEvidenceMatches(child, target, acceptedKeys) {
return true
}
}
}
return false
}
func versionNumberMatches(value any, target int) bool {
switch number := value.(type) {
case float64:
return number == float64(target)
case json.Number:
parsed, err := number.Int64()
return err == nil && parsed == int64(target)
case int:
return number == target
case string:
parsed, err := strconv.Atoi(strings.TrimSpace(number))
return err == nil && parsed == target
}
return false
}
func containsVersion(value any, target int) bool {
switch typed := value.(type) {
case map[string]any:
for key, child := range typed {
normalized := strings.ToLower(strings.ReplaceAll(key, "_", ""))
if normalized == "version" || normalized == "versionnumber" || normalized == "revision" {
switch number := child.(type) {
case float64:
if int(number) == target && number == float64(target) {
return true
}
case string:
parsed, err := strconv.Atoi(strings.TrimSpace(number))
if err == nil && parsed == target {
return true
}
if versionNumberMatches(child, target) {
return true
}
}
if containsVersion(child, target) {
@@ -167,39 +303,53 @@ var CreateFromTemplate = shortcut.Shortcut{
Service: "doc",
Command: "+create-from-template",
Product: productDoc,
Description: "按 templateId 直达或搜索消歧后创建文档",
Intent: "当用户要基于文档模板创建新文档时使用;可直接给 template-id,或给 query 搜索且只在唯一命中时继续创建。",
Description: "使用已选定的 templateId 创建文档",
Intent: "当模板搜索已经唯一解析或用户明确提供 templateId 时使用;只创建一次并返回稳定 nodeId。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown"},
Contract: docContract("+create-from-template", "按 templateId 直达或搜索消歧后创建文档",
"当用户要基于文档模板创建新文档时使用;可直接给 template-id,或给 query 搜索且只在唯一命中时继续创建。",
[]string{`dws doc +create-from-template --template-id <TEMPLATE_ID> --name "我的周报"`, `dws doc +create-from-template --query "会议纪要" --name "项目例会"`}),
Contract: createFromTemplateContract(),
Flags: []shortcut.Flag{
{Name: "template-id", Type: shortcut.FlagString, Desc: "模板 ID"},
{Name: "query", Type: shortcut.FlagString, Desc: "模板搜索名称"},
{Name: "query", Type: shortcut.FlagString, Desc: "兼容入口:先搜索且仅唯一命中时创建;新的 Agent 流程应先调用 +template-search"},
{Name: "source", Type: shortcut.FlagString, Desc: "模板来源", Enum: []string{"MY", "PUBLIC"}},
{Name: "name", Type: shortcut.FlagString, Desc: "新文档名称"},
{Name: "folder", Type: shortcut.FlagString, Desc: "目标文件夹 ID"},
{Name: "workspace", Type: shortcut.FlagString, Desc: "目标知识库 ID"},
},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"template-id", "query"}, Description: "--template-id 与 --query 必须且只能提供一个"}},
Tips: []string{`dws doc +create-from-template --template-id <TEMPLATE_ID> --name "我的周报"`, `dws doc +create-from-template --query "会议纪要" --name "项目例会"`},
Tips: []string{`dws doc +create-from-template --template-id <TEMPLATE_ID> --name "我的周报"`},
Execute: func(rt *shortcut.RuntimeContext) error {
templateID := rt.Str("template-id")
if templateID == "" {
params := map[string]any{"searchName": rt.Str("query")}
if rt.Str("source") != "" {
params["templateSource"] = rt.Str("source")
}
found, err := rt.CallMCPData(productDoc, "search_doc_templates", params)
candidates, err := searchTemplateCandidatesForCreate(rt, rt.Str("query"), rt.Str("source"))
if err != nil {
return err
}
ids := collectTemplateIDs(found)
if len(ids) != 1 {
return apperrors.NewValidation(fmt.Sprintf("模板搜索需要唯一命中,实际 %d 个候选: %v", len(ids), ids))
if len(candidates) != 1 {
status := "selection_required"
message := fmt.Sprintf("模板搜索返回 %d 个候选,必须先选择唯一 templateId", len(candidates))
actions := []string{"运行 dws doc +template-search 查看结构化候选", "让用户选择后使用 --template-id 创建一次"}
if len(candidates) == 0 {
status = "not_found"
message = "模板搜索没有命中;已停止创建"
actions = []string{"检查模板来源或请用户提供更准确的模板名", "不要自动变换关键词循环搜索"}
}
return apperrors.NewValidation(message,
apperrors.WithOperation("doc.create_from_template"),
apperrors.WithReason("template_"+status),
apperrors.WithExecutionStarted(false),
apperrors.WithRetryable(false),
apperrors.WithActions(actions...),
apperrors.WithDetails(map[string]any{
"contractVersion": "doc.template-selection.v1",
"status": status,
"query": rt.Str("query"),
"source": rt.Str("source"),
"candidates": candidates,
}),
)
}
templateID = ids[0]
templateID = candidates[0]["templateId"].(string)
}
params := map[string]any{"templateId": templateID}
for flag, property := range map[string]string{"name": "name", "folder": "folderId", "workspace": "workspaceId"} {
@@ -212,12 +362,151 @@ var CreateFromTemplate = shortcut.Shortcut{
}
result, err := rt.CallMCPWriteData(productDoc, "apply_doc_template", params)
if err != nil {
return err
return docUnknownWriteError("doc.create_from_template", "apply_template", "", err)
}
return rt.Output(docEnvelope("doc.create_from_template", result, map[string]any{"name": "apply_template", "status": "success"}))
return rt.Output(docEnvelope("doc.create_from_template", map[string]any{"templateId": templateID, "result": result}, map[string]any{"name": "apply_template", "status": "success"}))
},
}
func searchTemplateCandidatesForCreate(rt *shortcut.RuntimeContext, query, source string) ([]map[string]any, error) {
const (
pageSize = 2
maxPages = 20
)
cursor := ""
seenCursors := map[string]bool{}
seenCandidates := map[string]bool{}
candidates := make([]map[string]any, 0, pageSize)
for page := 1; page <= maxPages; page++ {
params := map[string]any{"searchName": query, "maxResults": pageSize}
if source != "" {
params["templateSource"] = source
}
if cursor != "" {
params["nextCursor"] = cursor
}
found, err := rt.CallMCPData(productDoc, "search_doc_templates", params)
if err != nil {
return nil, err
}
pageCandidates := collectTemplateCandidates(found)
for _, candidate := range pageCandidates {
id, _ := candidate["templateId"].(string)
if id == "" || seenCandidates[id] {
continue
}
seenCandidates[id] = true
candidates = append(candidates, candidate)
}
hasMore, hasMoreKnown, next := docPageState(found)
next = strings.TrimSpace(next)
if len(candidates) > 1 {
return sortedTemplateCandidates(candidates), nil
}
complete := hasMoreKnown && !hasMore
if !hasMoreKnown && next == "" && len(pageCandidates) < pageSize {
complete = true
}
if complete {
return sortedTemplateCandidates(candidates), nil
}
if next == "" {
return nil, templatePaginationError("missing_next_cursor", page, cursor, candidates)
}
if next == cursor || seenCursors[next] {
return nil, templatePaginationError("stalled_cursor", page, next, candidates)
}
seenCursors[next] = true
cursor = next
}
return nil, templatePaginationError("max_pages", maxPages, cursor, candidates)
}
func templatePaginationError(reason string, page int, cursor string, candidates []map[string]any) error {
return apperrors.NewAPI(
"模板搜索分页未完整结束;为避免基于非唯一候选创建文档,已停止执行",
apperrors.WithOperation("doc.create_from_template"),
apperrors.WithReason("template_pagination_"+reason),
apperrors.WithFailureStage("template_search"),
apperrors.WithExecutionStarted(false),
apperrors.WithRetryable(false),
apperrors.WithActions("使用 dws doc +template-search 分页查看候选", "选择唯一 templateId 后重新执行创建"),
apperrors.WithDetails(map[string]any{
"contractVersion": "doc.template-selection.v1",
"status": "pagination_incomplete",
"reason": reason,
"page": page,
"nextCursor": cursor,
"candidates": sortedTemplateCandidates(candidates),
}),
)
}
func sortedTemplateCandidates(candidates []map[string]any) []map[string]any {
out := append([]map[string]any(nil), candidates...)
sort.SliceStable(out, func(i, j int) bool {
leftName, _ := out[i]["name"].(string)
rightName, _ := out[j]["name"].(string)
if leftName != rightName {
return leftName < rightName
}
leftID, _ := out[i]["templateId"].(string)
rightID, _ := out[j]["templateId"].(string)
return leftID < rightID
})
return out
}
func collectTemplateCandidates(value any) []map[string]any {
seen := map[string]bool{}
var out []map[string]any
var walk func(any)
walk = func(current any) {
switch typed := current.(type) {
case map[string]any:
id := directTemplateString(typed, "templateId", "template_id")
if id != "" && !seen[id] {
seen[id] = true
candidate := map[string]any{"templateId": id}
if name := directTemplateString(typed, "templateName", "name", "title"); name != "" {
candidate["name"] = name
}
if source := directTemplateString(typed, "templateSource", "source"); source != "" {
candidate["source"] = source
}
if description := directTemplateString(typed, "description", "summary"); description != "" {
candidate["description"] = description
}
out = append(out, candidate)
}
keys := make([]string, 0, len(typed))
for key := range typed {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
walk(typed[key])
}
case []any:
for _, child := range typed {
walk(child)
}
}
}
walk(value)
return sortedTemplateCandidates(out)
}
func directTemplateString(value map[string]any, keys ...string) string {
for _, key := range keys {
if text, ok := value[key].(string); ok && strings.TrimSpace(text) != "" {
return strings.TrimSpace(text)
}
}
return ""
}
func collectTemplateIDs(value any) []string {
seen := map[string]bool{}
var out []string
+61 -20
View File
@@ -17,11 +17,11 @@ import (
var MediaList = shortcut.Shortcut{
Service: "doc", Command: "+media-list", Product: productDoc,
Description: "列出文档正文中的图片和附件资源",
Intent: "当用户要发现文档内可下载或可定位的图片、附件及其 blockId/resourceId 时使用;只读取并投影媒体节点。",
Intent: "当用户要发现文档内可下载或可定位的图片、附件及其 blockId/resourceId 时使用;先取得稳定 ID,再交给 +media-download,禁止提取临时 URL 后 curl。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: docContract("+media-list", "列出文档正文中的图片和附件资源",
"当用户要发现文档内可下载或可定位的图片、附件及其 blockId/resourceId 时使用;只读取并投影媒体节点。",
"当用户要发现文档内可下载或可定位的图片、附件及其 blockId/resourceId 时使用;先取得稳定 ID,再交给 +media-download,禁止提取临时 URL 后 curl。",
[]string{`dws doc +media-list --node <DOC_ID>`}),
Flags: []shortcut.Flag{{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true}},
Tips: []string{`dws doc +media-list --node <DOC_ID>`},
@@ -31,40 +31,42 @@ var MediaList = shortcut.Shortcut{
return err
}
items := collectMediaItems(data)
return rt.Output(map[string]any{"count": len(items), "media": items})
return rt.Output(docEnvelope("doc.media_list", map[string]any{"nodeId": rt.Str("node"), "count": len(items), "media": items}))
},
}
var MediaInsert = shortcut.Shortcut{
Service: "doc", Command: "+media-insert", Product: productDoc,
Description: "上传本地图片或文件并插入文档正文",
Intent: "当用户要把本地图片或附件作为正文 block 插入在线文档时使用;组合本地校验、上传凭证、OSS PUT 和插块,失败时不会伪造完整回滚。",
Intent: "当用户要把工作目录内的本地图片或附件作为正文 block 插入在线文档时使用;组合本地校验、上传凭证、OSS PUT 和插块,失败后保留稳定 ID,禁止改走手写 HTTP。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
Contract: withDryRun(docContract("+media-insert", "上传本地图片或文件并插入文档正文",
"当用户要把本地图片或附件作为正文 block 插入在线文档时使用;组合本地校验、上传凭证、OSS PUT 和插块,失败时不会伪造完整回滚。",
"当用户要把工作目录内的本地图片或附件作为正文 block 插入在线文档时使用;组合本地校验、上传凭证、OSS PUT 和插块,失败后保留稳定 ID,禁止改走手写 HTTP。",
[]string{`dws doc +media-insert --node <DOC_ID> --file ./report.pdf`, `dws doc +media-insert --node <DOC_ID> --file ./image.png --ref-block <BLOCK_ID> --where after`}), contract.DryRunPreviewPlan, false),
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
{Name: "file", Type: shortcut.FlagString, Desc: "本地文件路径", Required: true},
{Name: "file", Type: shortcut.FlagString, Desc: "工作目录内已存在的相对文件路径", Required: true},
{Name: "name", Type: shortcut.FlagString, Desc: "显示名称"},
{Name: "mime-type", Type: shortcut.FlagString, Desc: "MIME 类型"},
{Name: "index", Type: shortcut.FlagInt, Desc: "顶层插入索引"},
{Name: "where", Type: shortcut.FlagString, Desc: "相对参考块的位置", Enum: []string{"before", "after"}},
{Name: "ref-block", Type: shortcut.FlagString, Desc: "参考 block ID"},
},
Tips: []string{`dws doc +media-insert --node <DOC_ID> --file ./report.pdf`, `dws doc +media-insert --node <DOC_ID> --file ./image.png --ref-block <BLOCK_ID> --where after`},
Execute: func(rt *shortcut.RuntimeContext) error { return helpers.RunDocMediaInsertShortcut(rt.Command()) },
Validate: func(rt *shortcut.RuntimeContext) error { return validateWorkspaceInputPath("file", rt.Str("file")) },
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"file"}, Description: "--file 必须是工作目录内存在且不能经符号链接逃逸的相对文件"}},
Tips: []string{`dws doc +media-insert --node <DOC_ID> --file ./report.pdf`, `dws doc +media-insert --node <DOC_ID> --file ./image.png --ref-block <BLOCK_ID> --where after`},
Execute: func(rt *shortcut.RuntimeContext) error { return helpers.RunDocMediaInsertShortcut(rt.Command()) },
}
var MediaDownload = shortcut.Shortcut{
Service: "doc", Command: "+media-download", Product: productDoc,
Description: "安全下载文档正文附件到工作目录",
Intent: "当用户已从 media-list 或 block 数据拿到 resourceId,要把正文附件保存到本地时使用;默认拒绝覆盖并原子发布文件。",
Intent: "当用户已从 +media-list 拿到真实 resourceId,要把正文附件保存到工作目录时使用;CLI 内部换取临时链接并原子下载,默认拒绝覆盖。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: docContract("+media-download", "安全下载文档正文附件到工作目录",
"当用户已从 media-list 或 block 数据拿到 resourceId,要把正文附件保存到本地时使用;默认拒绝覆盖并原子发布文件。",
"当用户已从 +media-list 拿到真实 resourceId,要把正文附件保存到工作目录时使用;CLI 内部换取临时链接并原子下载,默认拒绝覆盖。",
[]string{`dws doc +media-download --node <DOC_ID> --resource-id <RESOURCE_ID> --output ./downloads/`}),
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
@@ -97,7 +99,7 @@ var MediaPreview = shortcut.Shortcut{
}
data, err := rt.CallMCPData(productDoc, "download_doc_attachment", map[string]any{"nodeId": rt.Str("node"), "resourceId": rt.Str("resource-id")})
if err != nil {
return err
return docMediaRecoveryError("doc_media_resolve_failed", "resolve_download", rt.Str("node"), rt.Str("resource-id"), err)
}
dir, err := docMkdirTemp("", "dws-doc-preview-*")
if err != nil {
@@ -106,9 +108,13 @@ var MediaPreview = shortcut.Shortcut{
result, err := downloadResolvedResource(rt, data, dir, ".")
if err != nil {
_ = docRemoveAll(dir)
return err
return docMediaRecoveryError("doc_media_preview_failed", "download", rt.Str("node"), rt.Str("resource-id"), err)
}
return rt.Output(docEnvelope("doc.media_preview", map[string]any{"previewPath": result.AbsolutePath, "sizeBytes": result.SizeBytes}))
if result.SizeBytes <= 0 {
_ = docRemoveAll(dir)
return docMediaRecoveryError("doc_media_empty_download", "verify", rt.Str("node"), rt.Str("resource-id"), fmt.Errorf("下载结果为空"))
}
return rt.Output(docEnvelope("doc.media_preview", map[string]any{"nodeId": rt.Str("node"), "resourceId": rt.Str("resource-id"), "previewPath": result.AbsolutePath, "sizeBytes": result.SizeBytes, "verified": true}))
},
}
@@ -124,11 +130,20 @@ var ResourceUpdate = shortcut.Shortcut{
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
{Name: "image", Type: shortcut.FlagString, Desc: "HTTPS 封面图片 URL"},
{Name: "file", Type: shortcut.FlagString, Desc: "本地封面图片"},
{Name: "file", Type: shortcut.FlagString, Desc: "工作目录内已存在封面图片的相对路径"},
},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"image", "file"}, Description: "--image 与 --file 必须且只能提供一个"}},
Tips: []string{`dws doc +resource-update --node <DOC_ID> --image https://example.com/cover.png`, `dws doc +resource-update --node <DOC_ID> --file ./cover.png`},
Execute: func(rt *shortcut.RuntimeContext) error { return helpers.RunDocResourceUpdateShortcut(rt.Command()) },
Validate: func(rt *shortcut.RuntimeContext) error {
if rt.Str("file") == "" {
return nil
}
return validateWorkspaceInputPath("file", rt.Str("file"))
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"image", "file"}, Description: "--image 与 --file 必须且只能提供一个"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"file"}, Description: "提供 --file 时必须是工作目录内已存在且不通过符号链接逃逸的相对路径"},
},
Tips: []string{`dws doc +resource-update --node <DOC_ID> --image https://example.com/cover.png`, `dws doc +resource-update --node <DOC_ID> --file ./cover.png`},
Execute: func(rt *shortcut.RuntimeContext) error { return helpers.RunDocResourceUpdateShortcut(rt.Command()) },
}
var ResourceDownload = shortcut.Shortcut{
@@ -225,7 +240,7 @@ func executeMediaDownload(rt *shortcut.RuntimeContext) error {
}
data, err := rt.CallMCPData(productDoc, "download_doc_attachment", map[string]any{"nodeId": rt.Str("node"), "resourceId": rt.Str("resource-id")})
if err != nil {
return err
return docMediaRecoveryError("doc_media_resolve_failed", "resolve_download", rt.Str("node"), rt.Str("resource-id"), err)
}
cwd, err := docGetwd()
if err != nil {
@@ -233,9 +248,35 @@ func executeMediaDownload(rt *shortcut.RuntimeContext) error {
}
result, err := downloadResolvedResource(rt, data, cwd, rt.Str("output"))
if err != nil {
return err
return docMediaRecoveryError("doc_media_download_failed", "download", rt.Str("node"), rt.Str("resource-id"), err)
}
return rt.Output(docEnvelope("doc.media_download", map[string]any{"resourceId": rt.Str("resource-id"), "localPath": result.RelativePath, "sizeBytes": result.SizeBytes}))
if result.SizeBytes <= 0 {
return docMediaRecoveryError("doc_media_empty_download", "verify", rt.Str("node"), rt.Str("resource-id"), fmt.Errorf("下载结果为空"))
}
return rt.Output(docEnvelope("doc.media_download", map[string]any{"nodeId": rt.Str("node"), "resourceId": rt.Str("resource-id"), "localPath": result.RelativePath, "sizeBytes": result.SizeBytes, "verified": true}))
}
func docMediaRecoveryError(reason, stage, nodeID, resourceID string, cause error) error {
return apperrors.NewAPI(
"文档媒体操作未完成;已保留 nodeId/resourceId,请稍后重试同一 shortcut,禁止改用临时 URL 或手写 HTTP",
apperrors.WithOperation("doc.media_download"),
apperrors.WithReason(reason),
apperrors.WithFailureStage(stage),
apperrors.WithExecutionStarted(false),
apperrors.WithRetryable(true),
apperrors.WithActions(
fmt.Sprintf("dws doc +media-download --node %s --resource-id %s --output <工作目录相对路径>", nodeID, resourceID),
"不要 curl/wget 临时 OSS URL,不要安装本地下载或文档转换依赖",
),
apperrors.WithDetails(map[string]any{
"contractVersion": "doc.operation.v1",
"status": "incomplete",
"nodeId": nodeID,
"resourceId": resourceID,
"stage": stage,
}),
apperrors.WithCause(cause),
)
}
func executeResourceDownload(rt *shortcut.RuntimeContext) error {
+222
View File
@@ -0,0 +1,222 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package doc
import (
"fmt"
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
type docPageOptions struct {
PageAll bool
PageSize int
MaxPages int
MaxItems int
Cursor string
}
func collectDocPages(
rt *shortcut.RuntimeContext,
tool, outputKey string,
base map[string]any,
project func(map[string]any) []map[string]any,
options docPageOptions,
) (map[string]any, error) {
if options.PageSize <= 0 {
options.PageSize = 30
}
if options.MaxPages <= 0 {
options.MaxPages = 20
}
if options.MaxItems <= 0 {
options.MaxItems = 500
}
pageLimit := 1
if options.PageAll {
pageLimit = options.MaxPages
}
items := make([]map[string]any, 0)
seenItems := map[string]bool{}
seenCursors := map[string]bool{}
cursor := strings.TrimSpace(options.Cursor)
if cursor != "" {
seenCursors[cursor] = true
}
complete := false
truncated := false
stopReason := ""
nextCursor := ""
hasMore := false
pagesRead := 0
for page := 1; page <= pageLimit; page++ {
remaining := options.MaxItems - len(items)
requestPageSize := options.PageSize
if requestPageSize > remaining {
requestPageSize = remaining
}
params := cloneMap(base)
params["pageSize"] = requestPageSize
if cursor != "" {
params["pageToken"] = cursor
}
data, err := rt.CallMCPData(productDoc, tool, params)
if err != nil {
return nil, docPaginationError(tool, "page_read_failed", err, page, items, cursor)
}
pagesRead++
projected := project(data)
pageItems := make([]map[string]any, 0, len(projected))
pageSeen := map[string]bool{}
for _, item := range projected {
key := pageItemKey(item)
if key != "" && (seenItems[key] || pageSeen[key]) {
continue
}
if key != "" {
pageSeen[key] = true
}
pageItems = append(pageItems, item)
}
if len(pageItems) > remaining {
return nil, docPaginationError(tool, "page_size_exceeded", nil, page, items, cursor)
}
for _, item := range pageItems {
if key := pageItemKey(item); key != "" {
seenItems[key] = true
}
items = append(items, item)
}
pageHasMore, hasMoreKnown, next := docPageState(data)
nextCursor = strings.TrimSpace(next)
switch {
case hasMoreKnown:
hasMore = pageHasMore
complete = !pageHasMore
case nextCursor != "":
hasMore = true
case len(projected) < requestPageSize:
complete = true
default:
hasMore = true
stopReason = "pagination_unproven"
}
if len(items) >= options.MaxItems && hasMore {
truncated = true
stopReason = "max_items"
}
if truncated {
complete = false
hasMore = true
}
if truncated || complete || !options.PageAll {
break
}
if stopReason == "pagination_unproven" {
return nil, docPaginationError(tool, stopReason, nil, page, items, cursor)
}
if nextCursor == "" {
return nil, docPaginationError(tool, "missing_next_cursor", nil, page, items, cursor)
}
if seenCursors[nextCursor] {
return nil, docPaginationError(tool, "stalled_cursor", nil, page, items, nextCursor)
}
seenCursors[nextCursor] = true
cursor = nextCursor
if page == pageLimit {
truncated = true
stopReason = "max_pages"
}
}
result := map[string]any{
"contractVersion": "doc.list.v1",
"status": "success",
"count": len(items),
outputKey: items,
"pagesRead": pagesRead,
"complete": complete,
"truncated": truncated,
"hasMore": hasMore,
"nextCursor": nextCursor,
"stopReason": stopReason,
"failures": []map[string]any{},
}
return result, nil
}
func docPaginationError(tool, reason string, cause error, page int, items []map[string]any, cursor string) error {
message := fmt.Sprintf("%s 分页未完成,已在第 %d 页停止", tool, page)
return apperrors.NewAPI(
message,
apperrors.WithOperation("doc/"+tool),
apperrors.WithReason("doc_pagination_"+reason),
apperrors.WithFailureStage("pagination"),
apperrors.WithExecutionStarted(false),
apperrors.WithRetryable(cause != nil),
apperrors.WithActions("保留 nextCursor 后继续读取", "缩小查询范围后重试"),
apperrors.WithDetails(map[string]any{
"contractVersion": "doc.list.v1",
"status": "partial_success",
"complete": false,
"reason": reason,
"page": page,
"nextCursor": cursor,
"count": len(items),
"items": items,
}),
apperrors.WithCause(cause),
)
}
func cloneMap(source map[string]any) map[string]any {
out := make(map[string]any, len(source)+2)
for key, value := range source {
out[key] = value
}
return out
}
func pageItemKey(item map[string]any) string {
for _, key := range []string{"nodeId", "id", "url"} {
if value, ok := item[key].(string); ok && strings.TrimSpace(value) != "" {
return key + ":" + strings.TrimSpace(value)
}
}
return ""
}
func docPageState(data map[string]any) (bool, bool, string) {
var hasMore bool
var known bool
var next string
var walk func(map[string]any)
walk = func(value map[string]any) {
if !known {
for _, key := range []string{"hasMore", "has_more"} {
if flag, ok := value[key].(bool); ok {
hasMore, known = flag, true
break
}
}
}
if next == "" {
if value, ok := docFirst(value, "nextPageToken", "nextCursor", "next_page_token"); ok {
next, _ = value.(string)
}
}
for _, key := range []string{"result", "data", "page", "pagination"} {
if nested, ok := value[key].(map[string]any); ok {
walk(nested)
}
}
}
walk(data)
return hasMore, known, next
}
+144
View File
@@ -0,0 +1,144 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package doc
import (
"errors"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
func TestCrossPlatformCoverageDocSearchPageAllContract(t *testing.T) {
caller := &docCoverageCaller{responses: map[string][]map[string]any{
"search_documents": {
{"documents": []any{map[string]any{"nodeId": "a", "name": "A"}}, "hasMore": true, "nextPageToken": "p2"},
{"documents": []any{map[string]any{"nodeId": "b", "name": "B"}, map[string]any{"nodeId": "a", "name": "A"}}, "hasMore": false},
},
}}
if err := runDocCoverage(t, Search, caller, "--query", "report", "--page-all", "--limit", "2"); err != nil {
t.Fatal(err)
}
if len(caller.history) != 2 || caller.history[1].params["pageToken"] != "p2" {
t.Fatalf("pagination calls = %#v", caller.history)
}
}
func TestCrossPlatformCoverageDocPaginationFailsClosedOnStalledCursor(t *testing.T) {
caller := &docCoverageCaller{responses: map[string][]map[string]any{
"list_nodes": {
{"nodes": []any{map[string]any{"nodeId": "a"}}, "hasMore": true, "nextPageToken": "p2"},
{"nodes": []any{map[string]any{"nodeId": "b"}}, "hasMore": true, "nextPageToken": "p2"},
},
}}
err := runDocCoverage(t, List, caller, "--folder", "f", "--page-all", "--limit", "1")
if err == nil || len(caller.history) != 2 {
t.Fatalf("stalled pagination err=%v history=%#v", err, caller.history)
}
}
func TestCrossPlatformCoverageDocPaginationMaxItemsStopsAtPageBoundary(t *testing.T) {
caller := &docCoverageCaller{responses: map[string][]map[string]any{
"search_documents": {
{"documents": []any{map[string]any{"nodeId": "a"}, map[string]any{"nodeId": "b"}}, "hasMore": true, "nextPageToken": "p2"},
{"documents": []any{map[string]any{"nodeId": "c"}}, "hasMore": true, "nextPageToken": "p3"},
},
}}
if err := runDocCoverage(t, Search, caller, "--query", "report", "--page-all", "--limit", "2", "--max-items", "3"); err != nil {
t.Fatal(err)
}
if len(caller.history) != 2 || caller.history[1].params["pageSize"] != 1 || caller.history[1].params["pageToken"] != "p2" {
t.Fatalf("max-items pagination calls = %#v", caller.history)
}
}
func TestCrossPlatformCoverageDocPaginationRejectsServerPageOverflow(t *testing.T) {
caller := &docCoverageCaller{responses: map[string][]map[string]any{
"search_documents": {{
"documents": []any{map[string]any{"nodeId": "a"}, map[string]any{"nodeId": "b"}},
"hasMore": true, "nextPageToken": "p2",
}},
}}
err := runDocCoverage(t, Search, caller, "--query", "report", "--page-all", "--limit", "2", "--max-items", "1")
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "doc_pagination_page_size_exceeded" {
t.Fatalf("page overflow error = %#v", err)
}
if len(caller.history) != 1 || caller.history[0].params["pageSize"] != 1 {
t.Fatalf("page overflow calls = %#v", caller.history)
}
}
func TestCrossPlatformCoverageDocPaginationFinalBranchMatrix(t *testing.T) {
run := func(t *testing.T, caller *docCoverageCaller, options docPageOptions) (map[string]any, error) {
t.Helper()
declaration := Search
declaration.Execute = func(rt *shortcut.RuntimeContext) error {
result, err := collectDocPages(rt, "search_documents", "documents", map[string]any{"base": true}, searchDocsProject, options)
if err == nil {
err = rt.Output(result)
}
return err
}
err := runDocCoverage(t, declaration, caller)
return nil, err
}
defaults := &docCoverageCaller{responses: map[string][]map[string]any{
"search_documents": {{"documents": []any{map[string]any{"url": "u"}}}},
}}
if _, err := run(t, defaults, docPageOptions{Cursor: " initial "}); err != nil {
t.Fatal(err)
}
if len(defaults.history) != 1 || defaults.history[0].params["pageSize"] != 30 || defaults.history[0].params["pageToken"] != "initial" {
t.Fatalf("default pagination call = %#v", defaults.history)
}
readFailure := &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}}
if _, err := run(t, readFailure, docPageOptions{PageAll: true}); err == nil {
t.Fatal("page read failure succeeded")
}
duplicate := &docCoverageCaller{responses: map[string][]map[string]any{
"search_documents": {{
"documents": []any{
map[string]any{"nodeId": "a"}, map[string]any{"nodeId": "a"}, map[string]any{"name": "no-key"},
},
"nextPageToken": "p2",
}},
}}
if _, err := run(t, duplicate, docPageOptions{PageAll: false, PageSize: 3, MaxPages: 2, MaxItems: 10}); err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
name string
response map[string]any
maxPages int
wantError bool
}{
{"unproven", map[string]any{"documents": []any{map[string]any{"nodeId": "a"}}}, 2, true},
{"missing cursor", map[string]any{"documents": []any{}, "hasMore": true}, 2, true},
{"max pages", map[string]any{"documents": []any{}, "hasMore": true, "nextPageToken": "p2"}, 1, false},
} {
t.Run(tc.name, func(t *testing.T) {
caller := &docCoverageCaller{responses: map[string][]map[string]any{"search_documents": {tc.response}}}
_, err := run(t, caller, docPageOptions{PageAll: true, PageSize: 1, MaxPages: tc.maxPages, MaxItems: 10})
if (err != nil) != tc.wantError {
t.Fatalf("err=%v wantError=%v", err, tc.wantError)
}
})
}
if got := pageItemKey(map[string]any{"url": " u "}); got != "url:u" {
t.Fatalf("URL page key = %q", got)
}
if got := pageItemKey(map[string]any{"id": 1}); got != "" {
t.Fatalf("invalid page key = %q", got)
}
if more, known, next := docPageState(map[string]any{"data": map[string]any{"has_more": true, "nextCursor": "c"}}); !more || !known || next != "c" {
t.Fatalf("nested page state = %v/%v/%q", more, known, next)
}
}
+82 -9
View File
@@ -4,6 +4,7 @@
package doc
import (
"encoding/json"
"fmt"
"strings"
"unicode/utf16"
@@ -60,13 +61,17 @@ var CommentUpdate = shortcut.Shortcut{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
{Name: "comment-key", Type: shortcut.FlagString, Desc: "评论 commentKey", Required: true},
{Name: "content", Type: shortcut.FlagString, Desc: "更新后的评论正文", Required: true},
{Name: "mention", Type: shortcut.FlagStringSlice, Desc: "被 @ 的用户 uid 列表"},
{Name: "mention", Type: shortcut.FlagStringSlice, Desc: "被 @ 的用户 uid,多个值用逗号分隔;不要传 JSON 数组"},
},
Tips: []string{`dws doc +comment-update --node <DOC_ID> --comment-key <COMMENT_KEY> --content "已按最新数据修正"`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{"nodeId": rt.Str("node"), "commentKey": rt.Str("comment-key"), "content": rt.Str("content")}
if rt.Changed("mention") {
params["mentionedUserIds"] = rt.StrSlice("mention")
mentions, err := normalizeMentionUserIDs(rt.StrSlice("mention"))
if err != nil {
return err
}
params["mentionedUserIds"] = mentions
}
return rt.CallMCP("update_comment", params)
},
@@ -113,14 +118,29 @@ func validateCommentCreate(rt *shortcut.RuntimeContext) error {
func executeCommentCreate(rt *shortcut.RuntimeContext) error {
params := map[string]any{"nodeId": rt.Str("node"), "content": rt.Str("content")}
if rt.Changed("mention") {
params["mentionedUserIds"] = rt.StrSlice("mention")
mentions, err := normalizeMentionUserIDs(rt.StrSlice("mention"))
if err != nil {
return err
}
params["mentionedUserIds"] = mentions
}
if rt.Str("block-id") != "" {
params["blockId"], params["start"], params["end"] = rt.Str("block-id"), rt.Int("start"), rt.Int("end")
if rt.Str("selected-text") != "" {
params["selectedText"] = rt.Str("selected-text")
blockID, start, end := rt.Str("block-id"), rt.Int("start"), rt.Int("end")
blocks, err := rt.CallMCPData(productDoc, "list_document_blocks", map[string]any{"nodeId": rt.Str("node"), "blockId": blockID, "format": "element"})
if err != nil {
return err
}
return rt.CallMCP("create_inline_comment", params)
blockText := map[string]string{}
collectBlockText(blocks, "", blockText)
selectedText, ok := sliceUTF16Range(blockText[blockID], start, end)
if !ok {
return apperrors.NewValidation(fmt.Sprintf("INVALID_SELECTION_RANGE: block %s 的 UTF-16 范围 [%d,%d) 无效", blockID, start, end))
}
if supplied := rt.Str("selected-text"); supplied != "" && supplied != selectedText {
return apperrors.NewValidation("SELECTED_TEXT_MISMATCH: --selected-text 与 block/start/end 对应的真实原文不一致")
}
params["blockId"], params["start"], params["end"], params["selectedText"] = blockID, start, end, selectedText
return executeCommentWrite(rt, "create_inline_comment", "inline", params)
}
if selection := rt.Str("selection"); selection != "" {
blocks, err := rt.CallMCPData(productDoc, "list_document_blocks", map[string]any{"nodeId": rt.Str("node"), "format": "element"})
@@ -136,9 +156,62 @@ func executeCommentCreate(rt *shortcut.RuntimeContext) error {
return apperrors.NewValidation(fmt.Sprintf("AMBIGUOUS_SELECTION: selection 需要唯一匹配,实际 %d 处;候选=%v", len(matches), candidates))
}
params["blockId"], params["start"], params["end"], params["selectedText"] = matches[0].blockID, matches[0].start, matches[0].end, matches[0].selected
return rt.CallMCP("create_inline_comment", params)
return executeCommentWrite(rt, "create_inline_comment", "inline", params)
}
return rt.CallMCP("create_comment", params)
return executeCommentWrite(rt, "create_comment", "global", params)
}
func normalizeMentionUserIDs(values []string) ([]string, error) {
if len(values) == 1 && strings.HasPrefix(strings.TrimSpace(values[0]), "[") {
var decoded []any
decoder := json.NewDecoder(strings.NewReader(values[0]))
decoder.UseNumber()
if err := decoder.Decode(&decoded); err != nil {
return nil, apperrors.NewValidation(fmt.Sprintf("--mention JSON 数组解析失败: %v", err))
}
values = make([]string, 0, len(decoded))
for _, value := range decoded {
switch typed := value.(type) {
case string:
values = append(values, typed)
case json.Number:
values = append(values, typed.String())
default:
return nil, apperrors.NewValidation("--mention JSON 数组只接受字符串或数字 uid")
}
}
}
values = stringSliceNonEmpty(values)
if len(values) == 0 {
return nil, apperrors.NewValidation("--mention 至少包含一个非空用户 uid")
}
return values, nil
}
func executeCommentWrite(rt *shortcut.RuntimeContext, tool, commentType string, params map[string]any) error {
result, err := rt.CallMCPWriteData(productComment, tool, params)
if err != nil {
return docUnknownWriteError("doc.comment_create", tool, rt.Str("node"), err)
}
return rt.Output(docEnvelope("doc.comment_create", map[string]any{
"nodeId": rt.Str("node"), "commentType": commentType, "result": result,
}, map[string]any{"name": tool, "status": "success"}))
}
func sliceUTF16Range(text string, start, end int) (string, bool) {
units := utf16.Encode([]rune(text))
if start < 0 || end <= start || end > len(units) {
return "", false
}
isHigh := func(value uint16) bool { return value >= 0xD800 && value <= 0xDBFF }
isLow := func(value uint16) bool { return value >= 0xDC00 && value <= 0xDFFF }
if start > 0 && start < len(units) && isHigh(units[start-1]) && isLow(units[start]) {
return "", false
}
if end > 0 && end < len(units) && isHigh(units[end-1]) && isLow(units[end]) {
return "", false
}
return string(utf16.Decode(units[start:end])), true
}
type selectionMatch struct {
+285
View File
@@ -0,0 +1,285 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
// Package docresolver resolves document IDs, URLs, and natural titles to one
// typed document target before a shortcut performs a business operation.
package docresolver
import (
"fmt"
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
const (
searchPageSize = 30
searchPageLimit = 40
)
// Reader is the read-only RuntimeContext surface needed by the resolver.
type Reader interface {
CallMCPData(product, tool string, params map[string]any) (map[string]any, error)
}
// Candidate is a credential-free document search candidate.
type Candidate struct {
CanonicalID string `json:"canonicalId"`
Name string `json:"name,omitempty"`
Product string `json:"product"`
ResourceType string `json:"resourceType,omitempty"`
URL string `json:"canonicalUrl,omitempty"`
ContainerID string `json:"containerId,omitempty"`
}
// Resolution is the stable result consumed by document shortcuts.
type Resolution struct {
ContractVersion string `json:"contractVersion"`
Status string `json:"status"`
Query string `json:"query,omitempty"`
MatchedBy string `json:"matchedBy"`
Complete bool `json:"complete"`
Selected Candidate `json:"selected"`
Candidates []Candidate `json:"candidates"`
}
// Resolve accepts exactly one stable node/URL or natural title. Natural title
// resolution exhausts pagination before selecting a unique candidate.
func Resolve(rt Reader, node, query string) (Resolution, error) {
node = strings.TrimSpace(node)
query = strings.TrimSpace(query)
if (node == "") == (query == "") {
return Resolution{}, apperrors.NewValidation(
"文档目标必须且只能提供一个:--node 或 --query",
apperrors.WithReason("document_target_invalid"),
apperrors.WithFailureStage("target_resolution"),
apperrors.WithExecutionStarted(false),
)
}
if node != "" {
return Resolution{
ContractVersion: "doc.target.v1",
Status: "resolved",
MatchedBy: "stable_id_or_url",
Complete: true,
Selected: Candidate{
CanonicalID: node,
Product: "doc",
},
Candidates: []Candidate{},
}, nil
}
candidates, err := searchAll(rt, query)
if err != nil {
return Resolution{}, err
}
selected, matchedBy := selectCandidates(candidates, query)
if len(selected) == 0 {
return Resolution{}, resolutionError("not_found", query, candidates, "没有找到匹配的在线文字文档")
}
if len(selected) > 1 {
return Resolution{}, resolutionError("ambiguous", query, selected, "找到多个匹配文档,请使用 nodeId 或 URL 消歧")
}
if typ := normalizeType(selected[0].ResourceType); typ != "" && typ != "adoc" && typ != "document" && typ != "doc" {
return Resolution{}, apperrors.NewValidation(
fmt.Sprintf("目标 %q 的资源类型为 %s,不是在线文字文档", selected[0].Name, selected[0].ResourceType),
apperrors.WithReason("document_target_type_mismatch"),
apperrors.WithFailureStage("target_resolution"),
apperrors.WithExecutionStarted(false),
apperrors.WithDetails(map[string]any{"status": "type_mismatch", "target": selected[0]}),
)
}
return Resolution{
ContractVersion: "doc.target.v1",
Status: "resolved",
Query: query,
MatchedBy: matchedBy,
Complete: true,
Selected: selected[0],
Candidates: []Candidate{},
}, nil
}
func searchAll(rt Reader, query string) ([]Candidate, error) {
cursor := ""
seenCursors := map[string]bool{}
all := make([]Candidate, 0)
for page := 1; page <= searchPageLimit; page++ {
params := map[string]any{"keyword": query, "pageSize": searchPageSize}
if cursor != "" {
params["pageToken"] = cursor
}
data, err := rt.CallMCPData("doc", "search_documents", params)
if err != nil {
return nil, err
}
rows := extractRows(data)
all = append(all, extractCandidates(rows)...)
hasMore, hasMoreKnown, next := extractPage(data)
switch {
case hasMoreKnown && !hasMore:
return dedupe(all), nil
case hasMoreKnown && hasMore:
// next is validated below.
case next != "":
// Older responses may only publish the continuation.
case len(rows) < searchPageSize:
return dedupe(all), nil
default:
return nil, incompleteError(query, all, "搜索返回满页结果但没有结束标记或下一页游标")
}
next = strings.TrimSpace(next)
if next == "" {
return nil, incompleteError(query, all, "搜索声明仍有更多结果但缺少下一页游标")
}
if next == cursor || seenCursors[next] {
return nil, incompleteError(query, all, fmt.Sprintf("搜索分页游标停滞在 %q", next))
}
seenCursors[next] = true
cursor = next
}
return nil, incompleteError(query, all, fmt.Sprintf("搜索超过最大页数 %d", searchPageLimit))
}
func selectCandidates(candidates []Candidate, query string) ([]Candidate, string) {
exact := make([]Candidate, 0)
for _, candidate := range candidates {
if strings.EqualFold(strings.TrimSpace(candidate.Name), query) {
exact = append(exact, candidate)
}
}
if len(exact) > 0 {
return exact, "exact_title"
}
return candidates, "search_candidate"
}
func extractCandidates(rows []any) []Candidate {
out := make([]Candidate, 0, len(rows))
for _, row := range rows {
item, ok := row.(map[string]any)
if !ok {
continue
}
candidate := Candidate{
CanonicalID: firstString(item, "nodeId", "node_id", "docId", "doc_id", "id"),
Name: firstString(item, "name", "title", "docName", "fileName"),
Product: "doc",
ResourceType: firstString(item, "docType", "doc_type", "extension", "fileType", "type"),
URL: firstString(item, "url", "docUrl", "nodeUrl", "webUrl"),
ContainerID: firstString(item, "workspaceId", "spaceId", "folderId", "parentId"),
}
if candidate.CanonicalID != "" {
out = append(out, candidate)
}
}
return out
}
func dedupe(in []Candidate) []Candidate {
seen := make(map[string]bool, len(in))
out := make([]Candidate, 0, len(in))
for _, candidate := range in {
if candidate.CanonicalID == "" || seen[candidate.CanonicalID] {
continue
}
seen[candidate.CanonicalID] = true
out = append(out, candidate)
}
return out
}
func extractRows(data map[string]any) []any {
for _, key := range []string{"documents", "nodes", "items", "list", "records", "result", "data"} {
value, ok := data[key]
if !ok {
continue
}
if rows, ok := value.([]any); ok {
return rows
}
if nested, ok := value.(map[string]any); ok {
if rows := extractRows(nested); rows != nil {
return rows
}
}
}
return []any{}
}
func extractPage(data map[string]any) (bool, bool, string) {
var hasMore bool
var hasMoreKnown bool
var next string
var walk func(map[string]any)
walk = func(value map[string]any) {
if !hasMoreKnown {
for _, key := range []string{"hasMore", "has_more"} {
if flag, ok := value[key].(bool); ok {
hasMore, hasMoreKnown = flag, true
break
}
}
}
if next == "" {
next = firstString(value, "nextPageToken", "nextCursor", "next_page_token")
}
for _, key := range []string{"result", "data", "page", "pagination"} {
if nested, ok := value[key].(map[string]any); ok {
walk(nested)
}
}
}
walk(data)
return hasMore, hasMoreKnown, next
}
func firstString(value map[string]any, keys ...string) string {
for _, key := range keys {
if text, ok := value[key].(string); ok && strings.TrimSpace(text) != "" {
return strings.TrimSpace(text)
}
}
return ""
}
func normalizeType(value string) string {
return strings.ToLower(strings.TrimSpace(strings.TrimPrefix(value, ".")))
}
func resolutionError(status, query string, candidates []Candidate, message string) error {
return apperrors.NewValidation(
message,
apperrors.WithReason("document_target_"+status),
apperrors.WithFailureStage("target_resolution"),
apperrors.WithExecutionStarted(false),
apperrors.WithRetryable(false),
apperrors.WithDetails(map[string]any{
"contractVersion": "doc.target.v1",
"status": status,
"query": query,
"complete": true,
"candidates": candidates,
}),
)
}
func incompleteError(query string, candidates []Candidate, cause string) error {
return apperrors.NewAPI(
"无法证明文档搜索候选完整,已停止目标解析",
apperrors.WithReason("document_target_incomplete"),
apperrors.WithFailureStage("target_resolution"),
apperrors.WithExecutionStarted(false),
apperrors.WithRetryable(true),
apperrors.WithActions("稍后以相同条件重试", "使用已知 nodeId 或 URL 直接指定目标"),
apperrors.WithDetails(map[string]any{
"contractVersion": "doc.target.v1",
"status": "incomplete",
"query": query,
"complete": false,
"cause": cause,
"candidates": dedupe(candidates),
}),
)
}
@@ -0,0 +1,154 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package docresolver
import (
"errors"
"fmt"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
type scriptedReader struct {
pages []map[string]any
err error
calls int
}
func (r *scriptedReader) CallMCPData(_ string, _ string, _ map[string]any) (map[string]any, error) {
r.calls++
if r.err != nil {
return nil, r.err
}
if len(r.pages) == 0 {
return map[string]any{}, nil
}
page := r.pages[0]
r.pages = r.pages[1:]
return page, nil
}
func row(id, name, typ string) any {
return map[string]any{"nodeId": id, "name": name, "docType": typ, "url": "https://alidocs.dingtalk.com/i/nodes/" + id}
}
func TestCrossPlatformCoverageResolveStableTargetDoesNotSearch(t *testing.T) {
reader := &scriptedReader{}
resolution, err := Resolve(reader, "node-1", "")
if err != nil || reader.calls != 0 || resolution.Selected.CanonicalID != "node-1" || !resolution.Complete {
t.Fatalf("resolution=%#v calls=%d err=%v", resolution, reader.calls, err)
}
}
func TestCrossPlatformCoverageResolveNaturalTitleExhaustsPagesBeforeSelecting(t *testing.T) {
first := make([]any, 0, searchPageSize)
for i := 0; i < searchPageSize; i++ {
first = append(first, row(fmt.Sprintf("other-%d", i), "其他", "adoc"))
}
reader := &scriptedReader{pages: []map[string]any{
{"documents": first, "hasMore": true, "nextPageToken": "p2"},
{"documents": []any{row("wanted", "项目周报", "adoc")}, "hasMore": false},
}}
resolution, err := Resolve(reader, "", "项目周报")
if err != nil || reader.calls != 2 || resolution.Selected.CanonicalID != "wanted" || resolution.MatchedBy != "exact_title" {
t.Fatalf("resolution=%#v calls=%d err=%v", resolution, reader.calls, err)
}
}
func TestCrossPlatformCoverageResolveFailsClosedForAmbiguousIncompleteAndWrongType(t *testing.T) {
tests := []struct {
name string
pages []map[string]any
query string
reason string
}{
{"ambiguous", []map[string]any{{"documents": []any{row("1", "周报", "adoc"), row("2", "周报", "adoc")}, "hasMore": false}}, "周报", "ambiguous"},
{"not found", []map[string]any{{"documents": []any{}, "hasMore": false}}, "missing", "not_found"},
{"stalled", []map[string]any{{"documents": []any{row("1", "周报", "adoc")}, "hasMore": true, "nextPageToken": "p1"}, {"documents": []any{}, "hasMore": true, "nextPageToken": "p1"}}, "周报", "incomplete"},
{"wrong type", []map[string]any{{"documents": []any{row("1", "周报", "pdf")}, "hasMore": false}}, "周报", "type_mismatch"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := Resolve(&scriptedReader{pages: tc.pages}, "", tc.query)
var typed *apperrors.Error
if err == nil || !errors.As(err, &typed) || !strings.Contains(typed.Reason, tc.reason) {
t.Fatalf("err=%v", err)
}
})
}
reader := &scriptedReader{err: errors.New("backend")}
if _, err := Resolve(reader, "", "x"); !errors.Is(err, reader.err) {
t.Fatalf("transport error=%v", err)
}
}
func TestCrossPlatformCoverageResolverFinalBranchMatrix(t *testing.T) {
for _, target := range [][2]string{{"", ""}, {"node", "query"}} {
if _, err := Resolve(&scriptedReader{}, target[0], target[1]); err == nil {
t.Fatalf("invalid target %#v succeeded", target)
}
}
full := make([]any, searchPageSize)
for i := range full {
full[i] = row(fmt.Sprintf("full-%d", i), "other", "adoc")
}
for _, tc := range []struct {
name string
pages []map[string]any
}{
{"unproven full page", []map[string]any{{"documents": full}}},
{"missing cursor", []map[string]any{{"documents": []any{}, "hasMore": true}}},
} {
t.Run(tc.name, func(t *testing.T) {
if _, err := Resolve(&scriptedReader{pages: tc.pages}, "", "wanted"); err == nil {
t.Fatal("incomplete pagination succeeded")
}
})
}
if _, err := Resolve(&scriptedReader{pages: []map[string]any{{"documents": []any{}}}}, "", "wanted"); err == nil {
t.Fatal("underfilled implicit final page should resolve to not-found")
}
pages := make([]map[string]any, searchPageLimit)
for i := range pages {
pages[i] = map[string]any{"documents": []any{}, "hasMore": true, "nextPageToken": fmt.Sprintf("p-%d", i+1)}
}
if _, err := Resolve(&scriptedReader{pages: pages}, "", "wanted"); err == nil {
t.Fatal("max-page pagination succeeded")
}
nested := map[string]any{
"result": map[string]any{
"data": map[string]any{
"records": []any{
"invalid",
map[string]any{"id": "", "title": "empty"},
map[string]any{"doc_id": "d", "fileName": "Doc", "fileType": ".ADOC", "webUrl": " u ", "parentId": "p"},
},
"pagination": map[string]any{"has_more": false, "next_page_token": " next "},
},
},
}
rows := extractRows(nested)
candidates := extractCandidates(rows)
if len(candidates) != 1 || candidates[0].CanonicalID != "d" || normalizeType(candidates[0].ResourceType) != "adoc" {
t.Fatalf("nested candidates = %#v", candidates)
}
if hasMore, known, next := extractPage(nested); !known || hasMore || next != "next" {
t.Fatalf("nested page = %v/%v/%q", hasMore, known, next)
}
if rows := extractRows(map[string]any{"items": "not-a-list"}); len(rows) != 0 {
t.Fatalf("non-list rows = %#v", rows)
}
deduped := dedupe([]Candidate{{}, {CanonicalID: "d"}, {CanonicalID: "d"}})
if len(deduped) != 1 {
t.Fatalf("deduped = %#v", deduped)
}
selected, matchedBy := selectCandidates([]Candidate{{CanonicalID: "d", Name: "Different"}}, "query")
if len(selected) != 1 || matchedBy != "search_candidate" {
t.Fatalf("fallback selection = %#v/%q", selected, matchedBy)
}
}
+14
View File
@@ -153,6 +153,20 @@ func (rt *RuntimeContext) CallMCPData(product, tool string, params map[string]an
return rt.callMCPData(product, tool, params)
}
// CallMCPReadData dispatches a tool whose name is explicitly classified as
// read-only without consulting Cobra state. Callers must invoke it serially
// unless the injected ToolCaller separately documents and enforces concurrent
// safety. Write-shaped tool names fail closed.
func (rt *RuntimeContext) CallMCPReadData(product, tool string, params map[string]any) (map[string]any, error) {
if !looksReadTool(tool) {
return nil, apperrors.NewValidation(fmt.Sprintf(
"并发只读入口拒绝写工具 %s/%s;写操作必须使用 CallMCPWriteData",
product, tool,
))
}
return rt.callMCPReadData(product, tool, params)
}
// CallMCPWriteData dispatches a write tool call and returns its parsed response.
// Unlike CallMCPData, it refuses to run under --dry-run so smart shortcuts cannot
// accidentally perform writes while rendering a preview. For compatibility with
+8 -8
View File
@@ -11,11 +11,11 @@
"+checkpoint-update": {"disposition":"primary_smart","semantic_delta":"写入前保存版本快照,更新后读回验证并输出逐步 ledger。","risk":"write","public":true,"reviewed":true},
"+export": {"disposition":"primary_smart","semantic_delta":"一体化提交、轮询导出任务并按 no-clobber 策略安全下载到本地。","risk":"read","public":true,"reviewed":true},
"+import": {"disposition":"primary_smart","semantic_delta":"一体化创建会话、上传、确认转换并轮询导入结果;白名单外格式自动改走文件上传原样入库(fallback=upload、converted=false)。","risk":"write","public":true,"reviewed":true},
"+history-save": {"disposition":"semantic_adapter","semantic_delta":"以文档历史语义命名手动版本快照,避免暴露底层 RPC 命名。","risk":"write","public":true,"reviewed":true},
"+history-list": {"disposition":"semantic_adapter","semantic_delta":"统一历史版本分页参数并返回可用于回滚的版本列表。","risk":"read","public":true,"reviewed":true},
"+history-revert": {"disposition":"primary_smart","semantic_delta":"先验证目标版本存在,再执行回滚并读回当前文档状态。","risk":"high-risk-write","public":true,"reviewed":true},
"+version-save": {"disposition":"semantic_adapter","semantic_delta":"版本快照的 Agent 主入口;只保存当前快照,不隐式修改正文。","risk":"write","public":true,"reviewed":true},
"+version-list": {"disposition":"semantic_adapter","semantic_delta":"版本浏览的 Agent 主入口;统一分页参数并返回可用于回滚的版本号。","risk":"read","public":true,"reviewed":true},
"+version-revert": {"disposition":"primary_smart","semantic_delta":"版本回滚的 Agent 主入口;先验证目标版本存在,再回滚并读回当前状态。","risk":"high-risk-write","public":true,"reviewed":true},
"+template-list": {"disposition":"semantic_adapter","semantic_delta":"统一 MY/PUBLIC 模板浏览和分页参数。","risk":"read","public":true,"reviewed":true},
"+template-search": {"disposition":"semantic_adapter","semantic_delta":"按名称检索模板并返回可继续创建的 templateId。","risk":"read","public":true,"reviewed":true},
"+template-search": {"disposition":"semantic_adapter","semantic_delta":"按名称或关键词检索模板并返回可消歧候选和 templateId。","risk":"read","public":true,"reviewed":true},
"+create-from-template": {"disposition":"primary_smart","semantic_delta":"支持 templateId 直达或按名称搜索消歧后创建文档。","risk":"write","public":true,"reviewed":true},
"+media-list": {"disposition":"semantic_adapter","semantic_delta":"从文档块中提取图片、附件及其 block/resource 标识。","risk":"read","public":true,"reviewed":true},
"+media-insert": {"disposition":"primary_smart","semantic_delta":"组合本地文件校验、上传凭证、OSS PUT、插块和验证。","risk":"write","public":true,"reviewed":true},
@@ -40,9 +40,9 @@
"+find-doc": {"disposition":"alias_internal","semantic_delta":"保留历史文档搜索命令及其稳定 Schema identity;新场景优先使用 +search。","risk":"read","primary":"+search","public":true,"reviewed":true},
"+doc-append": {"disposition":"alias_internal","semantic_delta":"保留历史文档末尾追加命令及其稳定 Schema identity;新场景优先使用 +update。","risk":"write","primary":"+update","public":true,"reviewed":true},
"+version-save": {"disposition":"alias_internal","semantic_delta":"保留历史版本快照命令及其稳定 Schema identity;新场景优先使用 +history-save。","risk":"write","primary":"+history-save","public":true,"reviewed":true},
"+version-list": {"disposition":"alias_internal","semantic_delta":"保留历史版本列表命令及其稳定 Schema identity;新场景优先使用 +history-list。","risk":"read","primary":"+history-list","public":true,"reviewed":true},
"+version-revert": {"disposition":"alias_internal","semantic_delta":"保留历史版本回滚命令及其稳定 Schema identity;新场景优先使用 +history-revert。","risk":"high-risk-write","primary":"+history-revert","public":true,"reviewed":true},
"+history-save": {"disposition":"alias_internal","semantic_delta":"保留既有历史快照路径及稳定 Schema identity;新的 Agent 场景统一使用 +version-save。","risk":"write","primary":"+version-save","public":true,"reviewed":true},
"+history-list": {"disposition":"alias_internal","semantic_delta":"保留既有历史列表路径及稳定 Schema identity;新的 Agent 场景统一使用 +version-list。","risk":"read","primary":"+version-list","public":true,"reviewed":true},
"+history-revert": {"disposition":"alias_internal","semantic_delta":"保留既有历史回滚路径及稳定 Schema identity;新的 Agent 场景统一使用 +version-revert。","risk":"high-risk-write","primary":"+version-revert","public":true,"reviewed":true},
"+share-doc": {"disposition":"alias_internal","semantic_delta":"保留历史单人文档分享命令及其稳定 Schema identity;新场景优先使用 +share。","risk":"write","primary":"+share","public":true,"reviewed":true},
"+list": {"disposition":"alias_internal","semantic_delta":"旧 Doc 导航入口,仅为兼容保留;新的文件树导航应使用 Drive 命令。","risk":"read","primary":"drive list","public":true,"reviewed":true},
@@ -50,7 +50,7 @@
"+move": {"disposition":"alias_internal","semantic_delta":"旧 Doc 移动入口,仅为兼容保留;新的文件移动应使用 Drive 命令。","risk":"write","primary":"drive move","public":true,"reviewed":true},
"+export-submit": {"disposition":"alias_internal","semantic_delta":"导出中断恢复所需的专家入口;常规场景使用一体化 +export。","risk":"read","primary":"+export","public":true,"reviewed":true},
"+export-get": {"disposition":"alias_internal","semantic_delta":"按 jobId 查询导出状态的恢复入口;常规场景使用一体化 +export。","risk":"read","primary":"+export","public":true,"reviewed":true},
"+comment-create-inline": {"disposition":"alias_internal","semantic_delta":"已知 block/start/end 时使用的低级批注入口;常规场景使用 +comment-create。","risk":"write","primary":"+comment-create","public":false,"reviewed":true},
"+comment-create-inline": {"disposition":"alias_internal","semantic_delta":"仅兼容既有调用;selection 与 block/start/end 新场景都统一使用 +comment-create。","risk":"write","primary":"+comment-create","public":false,"reviewed":true},
"+template-apply": {"disposition":"alias_internal","semantic_delta":"已知 templateId 时使用的低级入口;常规场景使用 +create-from-template。","risk":"write","primary":"+create-from-template","public":false,"reviewed":true}
}
}
+8
View File
@@ -685,6 +685,14 @@ func TestCrossPlatformCoverageCallMCPWriteDataRejectsDryRun(t *testing.T) {
}
}
func TestCrossPlatformCoverageCallMCPReadDataRejectsWriteName(t *testing.T) {
rt := &RuntimeContext{}
_, err := rt.CallMCPReadData("doc", "update_document", map[string]any{"nodeId": "n"})
if err == nil || !strings.Contains(err.Error(), "doc/update_document") {
t.Fatalf("read-only guard error = %v", err)
}
}
func TestCrossPlatformCoverageCallMCPDataDryRunReadNameFailsClosed(t *testing.T) {
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().Bool("dry-run", false, "")
+1 -1
View File
@@ -124,7 +124,7 @@ func TestStdioClientCallBeforeStart(t *testing.T) {
func TestStdioClientEnsureInitializedIsIdempotent(t *testing.T) {
client := NewStdioClient(buildTestHelper(t), nil, nil)
defer client.Stop()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := client.EnsureInitialized(ctx); err != nil {
+1 -1
View File
@@ -19,7 +19,7 @@ Script groups:
- Product convenience installers: `./scripts/install-devapp.sh`, `./scripts/install-devapp.ps1`, `./scripts/install-event.sh`
- CI package plan: `./scripts/ci/test-packages.sh`
- Dev helpers: `./scripts/dev/build.sh`, `./scripts/dev/lint.sh`, `./scripts/dev/ci-local.sh`, `./scripts/dev/run-mock-e2e.sh`, `./scripts/dev/coverage.sh`
- Policy checks: `./scripts/policy/check-generated-drift.sh`, `./scripts/policy/check-command-surface.sh`, `./scripts/policy/check-command-compatibility.sh --base-ref <main-ref> --stable-ref <latest-GA-tag>`, `./scripts/policy/check-schema-catalog.sh`, `./scripts/policy/check-schema-binary.sh`, `./scripts/policy/check-open-source-assets.sh`
- Policy checks: `./scripts/policy/check-generated-drift.sh`, `./scripts/policy/check-command-surface.sh`, `./scripts/policy/check-command-compatibility.sh --base-ref <main-ref> --stable-ref <latest-GA-tag> --candidate-ref <candidate-sha>`, `./scripts/policy/check-schema-catalog.sh`, `./scripts/policy/check-schema-binary.sh`, `./scripts/policy/check-open-source-assets.sh`
- Policy runtime files: set `DWS_POLICY_TMPDIR` to override the default `.worktrees/policy-tmp` workspace
- Release entrypoint and contract: `./scripts/release/release.sh`, `./scripts/release/release-contract.sh`; operator guide: [`docs/releasing.md`](../docs/releasing.md)
- Release packaging helpers: `./scripts/release/post-goreleaser.sh`, `./scripts/release/stage-npm-package.sh`, `./scripts/release/pack-npm-package.sh`, `./scripts/release/verify-delivered-stable.sh`, `./scripts/release/verify-release-artifacts.sh`, `./scripts/release/verify-github-release-assets.sh`, `./scripts/release/verify-github-tag-authority.sh`, `./scripts/release/verify-package-managers.sh`, `./scripts/release/publish-homebrew-formula.sh`
+15 -3
View File
@@ -59,7 +59,7 @@ RUNTIME_CONTRACT_END = "<!-- DWS_RUNTIME_CONTRACT_END -->"
# here only after verifying that the product skill has its own reviewed routing
# section and intent table; compacting a sparse skill without an alternative
# route would make its shortcuts harder to discover.
COMPACT_PRODUCT_SERVICES = {"chat"}
COMPACT_PRODUCT_SERVICES = {"chat", "doc"}
def md_escape(value: Any) -> str:
@@ -121,7 +121,10 @@ def runtime_contract_block() -> str:
def mono_overview(items: list[dict[str, Any]]) -> str:
counts = Counter(item["service"] for item in items)
# The source collector cannot see a small number of commands whose final
# canonical names are normalized at runtime. Overview counts come from the
# reviewed public catalog so they cannot under-report those declarations.
counts = Counter(service for service, _ in load_public_catalog())
rows = []
for service, count in sorted(counts.items()):
path = SERVICE_TO_SKILL.get(service)
@@ -163,10 +166,18 @@ def product_section(service: str, rows: list[dict[str, Any]]) -> str:
def compact_product_section(service: str, rows: list[dict[str, Any]]) -> str:
# Compact skills intentionally do not depend on the source parser's ability
# to recover every runtime-normalized declaration. The reviewed public
# catalog is the count authority for this non-enumerating overview.
public_count = sum(1 for item_service, _ in load_public_catalog() if item_service == service)
if service == "doc":
discovery = """已知意图按下方路由。"""
else:
discovery = """已知意图直接使用下方的优先路由、意图表或任务 reference;命令已选中时直接执行,只在参数/安全语义不确定时读取 leaf Schema,在当前 Cobra flags 不确定时读取 leaf Help。"""
return f"""{PRODUCT_START}
## Shortcut 发现(按需)
`{md_escape(service)}` 当前有 {len(rows)} 条公开 shortcut,完整清单保留在 Runtime Catalog 与 Schema,不在高频产品根 Skill 中重复展开。已知意图直接使用下方的优先路由、意图表或任务 reference;命令已选中时直接执行,只在参数/安全语义不确定时读取 leaf Schema,在当前 Cobra flags 不确定时读取 leaf Help。
`{md_escape(service)}` 当前有 {public_count} 条公开 shortcut,完整清单保留在 Runtime Catalog 与 Schema,不在高频产品根 Skill 中重复展开。{discovery}
仅当现有路由和 reference 都无法定位低频能力时,才执行 `dws shortcut list --service {md_escape(service)} --format json` 做最后回退;不要为已知高频意图加载完整 Shortcut Catalog 或产品级 Schema。
{PRODUCT_END}"""
@@ -194,6 +205,7 @@ def update_runtime_contract(check: bool) -> list[Path]:
changed = []
targets = [
ROOT / "skills" / "multi" / "dingtalk-chat" / "SKILL.md",
ROOT / "skills" / "multi" / "dingtalk-doc" / "SKILL.md",
SHARED_SKILL,
]
for path in targets:
+28 -7
View File
@@ -4,7 +4,8 @@
#
# 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 (personal IM/OA event routing)
# - multi prerequisites: dingtalk-shared + clean dingtalk-misc
# - mono skill: dws
#
# Usage:
@@ -25,7 +26,9 @@ 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"
SHARED_SKILL_NAME="dingtalk-shared"
MISC_SKILL_NAME="dingtalk-misc"
MONO_SKILL_NAME="dws"
if [ "$EVENT_VERSION" = "latest" ]; then
@@ -123,12 +126,13 @@ install_skill_to_homes() {
printf '%s\n' "$installed"
}
find_event_skill_src() {
find_multi_skill_src() {
bundle="$1"
skill_name="$2"
for c in \
"$bundle/multi/$EVENT_SKILL_NAME" \
"$bundle/skills/multi/$EVENT_SKILL_NAME" \
"$bundle/$EVENT_SKILL_NAME"
"$bundle/multi/$skill_name" \
"$bundle/skills/multi/$skill_name" \
"$bundle/$skill_name"
do
if [ -f "$c/SKILL.md" ]; then
printf '%s\n' "$c"
@@ -157,23 +161,40 @@ find_mono_skill_src() {
install_skills_from_bundle() {
bundle="$1"
event_src="$(find_event_skill_src "$bundle" || true)"
# Resolve the complete, version-matched set before touching any installed or
# cached skill. A malformed release therefore cannot leave a half-migrated
# event/misc pair behind.
event_src="$(find_multi_skill_src "$bundle" "$EVENT_SKILL_NAME" || true)"
[ -n "$event_src" ] || err "${EVENT_SKILL_NAME} not found in dws-skills.zip"
shared_src="$(find_multi_skill_src "$bundle" "$SHARED_SKILL_NAME" || true)"
[ -n "$shared_src" ] || err "${SHARED_SKILL_NAME} not found in dws-skills.zip"
misc_src="$(find_multi_skill_src "$bundle" "$MISC_SKILL_NAME" || true)"
[ -n "$misc_src" ] || err "${MISC_SKILL_NAME} not found in dws-skills.zip"
mono_src="$(find_mono_skill_src "$bundle" || true)"
[ -n "$mono_src" ] || err "mono ${MONO_SKILL_NAME} skill not found in dws-skills.zip"
event_cache="$HOME/.dws/skills/multi/$EVENT_SKILL_NAME"
shared_cache="$HOME/.dws/skills/multi/$SHARED_SKILL_NAME"
misc_cache="$HOME/.dws/skills/multi/$MISC_SKILL_NAME"
copy_tree "$event_src" "$event_cache"
copy_tree "$shared_src" "$shared_cache"
copy_tree "$misc_src" "$misc_cache"
mono_cache="$HOME/.dws/skills/mono"
copy_tree "$mono_src" "$mono_cache"
event_installed="$(install_skill_to_homes "$event_src" "$EVENT_SKILL_NAME")"
shared_installed="$(install_skill_to_homes "$shared_src" "$SHARED_SKILL_NAME")"
misc_installed="$(install_skill_to_homes "$misc_src" "$MISC_SKILL_NAME")"
mono_installed="$(install_skill_to_homes "$mono_src" "$MONO_SKILL_NAME")"
say "Skill ${EVENT_SKILL_NAME} -> ${event_installed} agent dir(s)"
say "Skill ${SHARED_SKILL_NAME} -> ${shared_installed} agent dir(s)"
say "Skill ${MISC_SKILL_NAME} -> ${misc_installed} agent dir(s)"
say "Skill ${MONO_SKILL_NAME} -> ${mono_installed} agent dir(s)"
say "Cached ${EVENT_SKILL_NAME} -> ${event_cache}"
say "Cached ${SHARED_SKILL_NAME} -> ${shared_cache}"
say "Cached ${MISC_SKILL_NAME} -> ${misc_cache}"
say "Cached mono ${MONO_SKILL_NAME} -> ${mono_cache}"
}
@@ -1,15 +1,16 @@
#!/bin/sh
set -eu
# Compare the candidate command tree against a Git-owned historical source
# revision. CI invokes this for both the PR merge-base and latest stable tag; a
# fixture from the candidate branch is never used as authority.
# Compatibility decisions are centralized in check-command-compatibility.sh.
# This historical entry point remains as the Makefile/user-facing wrapper.
ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
BASE_REF=""
STABLE_REF=""
CANDIDATE_REF="HEAD"
usage() {
printf '%s\n' "usage: $0 --base-ref <ref>" >&2
printf '%s\n' "usage: $0 --base-ref <ref> [--stable-ref <ref>] [--candidate-ref <ref>]" >&2
}
while [ "$#" -gt 0 ]; do
@@ -19,6 +20,16 @@ while [ "$#" -gt 0 ]; do
BASE_REF="$2"
shift 2
;;
--stable-ref)
[ "$#" -ge 2 ] || { usage; exit 2; }
STABLE_REF="$2"
shift 2
;;
--candidate-ref)
[ "$#" -ge 2 ] || { usage; exit 2; }
CANDIDATE_REF="$2"
shift 2
;;
-h|--help)
usage
exit 0
@@ -32,63 +43,30 @@ while [ "$#" -gt 0 ]; do
done
[ -n "$BASE_REF" ] || { usage; exit 2; }
cd "$ROOT"
. "$ROOT/scripts/policy/policy-runtime.sh"
policy_prepare_runtime "$ROOT"
. "$ROOT/scripts/release/release-lib.sh"
git rev-parse --verify --quiet "${BASE_REF}^{commit}" >/dev/null || {
printf 'error: interface authority is not available locally: %s\n' "$BASE_REF" >&2
exit 2
}
TMP_ROOT="$(policy_runtime_mktemp_dir dws-interface-authority)"
BASE_WORKTREE="$TMP_ROOT/base-worktree"
BASELINE="$TMP_ROOT/merge-base.txt"
CANDIDATE_BIN="$TMP_ROOT/interface-baseline"
CANDIDATE_HOME="$TMP_ROOT/candidate-home"
if [ -z "$STABLE_REF" ]; then
for tag in $(git tag --merged "$BASE_REF" --list 'v*' --sort=-version:refname); do
release_is_stable_version "$tag" || continue
if git rev-parse --verify --quiet "refs/tags/withdrawn/$tag" >/dev/null; then
continue
fi
STABLE_REF="$tag"
break
done
fi
if [ -z "$STABLE_REF" ]; then
printf 'error: no stable release tag is reachable from interface authority %s\n' "$BASE_REF" >&2
exit 2
fi
cleanup() {
git -C "$ROOT" worktree remove --force "$BASE_WORKTREE" >/dev/null 2>&1 || true
rm -rf "$TMP_ROOT"
}
trap cleanup EXIT
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM
install_snapshot_helper() {
worktree="$1"
helper="$worktree/scripts/policy/interface-baseline"
# Use one contract format on both sides while constructing the command tree
# from the authoritative historical source revision.
rm -rf "$helper"
mkdir -p "$helper"
cp -R "$ROOT/scripts/policy/interface-baseline/." "$helper/"
}
generate_reference() {
ref="$1"
worktree="$2"
output="$3"
home="$4"
bin="$TMP_ROOT/reference-$(basename "$worktree")"
git -C "$ROOT" worktree add --detach "$worktree" "$ref" >/dev/null
install_snapshot_helper "$worktree"
mkdir -p "$home"
(
cd "$worktree"
go build -o "$bin" ./scripts/policy/interface-baseline
HOME="$home" DWS_LANG=zh "$bin" >"$output"
)
}
generate_reference "$BASE_REF" "$BASE_WORKTREE" "$BASELINE" "$TMP_ROOT/base-home"
mkdir -p "$CANDIDATE_HOME"
go build -o "$CANDIDATE_BIN" ./scripts/policy/interface-baseline
printf 'checking candidate against authoritative interface ref %s\n' "$BASE_REF"
HOME="$CANDIDATE_HOME" DWS_LANG=zh "$CANDIDATE_BIN" --check "$BASELINE"
exec "$ROOT/scripts/policy/check-command-compatibility.sh" \
--base-ref "$BASE_REF" \
--stable-ref "$STABLE_REF" \
--candidate-ref "$CANDIDATE_REF"
@@ -1,16 +1,23 @@
#!/bin/sh
set -eu
# Compare the candidate's complete Schema contract with the Git-owned PR
# merge-base (or previous main SHA on push). The candidate branch cannot
# weaken this authority by editing a checked-in fixture.
# Compare the committed candidate's complete Schema contract independently with
# the Git-owned PR merge-base (or previous main SHA on push) and the latest
# reachable stable GA. The merge-base owns the Schema checker and, after
# bootstrap, the approved flag-migration ledger. Candidate code is lifecycle
# input only and cannot replace either authority.
ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
BIN="${DWS_BIN:-$ROOT/dws}"
BASE_REF=""
STABLE_REF=""
CANDIDATE_REF="HEAD"
SCHEMA_CHECKER_REL="scripts/policy/schema-compat/main.go"
MIGRATION_MANIFEST_REL="scripts/policy/interface-migrations/approved-flag-migrations-v1.json"
MIGRATIONS_REL="internal/interfacesnapshot/migrations.go"
ALIAS_CONTRACT_REL="internal/corecmd/runtimeannotate/interface_alias.go"
usage() {
printf '%s\n' "usage: $0 --base-ref <ref>" >&2
printf '%s\n' "usage: $0 --base-ref <ref> [--stable-ref <ref>] [--candidate-ref <ref>]" >&2
}
while [ "$#" -gt 0 ]; do
@@ -20,6 +27,16 @@ while [ "$#" -gt 0 ]; do
BASE_REF="$2"
shift 2
;;
--stable-ref)
[ "$#" -ge 2 ] || { usage; exit 2; }
STABLE_REF="$2"
shift 2
;;
--candidate-ref)
[ "$#" -ge 2 ] || { usage; exit 2; }
CANDIDATE_REF="$2"
shift 2
;;
-h|--help)
usage
exit 0
@@ -36,27 +53,62 @@ done
cd "$ROOT"
. "$ROOT/scripts/policy/policy-runtime.sh"
. "$ROOT/scripts/release/release-lib.sh"
policy_prepare_runtime "$ROOT"
git rev-parse --verify --quiet "${BASE_REF}^{commit}" >/dev/null || {
BASE_COMMIT="$(git rev-parse --verify "${BASE_REF}^{commit}")" || {
printf 'error: Schema authority is not available locally: %s\n' "$BASE_REF" >&2
exit 2
}
if [ ! -x "$BIN" ]; then
printf 'error: dws binary not found at %s (run make build first)\n' "$BIN" >&2
CANDIDATE_COMMIT="$(git rev-parse --verify "${CANDIDATE_REF}^{commit}")" || {
printf 'error: Schema candidate is not available locally: %s\n' "$CANDIDATE_REF" >&2
exit 2
}
EXPECTED_STABLE_REF=""
for tag in $(git tag --merged "$BASE_REF" --list 'v*' --sort=-version:refname); do
release_is_stable_version "$tag" || continue
if git rev-parse --verify --quiet "refs/tags/withdrawn/$tag" >/dev/null; then
continue
fi
EXPECTED_STABLE_REF="$tag"
break
done
if [ -z "$EXPECTED_STABLE_REF" ]; then
printf 'error: no stable release tag is reachable from Schema authority %s\n' "$BASE_REF" >&2
exit 2
fi
if [ -z "$STABLE_REF" ]; then
STABLE_REF="$EXPECTED_STABLE_REF"
fi
STABLE_COMMIT="$(git rev-parse --verify "${STABLE_REF}^{commit}")" || {
printf 'error: Schema stable authority is not available locally: %s\n' "$STABLE_REF" >&2
exit 2
}
if [ "$(git rev-parse "${STABLE_REF}^{commit}")" != "$(git rev-parse "${EXPECTED_STABLE_REF}^{commit}")" ]; then
printf 'error: stable ref %s is not the expected highest GA tag %s reachable from %s\n' \
"$STABLE_REF" "$EXPECTED_STABLE_REF" "$BASE_REF" >&2
exit 2
fi
TMP_ROOT="$(policy_runtime_mktemp_dir dws-schema-authority)"
BASE_WORKTREE="$TMP_ROOT/base-worktree"
BASE_BIN="$TMP_ROOT/base-dws"
BASE_RAW="$TMP_ROOT/base-schema.json"
BASELINE="$TMP_ROOT/base-contract.json"
CANDIDATE_RAW="$TMP_ROOT/candidate-schema.json"
CHECKER="$TMP_ROOT/schema-compat"
STABLE_WORKTREE="$TMP_ROOT/stable-worktree"
CANDIDATE_WORKTREE="$TMP_ROOT/candidate-worktree"
BASE_WORKTREE_ADDED=false
STABLE_WORKTREE_ADDED=false
CANDIDATE_WORKTREE_ADDED=false
cleanup() {
git -C "$ROOT" worktree remove --force "$BASE_WORKTREE" >/dev/null 2>&1 || true
if [ "$CANDIDATE_WORKTREE_ADDED" = true ]; then
git -C "$ROOT" worktree remove --force "$CANDIDATE_WORKTREE" >/dev/null 2>&1 || true
fi
if [ "$STABLE_WORKTREE_ADDED" = true ]; then
git -C "$ROOT" worktree remove --force "$STABLE_WORKTREE" >/dev/null 2>&1 || true
fi
if [ "$BASE_WORKTREE_ADDED" = true ]; then
git -C "$ROOT" worktree remove --force "$BASE_WORKTREE" >/dev/null 2>&1 || true
fi
rm -rf "$TMP_ROOT"
}
trap cleanup EXIT
@@ -64,19 +116,237 @@ trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM
git -C "$ROOT" worktree add --detach "$BASE_WORKTREE" "$BASE_REF" >/dev/null
git -C "$ROOT" worktree add --detach "$BASE_WORKTREE" "$BASE_COMMIT" >/dev/null
BASE_WORKTREE_ADDED=true
git -C "$ROOT" worktree add --detach "$STABLE_WORKTREE" "$STABLE_COMMIT" >/dev/null
STABLE_WORKTREE_ADDED=true
git -C "$ROOT" worktree add --detach "$CANDIDATE_WORKTREE" "$CANDIDATE_COMMIT" >/dev/null
CANDIDATE_WORKTREE_ADDED=true
commit_path_exists() {
tree_commit="$1"
tree_path="$2"
tree_entry="$(git -C "$ROOT" ls-tree "$tree_commit" -- "$tree_path")"
[ -n "$tree_entry" ]
}
commit_path_is_regular_file() {
tree_commit="$1"
tree_path="$2"
tree_entry="$(git -C "$ROOT" ls-tree "$tree_commit" -- "$tree_path")"
[ -n "$tree_entry" ] || return 1
set -- $tree_entry
[ "$#" -eq 4 ] || return 1
case "$1" in
100644|100755) ;;
*) return 1 ;;
esac
[ "$2" = blob ] && [ "$4" = "$tree_path" ]
}
has_modern_interface_helper() {
governance_commit="$1"
commit_path_is_regular_file "$governance_commit" cmd/interface-snapshot/main.go &&
commit_path_is_regular_file "$governance_commit" internal/interfacesnapshot/snapshot.go &&
commit_path_is_regular_file "$governance_commit" internal/interfacesnapshot/compare.go
}
has_complete_schema_migration_governance() {
governance_commit="$1"
commit_path_is_regular_file "$governance_commit" "$SCHEMA_CHECKER_REL" &&
has_modern_interface_helper "$governance_commit" &&
commit_path_is_regular_file "$governance_commit" "$MIGRATIONS_REL" &&
commit_path_is_regular_file "$governance_commit" "$ALIAS_CONTRACT_REL" &&
commit_path_is_regular_file "$governance_commit" "$MIGRATION_MANIFEST_REL"
}
has_any_schema_migration_governance_artifact() {
governance_commit="$1"
for relative_path in \
"$MIGRATIONS_REL" \
"$ALIAS_CONTRACT_REL" \
"$MIGRATION_MANIFEST_REL"; do
if commit_path_exists "$governance_commit" "$relative_path"; then
return 0
fi
done
return 1
}
check_candidate_alias_source_policy() {
source_policy_failed=false
for token in \
dws.compat.alias_of \
dws.compat.alias_origin \
corecmd.flag_spec_aliases.v1 \
AnnotationFlagAliasOf \
AnnotationFlagAliasOrigin \
FlagAliasOriginCorecmdV1; do
matches="$(git -C "$CANDIDATE_WORKTREE" grep -l -F -e "$token" -- '*.go' || true)"
[ -z "$matches" ] && continue
while IFS= read -r relative_path; do
[ -n "$relative_path" ] || continue
case "$relative_path" in
*_test.go)
continue
;;
internal/corecmd/corecmd.go|\
internal/corecmd/runtimeannotate/interface_alias.go|\
internal/interfacesnapshot/snapshot.go)
;;
*)
if [ "$source_policy_failed" = false ]; then
printf 'error: candidate production source may not forge framework-owned flag alias evidence:\n' >&2
fi
printf ' %s: protected alias evidence token %s\n' "$relative_path" "$token" >&2
source_policy_failed=true
;;
esac
done <<EOF
$matches
EOF
done
if [ "$source_policy_failed" = true ]; then
exit 2
fi
}
if ! commit_path_is_regular_file "$BASE_COMMIT" "$SCHEMA_CHECKER_REL"; then
printf 'error: merge-base lacks the base-owned Schema checker: %s\n' "$BASE_REF" >&2
exit 2
fi
if ! has_modern_interface_helper "$BASE_COMMIT"; then
printf 'error: merge-base lacks the modern interface snapshot helper required for Schema governance: %s\n' "$BASE_REF" >&2
exit 2
fi
if ! has_complete_schema_migration_governance "$CANDIDATE_COMMIT"; then
printf 'error: candidate must preserve the complete Schema flag migration governance artifact set at %s\n' \
"$CANDIDATE_COMMIT" >&2
exit 2
fi
check_candidate_alias_source_policy
EMPTY_MANIFEST="$TMP_ROOT/empty-migrations.json"
printf '%s\n' '{"version":1,"migrations":[]}' >"$EMPTY_MANIFEST"
USE_MIGRATION_GOVERNANCE=false
if has_complete_schema_migration_governance "$BASE_COMMIT"; then
USE_MIGRATION_GOVERNANCE=true
APPROVED_MANIFEST="$BASE_WORKTREE/$MIGRATION_MANIFEST_REL"
CANDIDATE_MANIFEST="$CANDIDATE_WORKTREE/$MIGRATION_MANIFEST_REL"
else
# One-time bootstrap for the governance PR itself. The old base checker owns
# the decision and receives no flags it does not understand. With no base
# approval authority, only the exact canonical empty candidate ledger is
# admissible.
if has_any_schema_migration_governance_artifact "$BASE_COMMIT"; then
printf 'error: merge-base contains an incomplete Schema flag migration governance artifact set: %s\n' \
"$BASE_REF" >&2
exit 2
fi
BOOTSTRAP_MANIFEST="$CANDIDATE_WORKTREE/$MIGRATION_MANIFEST_REL"
if ! cmp -s "$BOOTSTRAP_MANIFEST" "$EMPTY_MANIFEST"; then
printf 'error: initial Schema flag migration governance requires the canonical empty manifest: %s\n' \
"$BOOTSTRAP_MANIFEST" >&2
exit 2
fi
fi
BASE_BIN="$TMP_ROOT/base-dws"
STABLE_BIN="$TMP_ROOT/stable-dws"
CANDIDATE_BIN="$TMP_ROOT/candidate-dws"
CHECKER="$TMP_ROOT/schema-compat"
BASE_RAW="$TMP_ROOT/base-schema.json"
BASELINE="$TMP_ROOT/base-contract.json"
STABLE_RAW="$TMP_ROOT/stable-schema.json"
STABLE_BASELINE="$TMP_ROOT/stable-contract.json"
CANDIDATE_RAW="$TMP_ROOT/candidate-schema.json"
(
cd "$BASE_WORKTREE"
go build -o "$BASE_BIN" ./cmd
go build -o "$CHECKER" ./scripts/policy/schema-compat
)
(
cd "$STABLE_WORKTREE"
go build -o "$STABLE_BIN" ./cmd
)
(
cd "$CANDIDATE_WORKTREE"
go build -o "$CANDIDATE_BIN" ./cmd
)
go build -o "$CHECKER" ./scripts/policy/schema-compat
mkdir -p "$TMP_ROOT/base-home" "$TMP_ROOT/candidate-home"
mkdir -p "$TMP_ROOT/base-home" "$TMP_ROOT/stable-home" "$TMP_ROOT/candidate-home"
HOME="$TMP_ROOT/base-home" DWS_LANG=zh \
"$BASE_BIN" schema --all --format json >"$BASE_RAW"
HOME="$TMP_ROOT/stable-home" DWS_LANG=zh \
"$STABLE_BIN" schema --all --format json >"$STABLE_RAW"
HOME="$TMP_ROOT/candidate-home" DWS_LANG=zh \
"$BIN" schema --all --format json >"$CANDIDATE_RAW"
"$CANDIDATE_BIN" schema --all --format json >"$CANDIDATE_RAW"
"$CHECKER" --normalize "$BASE_RAW" >"$BASELINE"
printf 'checking complete Schema contract against PR merge-base %s\n' "$BASE_REF"
"$CHECKER" --check "$BASELINE" --current "$CANDIDATE_RAW"
"$CHECKER" --normalize "$STABLE_RAW" >"$STABLE_BASELINE"
check_schema_contract() {
historical_kind="$1"
historical_ref="$2"
historical_baseline="$3"
shift 3
printf 'checking complete Schema contract against %s %s\n' \
"$historical_kind" "$historical_ref"
"$CHECKER" --check "$historical_baseline" --current "$CANDIDATE_RAW" "$@"
}
if [ "$USE_MIGRATION_GOVERNANCE" = false ]; then
check_schema_contract "PR merge-base" "$BASE_REF" "$BASELINE"
check_schema_contract "stable" "$STABLE_REF" "$STABLE_BASELINE"
exit 0
fi
install_authority_interface_helper() {
worktree="$1"
for helper_path in cmd/interface-snapshot internal/interfacesnapshot; do
rm -rf "$worktree/$helper_path"
mkdir -p "$worktree/$helper_path"
cp -R "$BASE_WORKTREE/$helper_path/." "$worktree/$helper_path/"
done
mkdir -p "$worktree/$(dirname "$ALIAS_CONTRACT_REL")"
rm -f "$worktree/$ALIAS_CONTRACT_REL"
cp "$BASE_WORKTREE/$ALIAS_CONTRACT_REL" "$worktree/$ALIAS_CONTRACT_REL"
}
# The candidate and historical stable trees provide only the command surface.
# The merge-base owns the snapshot generator, comparator, and alias contract.
install_authority_interface_helper "$CANDIDATE_WORKTREE"
install_authority_interface_helper "$STABLE_WORKTREE"
generate_interface_snapshot() {
worktree="$1"
output="$2"
(
cd "$worktree"
go run ./cmd/interface-snapshot generate --output "$output"
)
}
CURRENT_INTERFACE_SNAPSHOT="$TMP_ROOT/candidate-interface-snapshot.json"
BASE_INTERFACE_SNAPSHOT="$TMP_ROOT/base-interface-snapshot.json"
STABLE_INTERFACE_SNAPSHOT="$TMP_ROOT/stable-interface-snapshot.json"
generate_interface_snapshot "$CANDIDATE_WORKTREE" "$CURRENT_INTERFACE_SNAPSHOT"
generate_interface_snapshot "$BASE_WORKTREE" "$BASE_INTERFACE_SNAPSHOT"
generate_interface_snapshot "$STABLE_WORKTREE" "$STABLE_INTERFACE_SNAPSHOT"
check_schema_contract "PR merge-base" "$BASE_REF" "$BASELINE" \
--approved-flag-migrations "$APPROVED_MANIFEST" \
--candidate-flag-migrations "$CANDIDATE_MANIFEST" \
--migration-current-snapshot "$CURRENT_INTERFACE_SNAPSHOT" \
--migration-base-snapshot "$BASE_INTERFACE_SNAPSHOT" \
--migration-stable-snapshot "$STABLE_INTERFACE_SNAPSHOT"
check_schema_contract "stable" "$STABLE_REF" "$STABLE_BASELINE" \
--approved-flag-migrations "$APPROVED_MANIFEST" \
--candidate-flag-migrations "$CANDIDATE_MANIFEST" \
--migration-current-snapshot "$CURRENT_INTERFACE_SNAPSHOT" \
--migration-base-snapshot "$BASE_INTERFACE_SNAPSHOT" \
--migration-stable-snapshot "$STABLE_INTERFACE_SNAPSHOT"
+228 -49
View File
@@ -1,23 +1,21 @@
#!/bin/sh
set -eu
# Compare the current Cobra command surface with two local Git revisions:
# the PR merge-base/main reference and the latest stable GA tag. The caller must
# pass the highest non-prerelease SemVer tag (excluding beta/rc tags). Tag
# governance treats that tag as proof of a successfully published GA release;
# release automation must not leave a GA tag behind when publication fails.
#
# The script never fetches refs or snapshots from the network. With checkout
# fetch-depth=0, both references are materialized in temporary worktrees. A
# reference uses its own snapshot helper when available; older revisions are
# bootstrapped with the candidate helper.
# Compare the committed candidate Cobra surface with the PR merge-base and the
# latest stable GA tag. Once the merge-base contains the migration governance
# helper, that revision owns the generator, comparator, and approved manifest.
# The candidate manifest is lifecycle input only and cannot authorize a change
# introduced by the same PR.
ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
BASE_REF=""
STABLE_REF=""
CANDIDATE_REF="HEAD"
MIGRATION_MANIFEST_REL="scripts/policy/interface-migrations/approved-flag-migrations-v1.json"
ALIAS_CONTRACT_REL="internal/corecmd/runtimeannotate/interface_alias.go"
usage() {
printf '%s\n' "usage: $0 --base-ref <ref> --stable-ref <ref>" >&2
printf '%s\n' "usage: $0 --base-ref <ref> --stable-ref <ref> [--candidate-ref <ref>]" >&2
}
while [ "$#" -gt 0 ]; do
@@ -32,6 +30,11 @@ while [ "$#" -gt 0 ]; do
STABLE_REF="$2"
shift 2
;;
--candidate-ref)
[ "$#" -ge 2 ] || { usage; exit 2; }
CANDIDATE_REF="$2"
shift 2
;;
-h|--help)
usage
exit 0
@@ -47,20 +50,51 @@ done
[ -n "$BASE_REF" ] && [ -n "$STABLE_REF" ] || { usage; exit 2; }
cd "$ROOT"
git rev-parse --verify --quiet "${BASE_REF}^{commit}" >/dev/null || {
. "$ROOT/scripts/policy/policy-runtime.sh"
. "$ROOT/scripts/release/release-lib.sh"
policy_prepare_runtime "$ROOT"
BASE_COMMIT="$(git rev-parse --verify "${BASE_REF}^{commit}")" || {
printf 'base ref is not available locally: %s\n' "$BASE_REF" >&2
exit 2
}
git rev-parse --verify --quiet "${STABLE_REF}^{commit}" >/dev/null || {
STABLE_COMMIT="$(git rev-parse --verify "${STABLE_REF}^{commit}")" || {
printf 'stable ref is not available locally: %s\n' "$STABLE_REF" >&2
exit 2
}
CANDIDATE_COMMIT="$(git rev-parse --verify "${CANDIDATE_REF}^{commit}")" || {
printf 'candidate ref is not available locally: %s\n' "$CANDIDATE_REF" >&2
exit 2
}
EXPECTED_STABLE_REF=""
for tag in $(git tag --merged "$BASE_REF" --list 'v*' --sort=-version:refname); do
release_is_stable_version "$tag" || continue
if git rev-parse --verify --quiet "refs/tags/withdrawn/$tag" >/dev/null; then
continue
fi
EXPECTED_STABLE_REF="$tag"
break
done
if [ -z "$EXPECTED_STABLE_REF" ]; then
printf 'no stable GA tag is reachable from compatibility base %s\n' "$BASE_REF" >&2
exit 2
fi
if [ "$(git rev-parse "${STABLE_REF}^{commit}")" != "$(git rev-parse "${EXPECTED_STABLE_REF}^{commit}")" ]; then
printf 'stable ref %s is not the expected highest GA tag %s reachable from %s\n' \
"$STABLE_REF" "$EXPECTED_STABLE_REF" "$BASE_REF" >&2
exit 2
fi
TMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/dws-command-compat.XXXXXX")"
TMP_ROOT="$(policy_runtime_mktemp_dir dws-command-compat)"
BASE_WORKTREE="$TMP_ROOT/base-worktree"
STABLE_WORKTREE="$TMP_ROOT/stable-worktree"
CANDIDATE_WORKTREE="$TMP_ROOT/candidate-worktree"
CANDIDATE_WORKTREE_ADDED=false
cleanup() {
if [ "$CANDIDATE_WORKTREE_ADDED" = true ]; then
git -C "$ROOT" worktree remove --force "$CANDIDATE_WORKTREE" >/dev/null 2>&1 || true
fi
git -C "$ROOT" worktree remove --force "$BASE_WORKTREE" >/dev/null 2>&1 || true
git -C "$ROOT" worktree remove --force "$STABLE_WORKTREE" >/dev/null 2>&1 || true
rm -rf "$TMP_ROOT"
@@ -70,38 +104,168 @@ trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM
ensure_snapshot_helper() {
worktree="$1"
git -C "$ROOT" worktree add --detach "$BASE_WORKTREE" "$BASE_COMMIT" >/dev/null
git -C "$ROOT" worktree add --detach "$STABLE_WORKTREE" "$STABLE_COMMIT" >/dev/null
git -C "$ROOT" worktree add --detach "$CANDIDATE_WORKTREE" "$CANDIDATE_COMMIT" >/dev/null
CANDIDATE_WORKTREE_ADDED=true
# Once a baseline contains the helper, use that revision's own capture
# rules. This makes a rule/filter change in the candidate visible as a
# snapshot_rules_changed failure instead of applying the new exclusion to
# both sides and accidentally hiding a removed command. Older revisions
# created before this helper existed are bootstrapped with the candidate
# implementation.
if [ -f "$worktree/cmd/interface-snapshot/main.go" ] && \
[ -f "$worktree/internal/interfacesnapshot/snapshot.go" ] && \
[ -f "$worktree/internal/interfacesnapshot/compare.go" ]; then
return
fi
if [ -e "$worktree/cmd/interface-snapshot" ] || \
[ -e "$worktree/internal/interfacesnapshot" ]; then
printf 'baseline contains an incomplete interface snapshot helper: %s\n' "$worktree" >&2
exit 2
fi
mkdir -p "$worktree/cmd/interface-snapshot" "$worktree/internal/interfacesnapshot"
cp -R "$ROOT/cmd/interface-snapshot/." "$worktree/cmd/interface-snapshot/"
cp -R "$ROOT/internal/interfacesnapshot/." "$worktree/internal/interfacesnapshot/"
commit_path_exists() {
tree_commit="$1"
tree_path="$2"
tree_entry="$(git -C "$ROOT" ls-tree "$tree_commit" -- "$tree_path")"
[ -n "$tree_entry" ]
}
generate_ref_snapshot() {
ref="$1"
worktree="$2"
output="$3"
commit_path_is_regular_file() {
tree_commit="$1"
tree_path="$2"
tree_entry="$(git -C "$ROOT" ls-tree "$tree_commit" -- "$tree_path")"
[ -n "$tree_entry" ] || return 1
set -- $tree_entry
[ "$#" -eq 4 ] || return 1
case "$1" in
100644|100755) ;;
*) return 1 ;;
esac
[ "$2" = blob ] && [ "$4" = "$tree_path" ]
}
git -C "$ROOT" worktree add --detach "$worktree" "$ref" >/dev/null
ensure_snapshot_helper "$worktree"
has_modern_interface_helper() {
governance_commit="$1"
commit_path_is_regular_file "$governance_commit" cmd/interface-snapshot/main.go &&
commit_path_is_regular_file "$governance_commit" internal/interfacesnapshot/snapshot.go &&
commit_path_is_regular_file "$governance_commit" internal/interfacesnapshot/compare.go
}
has_complete_migration_governance() {
governance_commit="$1"
has_modern_interface_helper "$governance_commit" &&
commit_path_is_regular_file "$governance_commit" internal/interfacesnapshot/migrations.go &&
commit_path_is_regular_file "$governance_commit" "$ALIAS_CONTRACT_REL" &&
commit_path_is_regular_file "$governance_commit" "$MIGRATION_MANIFEST_REL"
}
has_any_migration_governance_artifact() {
governance_commit="$1"
for relative_path in \
internal/interfacesnapshot/migrations.go \
"$ALIAS_CONTRACT_REL" \
"$MIGRATION_MANIFEST_REL"; do
if commit_path_exists "$governance_commit" "$relative_path"; then
return 0
fi
done
return 1
}
require_complete_candidate_governance() {
if ! has_complete_migration_governance "$CANDIDATE_COMMIT"; then
printf 'candidate must preserve the complete flag migration governance artifact set at %s\n' \
"$CANDIDATE_COMMIT" >&2
exit 2
fi
}
check_candidate_alias_source_policy() {
source_policy_failed=false
for token in \
dws.compat.alias_of \
dws.compat.alias_origin \
corecmd.flag_spec_aliases.v1 \
AnnotationFlagAliasOf \
AnnotationFlagAliasOrigin \
FlagAliasOriginCorecmdV1; do
matches="$(git -C "$CANDIDATE_WORKTREE" grep -l -F -e "$token" -- '*.go' || true)"
[ -z "$matches" ] && continue
while IFS= read -r relative_path; do
[ -n "$relative_path" ] || continue
case "$relative_path" in
*_test.go)
continue
;;
internal/corecmd/corecmd.go|\
internal/corecmd/runtimeannotate/interface_alias.go|\
internal/interfacesnapshot/snapshot.go)
;;
*)
if [ "$source_policy_failed" = false ]; then
printf 'candidate production source may not forge framework-owned flag alias evidence:\n' >&2
fi
printf ' %s: protected alias evidence token %s\n' "$relative_path" "$token" >&2
source_policy_failed=true
;;
esac
done <<EOF
$matches
EOF
done
if [ "$source_policy_failed" = true ]; then
exit 2
fi
}
install_authority_helper() {
source_root="$1"
worktree="$2"
include_alias_contract="$3"
[ "$source_root" = "$worktree" ] && return
for helper_path in cmd/interface-snapshot internal/interfacesnapshot; do
rm -rf "$worktree/$helper_path"
mkdir -p "$worktree/$helper_path"
cp -R "$source_root/$helper_path/." "$worktree/$helper_path/"
done
if [ "$include_alias_contract" = true ]; then
mkdir -p "$worktree/$(dirname "$ALIAS_CONTRACT_REL")"
rm -f "$worktree/$ALIAS_CONTRACT_REL"
cp "$source_root/$ALIAS_CONTRACT_REL" "$worktree/$ALIAS_CONTRACT_REL"
fi
}
EMPTY_MANIFEST="$TMP_ROOT/empty-migrations.json"
printf '%s\n' '{"version":1,"migrations":[]}' >"$EMPTY_MANIFEST"
USE_MIGRATION_GOVERNANCE=false
if has_complete_migration_governance "$BASE_COMMIT"; then
USE_MIGRATION_GOVERNANCE=true
AUTHORITY_ROOT="$BASE_WORKTREE"
APPROVED_MANIFEST="$BASE_WORKTREE/$MIGRATION_MANIFEST_REL"
CANDIDATE_MANIFEST="$CANDIDATE_WORKTREE/$MIGRATION_MANIFEST_REL"
require_complete_candidate_governance
check_candidate_alias_source_policy
install_authority_helper "$AUTHORITY_ROOT" "$CANDIDATE_WORKTREE" true
else
# One-time bootstrap for the governance PR itself. No base approval exists,
# so the existing base-owned modern helper performs an ordinary comparison
# and this path admits only the canonical empty ledger. The candidate's new
# comparator never participates in the bootstrap decision.
if has_any_migration_governance_artifact "$BASE_COMMIT"; then
printf 'merge-base contains an incomplete flag migration governance artifact set: %s\n' \
"$BASE_REF" >&2
exit 2
fi
if ! has_modern_interface_helper "$BASE_COMMIT"; then
printf 'merge-base lacks the modern interface snapshot helper required for governance bootstrap: %s\n' "$BASE_REF" >&2
exit 2
fi
AUTHORITY_ROOT="$BASE_WORKTREE"
require_complete_candidate_governance
check_candidate_alias_source_policy
BOOTSTRAP_MANIFEST="$CANDIDATE_WORKTREE/$MIGRATION_MANIFEST_REL"
if ! cmp -s "$BOOTSTRAP_MANIFEST" "$EMPTY_MANIFEST"; then
printf 'initial flag migration governance requires the canonical empty manifest: %s\n' "$BOOTSTRAP_MANIFEST" >&2
exit 2
fi
install_authority_helper "$AUTHORITY_ROOT" "$CANDIDATE_WORKTREE" false
fi
install_authority_helper "$AUTHORITY_ROOT" "$BASE_WORKTREE" "$USE_MIGRATION_GOVERNANCE"
install_authority_helper "$AUTHORITY_ROOT" "$STABLE_WORKTREE" "$USE_MIGRATION_GOVERNANCE"
generate_snapshot() {
worktree="$1"
output="$2"
(
cd "$worktree"
go run ./cmd/interface-snapshot generate --output "$output"
@@ -112,11 +276,26 @@ CANDIDATE="$TMP_ROOT/candidate.json"
BASELINE="$TMP_ROOT/merge-base.json"
STABLE="$TMP_ROOT/stable.json"
go run ./cmd/interface-snapshot generate --output "$CANDIDATE"
generate_ref_snapshot "$BASE_REF" "$BASE_WORKTREE" "$BASELINE"
generate_ref_snapshot "$STABLE_REF" "$STABLE_WORKTREE" "$STABLE"
generate_snapshot "$CANDIDATE_WORKTREE" "$CANDIDATE"
generate_snapshot "$BASE_WORKTREE" "$BASELINE"
generate_snapshot "$STABLE_WORKTREE" "$STABLE"
go run ./cmd/interface-snapshot compare \
--current "$CANDIDATE" \
--base "$BASELINE" \
--stable "$STABLE"
if [ "$USE_MIGRATION_GOVERNANCE" = true ]; then
(
cd "$AUTHORITY_ROOT"
go run ./cmd/interface-snapshot compare \
--current "$CANDIDATE" \
--base "$BASELINE" \
--stable "$STABLE" \
--approved-flag-migrations "$APPROVED_MANIFEST" \
--candidate-flag-migrations "$CANDIDATE_MANIFEST"
)
else
(
cd "$AUTHORITY_ROOT"
go run ./cmd/interface-snapshot compare \
--current "$CANDIDATE" \
--base "$BASELINE" \
--stable "$STABLE"
)
fi
+86 -2
View File
@@ -7,12 +7,14 @@ 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"
doc_skill="skills/multi/dingtalk-doc/SKILL.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"
chat_target_bytes=10000
chat_max_overage_percent=5
chat_max_bytes=$((chat_target_bytes * (100 + chat_max_overage_percent) / 100))
doc_max_bytes=9000
event_max_bytes=10000
runtime_contract_max_bytes=3000
@@ -23,6 +25,13 @@ if [ "$chat_bytes" -gt "$chat_max_bytes" ]; then
exit 1
fi
doc_bytes="$(wc -c < "$doc_skill" | tr -d ' ')"
if [ "$doc_bytes" -gt "$doc_max_bytes" ]; then
printf '%s\n' \
"skill context budget exceeded: $doc_skill is ${doc_bytes} bytes (max ${doc_max_bytes})" >&2
exit 1
fi
event_bytes="$(wc -c < "$event_skill" | tr -d ' ')"
if [ "$event_bytes" -gt "$event_max_bytes" ]; then
printf '%s\n' \
@@ -51,6 +60,20 @@ if [ "$shortcut_rows" -ne 0 ]; then
exit 1
fi
doc_shortcut_rows="$(
awk '
/<!-- VISIBLE_SHORTCUTS_START -->/ { in_block = 1; next }
/<!-- VISIBLE_SHORTCUTS_END -->/ { in_block = 0 }
in_block && /^\| `dws doc \+/ { count++ }
END { print count + 0 }
' "$doc_skill"
)"
if [ "$doc_shortcut_rows" -ne 0 ]; then
printf '%s\n' \
"skill context budget exceeded: $doc_skill re-expanded $doc_shortcut_rows shortcut rows" >&2
exit 1
fi
for required_heading in \
"## 最小 DWS 执行契约" \
"## Golden Route" \
@@ -65,6 +88,67 @@ do
fi
done
for required_heading in \
"## 最小 DWS 执行契约" \
"## Golden Route" \
"## 关键结果语义" \
"## 按需加载" \
"## 错误最短路径"
do
if ! grep -Fq "$required_heading" "$doc_skill"; then
printf '%s\n' \
"skill value regression: $doc_skill is missing $required_heading" >&2
exit 1
fi
done
for required_route in \
"+search" \
"+fetch" \
"+create" \
"+update" \
"+checkpoint-update" \
"+export"
do
if ! grep -Fq -- "$required_route" "$doc_skill"; then
printf '%s\n' \
"skill route regression: $doc_skill is missing $required_route" >&2
exit 1
fi
done
for forbidden_route in \
"## 标准 SOP" \
"doc_create_and_write.py" \
"只在任务命中时读取一个精确 reference" \
"MUST 先用 Read" \
"先用 Read 工具完整读取"
do
if grep -Fq "$forbidden_route" "$doc_skill"; then
printf '%s\n' \
"skill route regression: $doc_skill restored legacy route: $forbidden_route" >&2
exit 1
fi
done
for required_reference_rule in \
"Golden Route 已给出命令且参数足够时,禁止读取 reference" \
"才最多读取一个 reference" \
"\`+create\`、\`+fetch\`、\`+update\` append/overwrite、\`+export\`、\`+import\` 禁止读取 reference"
do
if ! grep -Fq "$required_reference_rule" "$doc_skill"; then
printf '%s\n' \
"skill reference budget regression: $doc_skill is missing $required_reference_rule" >&2
exit 1
fi
done
if grep -Fq "../dingtalk-shared/SKILL.md" "$doc_skill"; then
printf '%s\n' \
"skill context regression: $doc_skill requires full dingtalk-shared cold-start loading" >&2
exit 1
fi
for required_route in \
"+dm" \
"+send-to-group" \
@@ -108,4 +192,4 @@ if grep -Fq "充分阅读产品参考文件" "$mono_skill"; then
fi
printf '%s\n' \
"skill context budget: ok (chat_bytes=$chat_bytes target=$chat_target_bytes max=$chat_max_bytes allowance=${chat_max_overage_percent}% event_bytes=$event_bytes event_max=$event_max_bytes runtime_contract_bytes=$runtime_contract_bytes runtime_contract_max=$runtime_contract_max_bytes shortcut_rows=$shortcut_rows)"
"skill context budget: ok (chat_bytes=$chat_bytes target=$chat_target_bytes max=$chat_max_bytes allowance=${chat_max_overage_percent}% doc_bytes=$doc_bytes doc_max=$doc_max_bytes event_bytes=$event_bytes event_max=$event_max_bytes runtime_contract_bytes=$runtime_contract_bytes runtime_contract_max=$runtime_contract_max_bytes shortcut_rows=$shortcut_rows doc_shortcut_rows=$doc_shortcut_rows)"
@@ -0,0 +1,454 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package commandcompat
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
const authorityHelperSource = `package main
import (
"flag"
"fmt"
"os"
"example.com/dws-command-compat-authority-test/internal/interfacesnapshot"
)
func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "missing command")
os.Exit(2)
}
switch os.Args[1] {
case "generate":
flags := flag.NewFlagSet("generate", flag.ExitOnError)
output := flags.String("output", "", "snapshot output")
_ = flags.Parse(os.Args[2:])
if *output == "" {
fmt.Fprintln(os.Stderr, "missing --output")
os.Exit(2)
}
marker := interfacesnapshot.AuthorityMarker()
if err := os.WriteFile(*output, []byte("AUTHORITY="+marker+"\n"), 0o600); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
fmt.Printf("BASE_HELPER_GENERATE=%s\n", marker)
case "compare":
flags := flag.NewFlagSet("compare", flag.ExitOnError)
candidateManifest := flags.String("candidate-flag-migrations", "", "candidate manifest")
current := flags.String("current", "", "current snapshot")
base := flags.String("base", "", "base snapshot")
stable := flags.String("stable", "", "stable snapshot")
_ = flags.String("approved-flag-migrations", "", "approved manifest")
_ = flags.Parse(os.Args[2:])
for _, path := range []string{*current, *base, *stable} {
content, err := os.ReadFile(path)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
if string(content) != "AUTHORITY=BASE\n" {
fmt.Fprintf(os.Stderr, "snapshot did not use base internal helper: %s", content)
os.Exit(2)
}
}
if *candidateManifest != "" {
content, err := os.ReadFile(*candidateManifest)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
fmt.Printf("CANDIDATE_MANIFEST=%s\n", *candidateManifest)
if string(content) != "{\"version\":1,\"migrations\":[]}\n" {
fmt.Println("UNCOMMITTED_MANIFEST_USED")
return
}
}
fmt.Fprintln(os.Stderr, "BASE_COMPARATOR_ENFORCED")
os.Exit(23)
default:
fmt.Fprintln(os.Stderr, "unknown command")
os.Exit(2)
}
}
`
const governedSnapshotSource = `package interfacesnapshot
import "example.com/dws-command-compat-authority-test/internal/corecmd/runtimeannotate"
var (
_ = runtimeannotate.AnnotationFlagAliasOf
_ = runtimeannotate.AnnotationFlagAliasOrigin
_ = runtimeannotate.FlagAliasOriginCorecmdV1
)
func AuthorityMarker() string { return "BASE" }
`
const baseSnapshotSource = `package interfacesnapshot
func AuthorityMarker() string { return "BASE" }
`
const stableSnapshotSource = `package interfacesnapshot
func AuthorityMarker() string { return "STABLE" }
`
const candidateSnapshotSource = `package interfacesnapshot
func AuthorityMarker() string { return "CANDIDATE" }
`
const aliasContractSource = `package runtimeannotate
const (
AnnotationFlagAliasOf = "dws.compat.alias_of"
AnnotationFlagAliasOrigin = "dws.compat.alias_origin"
FlagAliasOriginCorecmdV1 = "corecmd.flag_spec_aliases.v1"
)
`
const candidateNoopSource = `package main
import "fmt"
func main() {
fmt.Println("CANDIDATE_NOOP")
}
`
type authorityScenario struct {
name string
baseGovernance string
candidateMutation string
want string
wantAuthorityDecision bool
}
func TestCrossPlatformCoverageCommandCompatibilityUsesBaseOwnedAuthority(t *testing.T) {
tests := []authorityScenario{
{
name: "governed base rejects candidate helper and live manifest",
baseGovernance: "complete",
wantAuthorityDecision: true,
},
{
name: "bootstrap uses old base helper",
baseGovernance: "none",
wantAuthorityDecision: true,
},
{
name: "partial base fails closed",
baseGovernance: "partial",
want: "incomplete flag migration governance artifact set",
},
{
name: "base ledger ancestor symlink fails closed",
baseGovernance: "ancestor-symlink",
want: "incomplete flag migration governance artifact set",
},
{
name: "governed candidate cannot delete alias contract",
baseGovernance: "complete",
candidateMutation: "delete-alias-contract",
want: "must preserve the complete flag migration governance artifact set",
},
{
name: "governed candidate cannot replace helper parent with symlink",
baseGovernance: "complete",
candidateMutation: "symlink-helper-parent",
want: "must preserve the complete flag migration governance artifact set",
},
{
name: "candidate cannot forge alias evidence token",
baseGovernance: "complete",
candidateMutation: "forge-alias-token",
want: "may not forge framework-owned flag alias evidence",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
runBaseOwnedAuthorityCase(t, test)
})
}
}
func runBaseOwnedAuthorityCase(t *testing.T, test authorityScenario) {
t.Helper()
repoRoot := repositoryRoot(t)
managedRoot := filepath.Join(repoRoot, ".worktrees", "commandcompat-authority-integration")
if err := os.MkdirAll(managedRoot, 0o755); err != nil {
t.Fatalf("create managed test root: %v", err)
}
fixtureRoot, err := os.MkdirTemp(managedRoot, fmt.Sprintf("case-%d-", time.Now().UnixNano()))
if err != nil {
t.Fatalf("create managed fixture repo: %v", err)
}
t.Cleanup(func() {
if err := os.RemoveAll(fixtureRoot); err != nil {
t.Errorf("remove managed fixture repo: %v", err)
}
})
copyFile(t,
filepath.Join(repoRoot, "scripts", "policy", "check-command-compatibility.sh"),
filepath.Join(fixtureRoot, "scripts", "policy", "check-command-compatibility.sh"),
0o755,
)
copyFile(t,
filepath.Join(repoRoot, "scripts", "policy", "policy-runtime.sh"),
filepath.Join(fixtureRoot, "scripts", "policy", "policy-runtime.sh"),
0o755,
)
copyFile(t,
filepath.Join(repoRoot, "scripts", "release", "release-lib.sh"),
filepath.Join(fixtureRoot, "scripts", "release", "release-lib.sh"),
0o755,
)
writeFile(t, filepath.Join(fixtureRoot, "go.mod"), "module example.com/dws-command-compat-authority-test\n\ngo 1.23\n", 0o644)
writeFile(t, filepath.Join(fixtureRoot, "cmd", "interface-snapshot", "main.go"), authorityHelperSource, 0o644)
writeFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "snapshot.go"), stableSnapshotSource, 0o644)
writeFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "compare.go"), "package interfacesnapshot\n", 0o644)
run(t, fixtureRoot, "git", "init", "-b", "main")
run(t, fixtureRoot, "git", "config", "user.name", "Command Compatibility Test")
run(t, fixtureRoot, "git", "config", "user.email", "command-compat-test@example.invalid")
run(t, fixtureRoot, "git", "add", ".")
run(t, fixtureRoot, "git", "commit", "-m", "old stable without migration governance")
stableCommit := strings.TrimSpace(run(t, fixtureRoot, "git", "rev-parse", "HEAD"))
run(t, fixtureRoot, "git", "tag", "v1.0.0", stableCommit)
writeFile(t, filepath.Join(fixtureRoot, "BASE_MARKER"), "newer merge-base\n", 0o644)
writeFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "snapshot.go"), baseSnapshotSource, 0o644)
switch test.baseGovernance {
case "complete":
writeFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "snapshot.go"), governedSnapshotSource, 0o644)
writeFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "migrations.go"), "package interfacesnapshot\n", 0o644)
writeFile(t,
filepath.Join(fixtureRoot, "internal", "corecmd", "runtimeannotate", "interface_alias.go"),
aliasContractSource,
0o644,
)
writeFile(t,
filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-flag-migrations-v1.json"),
"{\"version\":1,\"migrations\":[]}\n",
0o644,
)
case "partial":
writeFile(t,
filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-flag-migrations-v1.json"),
"{\"version\":1,\"migrations\":[]}\n",
0o644,
)
case "ancestor-symlink":
writeFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "migrations.go"), "package interfacesnapshot\n", 0o644)
writeFile(t,
filepath.Join(fixtureRoot, "internal", "corecmd", "runtimeannotate", "interface_alias.go"),
aliasContractSource,
0o644,
)
writeFile(t,
filepath.Join(fixtureRoot, "scripts", "policy", "authority-ledger", "approved-flag-migrations-v1.json"),
"{\"version\":1,\"migrations\":[]}\n",
0o644,
)
if err := os.Symlink("authority-ledger", filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations")); err != nil {
t.Fatalf("symlink base ledger parent: %v", err)
}
case "none":
default:
t.Fatalf("unknown base governance state %q", test.baseGovernance)
}
run(t, fixtureRoot, "git", "add", ".")
run(t, fixtureRoot, "git", "commit", "-m", "base authority")
baseRef := strings.TrimSpace(run(t, fixtureRoot, "git", "rev-parse", "HEAD"))
if baseRef == stableCommit {
t.Fatal("fixture stable tag must predate the governed merge-base")
}
// A withdrawn higher GA tag is reserved history, not the active stable
// compatibility baseline. The checker must agree with release-contract.sh.
run(t, fixtureRoot, "git", "tag", "v1.1.0", baseRef)
run(t, fixtureRoot, "git", "update-ref", "refs/tags/withdrawn/v1.1.0", baseRef)
// 候选提交故意把生成器与比较器改成无条件成功;若脚本错误地信任候选代码,
// 整个检查会输出 CANDIDATE_NOOP 并以 0 退出。
writeFile(t, filepath.Join(fixtureRoot, "cmd", "interface-snapshot", "main.go"), candidateNoopSource, 0o644)
writeFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "snapshot.go"), candidateSnapshotSource, 0o644)
if test.baseGovernance != "complete" {
writeFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "migrations.go"), "package interfacesnapshot\n", 0o644)
writeFile(t,
filepath.Join(fixtureRoot, "internal", "corecmd", "runtimeannotate", "interface_alias.go"),
aliasContractSource,
0o644,
)
writeFile(t,
filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-flag-migrations-v1.json"),
"{\"version\":1,\"migrations\":[]}\n",
0o644,
)
}
switch test.candidateMutation {
case "":
case "delete-alias-contract":
if err := os.Remove(filepath.Join(fixtureRoot, "internal", "corecmd", "runtimeannotate", "interface_alias.go")); err != nil {
t.Fatalf("remove candidate alias contract: %v", err)
}
case "forge-alias-token":
writeFile(t,
filepath.Join(fixtureRoot, "internal", "evil", "forged.go"),
"package evil\n\nconst forged = \"dws.compat.alias_of\"\n",
0o644,
)
case "symlink-helper-parent":
helperPath := filepath.Join(fixtureRoot, "internal", "interfacesnapshot")
alternatePath := filepath.Join(fixtureRoot, "internal", "interfacesnapshot-alt")
if err := os.Rename(helperPath, alternatePath); err != nil {
t.Fatalf("move candidate helper behind ancestor symlink: %v", err)
}
if err := os.Symlink("interfacesnapshot-alt", helperPath); err != nil {
t.Fatalf("symlink candidate helper parent: %v", err)
}
default:
t.Fatalf("unknown candidate mutation %q", test.candidateMutation)
}
run(t, fixtureRoot, "git", "add", ".")
run(t, fixtureRoot, "git", "commit", "-m", "malicious candidate comparator")
if test.baseGovernance == "complete" && test.candidateMutation == "" {
writeFile(t,
filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-flag-migrations-v1.json"),
"{\"version\":1,\"migrations\":[{\"uncommitted\":true}]}\n",
0o644,
)
}
cmd := exec.Command(
"sh",
"scripts/policy/check-command-compatibility.sh",
"--base-ref", baseRef,
"--stable-ref", "v1.0.0",
"--candidate-ref", "HEAD",
)
cmd.Dir = fixtureRoot
cmd.Env = integrationEnvironment(fixtureRoot)
output, runErr := cmd.CombinedOutput()
got := string(output)
if runErr == nil {
t.Fatalf("compatibility script unexpectedly succeeded; output:\n%s", got)
}
if strings.Contains(got, "CANDIDATE_NOOP") {
t.Fatalf("candidate-owned no-op helper took control; output:\n%s", got)
}
if strings.Contains(got, "UNCOMMITTED_MANIFEST_USED") {
t.Fatalf("compatibility script mixed the live manifest with the committed candidate surface; output:\n%s", got)
}
if test.wantAuthorityDecision {
if count := strings.Count(got, "BASE_HELPER_GENERATE=BASE"); count != 3 {
t.Fatalf("compatibility script generated %d/3 snapshots with the base-owned internal helper; output:\n%s", count, got)
}
if !strings.Contains(got, "BASE_COMPARATOR_ENFORCED") {
t.Fatalf("compatibility script did not use the base-owned comparator; output:\n%s", got)
}
if test.baseGovernance == "complete" && !strings.Contains(got, "CANDIDATE_MANIFEST=") {
t.Fatalf("base comparator did not receive the committed candidate manifest; output:\n%s", got)
}
} else if !strings.Contains(got, test.want) {
t.Fatalf("compatibility script output missing %q:\n%s", test.want, got)
}
if !test.wantAuthorityDecision {
return
}
wrongStable := exec.Command(
"sh",
"scripts/policy/check-command-compatibility.sh",
"--base-ref", baseRef,
"--stable-ref", "HEAD",
"--candidate-ref", "HEAD",
)
wrongStable.Dir = fixtureRoot
wrongStable.Env = integrationEnvironment(fixtureRoot)
wrongOutput, wrongErr := wrongStable.CombinedOutput()
if wrongErr == nil || !strings.Contains(string(wrongOutput), "is not the expected highest GA tag") {
t.Fatalf("arbitrary stable ref was not rejected: err=%v output:\n%s", wrongErr, wrongOutput)
}
}
func repositoryRoot(t *testing.T) string {
t.Helper()
_, sourceFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("resolve test source path")
}
root := filepath.Clean(filepath.Join(filepath.Dir(sourceFile), "..", "..", ".."))
if _, err := os.Stat(filepath.Join(root, "scripts", "policy", "check-command-compatibility.sh")); err != nil {
t.Fatalf("resolve repository root %s: %v", root, err)
}
return root
}
func copyFile(t *testing.T, source, destination string, mode os.FileMode) {
t.Helper()
content, err := os.ReadFile(source)
if err != nil {
t.Fatalf("read %s: %v", source, err)
}
writeFile(t, destination, string(content), mode)
}
func writeFile(t *testing.T, path, content string, mode os.FileMode) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("create parent for %s: %v", path, err)
}
if err := os.WriteFile(path, []byte(content), mode); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
func run(t *testing.T, dir, name string, args ...string) string {
t.Helper()
cmd := exec.Command(name, args...)
cmd.Dir = dir
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("%s %s failed: %v\n%s", name, strings.Join(args, " "), err, output)
}
return string(output)
}
func integrationEnvironment(fixtureRoot string) []string {
overrides := map[string]string{
"DWS_POLICY_TMPDIR": "",
"GOCACHE": filepath.Join(fixtureRoot, ".cache", "go-build"),
"GOMODCACHE": filepath.Join(fixtureRoot, ".cache", "go-mod"),
"GOTMPDIR": "",
"GOTOOLCHAIN": "local",
"GOWORK": "off",
}
environment := make([]string, 0, len(os.Environ())+len(overrides))
for _, item := range os.Environ() {
key, _, _ := strings.Cut(item, "=")
if _, replaced := overrides[key]; !replaced {
environment = append(environment, item)
}
}
for key, value := range overrides {
environment = append(environment, key+"="+value)
}
return environment
}
@@ -0,0 +1 @@
{"version":1,"migrations":[]}
+3 -2
View File
@@ -1,8 +1,9 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// Command multi-im-skill-chain verifies that reviewed high-frequency IM
// intents keep one default route across Skill references and Agent selection.
// Command multi-im-skill-chain verifies that reviewed high-frequency IM and
// personal-event intents keep one default route across Skill references and
// Agent selection.
package main
import (
@@ -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,19 @@
"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"
]
},
{
"id": "event.listen.oa",
"preferred_tool": "event.consume",
"forbidden_default_tools": [
"event.listen_im"
],
"references": [
"skills/multi/dingtalk-event/SKILL.md",
"skills/multi/dingtalk-event/references/event-oa.md"
]
}
]
+562 -2
View File
@@ -12,8 +12,11 @@ import (
"fmt"
"io"
"os"
"reflect"
"sort"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/interfacesnapshot"
)
const schemaContractVersion = 3
@@ -82,12 +85,19 @@ func main() {
func run(args []string, stdout, stderr io.Writer) int {
var normalizePath, checkPath, mergePath, currentPath string
var approvedFlagMigrationsPath, candidateFlagMigrationsPath string
var migrationCurrentSnapshotPath, migrationBaseSnapshotPath, migrationStableSnapshotPath string
flags := flag.NewFlagSet("schema-compat", flag.ContinueOnError)
flags.SetOutput(stderr)
flags.StringVar(&normalizePath, "normalize", "", "normalize a raw complete Schema response")
flags.StringVar(&checkPath, "check", "", "check against a normalized historical baseline")
flags.StringVar(&mergePath, "merge", "", "merge additions into a normalized historical baseline")
flags.StringVar(&currentPath, "current", "", "raw current complete Schema response")
flags.StringVar(&approvedFlagMigrationsPath, "approved-flag-migrations", "", "base-owned approved flag migration manifest")
flags.StringVar(&candidateFlagMigrationsPath, "candidate-flag-migrations", "", "detached candidate flag migration manifest")
flags.StringVar(&migrationCurrentSnapshotPath, "migration-current-snapshot", "", "current interface snapshot used for migration authorization")
flags.StringVar(&migrationBaseSnapshotPath, "migration-base-snapshot", "", "merge-base interface snapshot used for migration authorization")
flags.StringVar(&migrationStableSnapshotPath, "migration-stable-snapshot", "", "stable interface snapshot used for migration authorization")
if err := flags.Parse(args); err != nil {
return 2
}
@@ -102,6 +112,27 @@ func run(args []string, stdout, stderr io.Writer) int {
fmt.Fprintln(stderr, "exactly one of --normalize, --check, or --merge is required")
return 2
}
migrationInputs := []string{
approvedFlagMigrationsPath,
candidateFlagMigrationsPath,
migrationCurrentSnapshotPath,
migrationBaseSnapshotPath,
migrationStableSnapshotPath,
}
migrationInputCount := 0
for _, path := range migrationInputs {
if path != "" {
migrationInputCount++
}
}
if migrationInputCount != 0 && migrationInputCount != len(migrationInputs) {
fmt.Fprintln(stderr, "Schema flag migration authorization requires all five migration inputs")
return 2
}
if migrationInputCount != 0 && checkPath == "" {
fmt.Fprintln(stderr, "Schema flag migration authorization is only valid with --check")
return 2
}
if normalizePath != "" {
currentPath = normalizePath
@@ -128,6 +159,24 @@ func run(args []string, stdout, stderr io.Writer) int {
fmt.Fprintf(stderr, "read schema baseline: %v\n", err)
return 2
}
if migrationInputCount != 0 {
migrations, err := authorizeSchemaFlagMigrations(
approvedFlagMigrationsPath,
candidateFlagMigrationsPath,
migrationCurrentSnapshotPath,
migrationBaseSnapshotPath,
migrationStableSnapshotPath,
)
if err != nil {
fmt.Fprintf(stderr, "authorize Schema flag migrations: %v\n", err)
return 2
}
baseline, err = normalizeSchemaFlagMigrations(baseline, current, migrations)
if err != nil {
fmt.Fprintf(stderr, "normalize approved Schema flag migrations: %v\n", err)
return 2
}
}
failures := checkCompatibility(baseline, current)
if len(failures) > 0 {
fmt.Fprintln(stderr, "Schema backwards-compatibility check failed:")
@@ -159,6 +208,57 @@ func run(args []string, stdout, stderr io.Writer) int {
return 0
}
func authorizeSchemaFlagMigrations(
approvedManifestPath string,
candidateManifestPath string,
currentSnapshotPath string,
baseSnapshotPath string,
stableSnapshotPath string,
) ([]interfacesnapshot.FlagMigration, error) {
approved, err := readFlagMigrationManifestFile(approvedManifestPath)
if err != nil {
return nil, fmt.Errorf("read approved flag migrations: %w", err)
}
candidate, err := readFlagMigrationManifestFile(candidateManifestPath)
if err != nil {
return nil, fmt.Errorf("read candidate flag migrations: %w", err)
}
current, err := readInterfaceSnapshotFile(currentSnapshotPath)
if err != nil {
return nil, fmt.Errorf("read migration current snapshot: %w", err)
}
base, err := readInterfaceSnapshotFile(baseSnapshotPath)
if err != nil {
return nil, fmt.Errorf("read migration base snapshot: %w", err)
}
stable, err := readInterfaceSnapshotFile(stableSnapshotPath)
if err != nil {
return nil, fmt.Errorf("read migration stable snapshot: %w", err)
}
return interfacesnapshot.AuthorizeFlagMigrations(
current,
map[string]interfacesnapshot.Snapshot{"merge-base": base, "stable": stable},
approved,
candidate,
)
}
func readFlagMigrationManifestFile(path string) (interfacesnapshot.FlagMigrationManifest, error) {
data, err := os.ReadFile(path)
if err != nil {
return interfacesnapshot.FlagMigrationManifest{}, err
}
return interfacesnapshot.ReadFlagMigrationManifest(bytes.NewReader(data))
}
func readInterfaceSnapshotFile(path string) (interfacesnapshot.Snapshot, error) {
data, err := os.ReadFile(path)
if err != nil {
return interfacesnapshot.Snapshot{}, err
}
return interfacesnapshot.Read(bytes.NewReader(data))
}
func normalizeRawFile(path string) (schemaContract, error) {
data, err := os.ReadFile(path)
if err != nil {
@@ -510,7 +610,8 @@ func checkToolCompatibility(toolPath string, oldTool, newTool toolSchema) []stri
}
if oldTool.Constraints != newTool.Constraints &&
!compatibleHiddenSiblingConstraintExpansion(oldTool, newTool) &&
!compatibleAdditiveConstraintEvolution(oldTool, newTool) {
!compatibleAdditiveConstraintEvolution(oldTool, newTool) &&
!compatibleReviewedConstraintTransition(toolPath, oldTool, newTool) {
failures = append(failures, fmt.Sprintf("schema tool %q changed constraints", toolPath))
}
if !compatiblePositionals(oldTool.Positionals, newTool.Positionals) {
@@ -562,6 +663,31 @@ var reviewedInterfaceRefRedirect = map[string]map[string]string{
},
}
// reviewedConstraintTransition enumerates exact constraint changes that were
// explicitly reviewed even though the compatibility gate cannot infer their
// business semantics. Keep this narrow: removing a constraint can expose a new
// runtime route, so arbitrary removals must not pass as harmless drift.
var reviewedConstraintTransition = map[string]map[string]string{
// PR #933 aligned the Shortcut and Schema with the existing doc import
// runtime: omitting both targets imports into the default root. Keeping the
// historical require_one_of made the documented Golden Route unreachable.
"doc/doc.shortcut_import": {
`{"require_one_of":[["folder","workspace"]]}`: "",
},
}
func compatibleReviewedConstraintTransition(toolPath string, oldTool, newTool toolSchema) bool {
transitions, ok := reviewedConstraintTransition[toolPath]
if !ok {
return false
}
want, ok := transitions[oldTool.Constraints]
if !ok {
return false
}
return want == newTool.Constraints
}
// compatibleInterfaceRefRedirect accepts repointing a tool at a different
// backing RPC when the migration is an explicitly reviewed entry in
// reviewedInterfaceRefRedirect **and** the CLI-facing contract is provably
@@ -814,14 +940,120 @@ func compatiblePositionals(oldPositionals, newPositionals []positionalSchema) bo
return true
}
// parameterTypeChange identifies one published parameter type migration
// exactly: which tool, which parameter, and in which direction. Nothing is
// matched by wildcard — an entry differing in any of the four fields does not
// apply.
//
// ToolPath is "<product id>/<tool id>" exactly as the comparison builds it (see
// where toolPath is assembled), e.g.
// "minutes/minutes.apply_minutes_permission". From and To are the canonical
// type values schemaType produces — the JSON encoding of the "type" keyword,
// so `"string"` **with** its quotes, not a bare string. Spelling either one any
// other way silently disables the exemption instead of failing loudly (exactly
// how reviewedInterfaceRefRedirect was broken twice), so
// TestCrossPlatformCoverageReviewedParameterTypeChangeKeysAreCanonical
// recomputes both through schemaType rather than trusting the table.
type parameterTypeChange struct {
ToolPath string
Parameter string
From string
To string
}
// reviewedParameterTypeChanges enumerates the individually reviewed parameter
// type migrations this gate accepts. A published type is part of the Schema
// contract and a swap cannot be proven safe from the type names alone, so a
// migration is accepted only when this exact tool, parameter and old→new pair
// appear here. Adding an entry is a contract decision and belongs in review,
// not in a feature change.
//
// Entries are direction-sensitive by construction: "string" → "integer" is a
// separate key from "integer" → "string", and only the reviewed direction is
// accepted.
//
// The table alone does not admit a change: the migration is rejected unless
// every other published field of the parameter is identical — not merely free
// of compatibility failures. See compatibleReviewedParameterTypeChange.
var reviewedParameterTypeChanges = map[parameterTypeChange]struct{}{
// "dws minutes permission apply --policy" moved from a String flag to a
// native Int flag, so the published type follows it from "string" to
// "integer". A parameter's type is projected from the Cobra flag type
// (provenance source cobra_flag_type), so it describes how the CLI accepts
// the value, not how the backing RPC receives it.
//
// Accepted because no historical caller is invalidated:
//
// - Consumers read this Schema to construct CLI invocations, and a command
// line is text either way: "--policy 4" is the same argv under both
// declarations, and a quoted "--policy \"4\"" still reaches pflag as 4.
// - The flag keeps enforcing the same [2,4] domain in RunE, and pflag's
// base-0 parse accepts every base-10 spelling the previous
// strconv.ParseInt(v, 10, 64) accepted, so the set of successful
// invocations only widens.
// - "integer" also matches what actually goes on the wire: this parameter
// maps to property "policyId", which the command has always sent as a
// number, so the new declaration is closer to the request than the old.
//
// The parameter's default stays absent on both sides here (a zero default is
// not published) and every other published field is identical, which is why
// the equality guard admits this entry. A migration that also moved the
// default — or relaxed required, cleared required_when, widened enum, cleared
// interface_type — would be rejected even though several of those are
// individually compatible, because none of them were reviewed under this
// entry.
{
ToolPath: "minutes/minutes.apply_minutes_permission",
Parameter: "policy",
From: `"string"`,
To: `"integer"`,
}: {},
}
// parameterContractUnchangedExceptType reports whether every published field of
// the parameter other than its type is identical.
//
// This is a full equality check rather than "the gate recorded no other
// failure", because several field changes are individually *compatible* and so
// produce no failure at all: relaxing required or cli_required, clearing
// required_when, widening enum, clearing interface_type, and clearing property
// through a reviewed mapping exclusion. Keying the carve-out on the failure list
// would let any of those ride along with a reviewed type migration without ever
// having been reviewed under that entry — the exemption would be wider than what
// it documents.
//
// Comparing the struct also means a field added to parameterSchema later is
// covered here automatically, instead of silently widening every existing entry.
func parameterContractUnchangedExceptType(oldParameter, newParameter parameterSchema) bool {
oldParameter.Type = ""
newParameter.Type = ""
return reflect.DeepEqual(oldParameter, newParameter)
}
// compatibleReviewedParameterTypeChange accepts a published parameter type
// migration when it is an explicitly reviewed entry **and** the rest of the
// parameter's published contract is unchanged.
func compatibleReviewedParameterTypeChange(toolPath, name string, oldParameter, newParameter parameterSchema) bool {
_, reviewed := reviewedParameterTypeChanges[parameterTypeChange{
ToolPath: toolPath,
Parameter: name,
From: oldParameter.Type,
To: newParameter.Type,
}]
return reviewed && parameterContractUnchangedExceptType(oldParameter, newParameter)
}
func checkParameterCompatibility(toolPath, name string, oldParameter, newParameter parameterSchema) []string {
var failures []string
if oldParameter.Type != newParameter.Type &&
!compatibleReviewedParameterTypeChange(toolPath, name, oldParameter, newParameter) {
failures = append(failures, fmt.Sprintf("schema tool %q parameter %q changed type", toolPath, name))
}
for _, field := range []struct {
name string
old string
new string
}{
{name: "type", old: oldParameter.Type, new: newParameter.Type},
{name: "default", old: oldParameter.Default, new: newParameter.Default},
{name: "interface_default", old: oldParameter.InterfaceDefault, new: newParameter.InterfaceDefault},
{name: "format", old: oldParameter.Format, new: newParameter.Format},
@@ -919,6 +1151,334 @@ func cloneContract(source schemaContract) schemaContract {
return cloned
}
type schemaToolRef struct {
productID string
toolID string
}
// normalizeSchemaFlagMigrations projects only an already-authorized CLI flag
// rename onto a cloned historical Schema contract. The ordinary compatibility
// checker still makes the final decision; this adapter never drops findings by
// matching their rendered text.
func normalizeSchemaFlagMigrations(
baseline schemaContract,
current schemaContract,
migrations []interfacesnapshot.FlagMigration,
) (schemaContract, error) {
normalized := cloneContract(baseline)
renamesByTool := map[schemaToolRef]map[string]string{}
for _, migration := range migrations {
primaryPath := strings.TrimPrefix(migration.Command, "dws ")
matches := schemaToolsByPrimaryPath(baseline, primaryPath)
if len(matches) == 0 {
// A reviewed CLI-only command has no Schema compatibility surface.
continue
}
if len(matches) != 1 {
return schemaContract{}, fmt.Errorf(
"approved flag migration %q matches %d historical Schema tools",
migration.Command,
len(matches),
)
}
ref := matches[0]
oldTool := baseline.Products[ref.productID].Tools[ref.toolID]
newProduct, productExists := current.Products[ref.productID]
newTool, toolExists := newProduct.Tools[ref.toolID]
if !productExists || !toolExists || newTool.PrimaryCLIPath != primaryPath {
// Preserve the original baseline so the ordinary checker reports the
// missing tool or changed primary_cli_path.
continue
}
oldLegacy, legacyExisted := oldTool.Parameters[migration.Legacy.Name]
oldCanonical, canonicalExisted := oldTool.Parameters[migration.Canonical.Name]
if !legacyExisted && !canonicalExisted {
// A CLI migration is not evidence that an unrelated Schema surface
// published either name. In particular, it must not authorize a
// constraints rewrite with no historical parameter to rename.
continue
}
if _, exists := newTool.Parameters[migration.Legacy.Name]; exists {
return schemaContract{}, fmt.Errorf(
"approved flag migration %q still publishes legacy Schema parameter %q",
migration.Command,
migration.Legacy.Name,
)
}
newCanonical, exists := newTool.Parameters[migration.Canonical.Name]
if !exists {
return schemaContract{}, fmt.Errorf(
"approved flag migration %q does not publish canonical Schema parameter %q",
migration.Command,
migration.Canonical.Name,
)
}
if migration.Canonical.After.Required && (!newCanonical.Required || !newCanonical.CLIRequired) {
return schemaContract{}, fmt.Errorf(
"approved flag migration %q canonical Schema parameter %q must be required and cli_required",
migration.Command,
migration.Canonical.Name,
)
}
if !legacyExisted {
// A consumed receipt may be evaluated after the merge-base Schema has
// already reached the canonical-only state. In that case the ordinary
// checker needs no projection. More importantly, a CLI migration is not
// authority to promote a canonical-only historical Schema parameter from
// optional to required: without legacy Schema evidence there is no rename.
continue
}
normalizedProduct := normalized.Products[ref.productID]
normalizedTool := normalizedProduct.Tools[ref.toolID]
if err := validateRenamedSchemaParameter(migration, oldLegacy, newCanonical); err != nil {
return schemaContract{}, err
}
delete(normalizedTool.Parameters, migration.Legacy.Name)
if canonicalExisted {
if err := validateRenamedSchemaParameter(migration, oldCanonical, newCanonical); err != nil {
return schemaContract{}, err
}
normalizedCanonical := normalizedTool.Parameters[migration.Canonical.Name]
normalizedCanonical.Required = newCanonical.Required
normalizedCanonical.CLIRequired = newCanonical.CLIRequired
normalizedTool.Parameters[migration.Canonical.Name] = normalizedCanonical
} else {
normalizedTool.Parameters[migration.Canonical.Name] = newCanonical
}
normalizedProduct.Tools[ref.toolID] = normalizedTool
normalized.Products[ref.productID] = normalizedProduct
if renamesByTool[ref] == nil {
renamesByTool[ref] = map[string]string{}
}
if existing, ok := renamesByTool[ref][migration.Legacy.Name]; ok && existing != migration.Canonical.Name {
return schemaContract{}, fmt.Errorf(
"approved flag migration %q maps Schema parameter %q to both %q and %q",
migration.Command,
migration.Legacy.Name,
existing,
migration.Canonical.Name,
)
}
renamesByTool[ref][migration.Legacy.Name] = migration.Canonical.Name
}
for ref, renames := range renamesByTool {
oldTool := baseline.Products[ref.productID].Tools[ref.toolID]
newTool := current.Products[ref.productID].Tools[ref.toolID]
if oldTool.Constraints == newTool.Constraints {
continue
}
oldConstraints, oldOK := canonicalizeMigratedConstraints(oldTool.Constraints, renames)
newConstraints, newOK := canonicalizeMigratedConstraints(newTool.Constraints, nil)
if !oldOK || !newOK || oldConstraints != newConstraints {
// Keep the historical constraints unchanged. The ordinary checker
// will report every non-rename constraint change.
continue
}
product := normalized.Products[ref.productID]
tool := product.Tools[ref.toolID]
tool.Constraints = newTool.Constraints
product.Tools[ref.toolID] = tool
normalized.Products[ref.productID] = product
}
return normalized, nil
}
func schemaToolsByPrimaryPath(contract schemaContract, primaryPath string) []schemaToolRef {
var matches []schemaToolRef
for productID, product := range contract.Products {
for toolID, tool := range product.Tools {
if tool.PrimaryCLIPath == primaryPath {
matches = append(matches, schemaToolRef{productID: productID, toolID: toolID})
}
}
}
sort.Slice(matches, func(i, j int) bool {
if matches[i].productID != matches[j].productID {
return matches[i].productID < matches[j].productID
}
return matches[i].toolID < matches[j].toolID
})
return matches
}
func validateRenamedSchemaParameter(
migration interfacesnapshot.FlagMigration,
oldParameter parameterSchema,
newParameter parameterSchema,
) error {
if oldParameter.Type != newParameter.Type ||
oldParameter.Property != newParameter.Property ||
oldParameter.InterfaceType != newParameter.InterfaceType ||
oldParameter.RequiredWhen != newParameter.RequiredWhen ||
oldParameter.Default != newParameter.Default ||
oldParameter.InterfaceDefault != newParameter.InterfaceDefault ||
oldParameter.Format != newParameter.Format ||
!equalStringSlices(oldParameter.Enum, newParameter.Enum) {
return fmt.Errorf(
"approved flag migration %q Schema parameter %q -> %q changed a non-migration field",
migration.Command,
migration.Legacy.Name,
migration.Canonical.Name,
)
}
if oldParameter.Required && !newParameter.Required {
return fmt.Errorf(
"approved flag migration %q Schema parameter %q -> %q became optional",
migration.Command,
migration.Legacy.Name,
migration.Canonical.Name,
)
}
if oldParameter.CLIRequired && !newParameter.CLIRequired {
return fmt.Errorf(
"approved flag migration %q Schema parameter %q -> %q stopped being cli_required",
migration.Command,
migration.Legacy.Name,
migration.Canonical.Name,
)
}
return nil
}
func equalStringSlices(left, right []string) bool {
if len(left) != len(right) {
return false
}
for index := range left {
if left[index] != right[index] {
return false
}
}
return true
}
func canonicalizeMigratedConstraints(raw string, renames map[string]string) (string, bool) {
groups, ok := parseMigrationConstraintsStrict(raw)
if !ok {
return "", false
}
normalized := map[string][][]string{}
for _, kind := range []string{"mutually_exclusive", "require_one_of", "require_together"} {
for _, group := range groups[kind] {
members := make([]string, 0, len(group))
for _, member := range group {
if replacement := renames[member]; replacement != "" {
member = replacement
}
members = append(members, member)
}
sort.Strings(members)
members = compactConstraintStrings(members)
normalized[kind] = append(normalized[kind], members)
}
sort.Slice(normalized[kind], func(i, j int) bool {
return strings.Join(normalized[kind][i], "\x00") < strings.Join(normalized[kind][j], "\x00")
})
normalized[kind] = compactConstraintGroups(normalized[kind])
if len(normalized[kind]) == 0 {
delete(normalized, kind)
}
}
if len(normalized) == 0 {
return "", true
}
encoded, err := json.Marshal(normalized)
return string(encoded), err == nil
}
func parseMigrationConstraintsStrict(raw string) (map[string][][]string, bool) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return map[string][][]string{}, true
}
decoder := json.NewDecoder(strings.NewReader(trimmed))
opening, err := decoder.Token()
if err != nil || opening != json.Delim('{') {
return nil, false
}
groups := map[string][][]string{}
for decoder.More() {
fieldToken, err := decoder.Token()
if err != nil {
return nil, false
}
// encoding/json guarantees that an object member name is a string token.
field := fieldToken.(string)
switch field {
case "mutually_exclusive", "require_one_of", "require_together":
default:
return nil, false
}
if _, duplicate := groups[field]; duplicate {
return nil, false
}
var encodedGroups json.RawMessage
if err := decoder.Decode(&encodedGroups); err != nil {
return nil, false
}
encodedGroups = bytes.TrimSpace(encodedGroups)
if len(encodedGroups) == 0 || encodedGroups[0] != '[' {
return nil, false
}
var fieldGroups [][]string
if err := json.Unmarshal(encodedGroups, &fieldGroups); err != nil {
return nil, false
}
for _, group := range fieldGroups {
if len(group) == 0 {
return nil, false
}
for _, member := range group {
if member == "" || member != strings.TrimSpace(member) {
return nil, false
}
}
}
groups[field] = fieldGroups
}
closing, err := decoder.Token()
if err != nil || closing != json.Delim('}') {
return nil, false
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
return nil, false
}
return groups, true
}
func compactConstraintStrings(values []string) []string {
if len(values) < 2 {
return values
}
out := values[:1]
for _, value := range values[1:] {
if value != out[len(out)-1] {
out = append(out, value)
}
}
return out
}
func compactConstraintGroups(groups [][]string) [][]string {
if len(groups) < 2 {
return groups
}
out := groups[:1]
for _, group := range groups[1:] {
if !equalStringSlices(group, out[len(out)-1]) {
out = append(out, group)
}
}
return out
}
func writeContract(w io.Writer, contract schemaContract) error {
contract.Version = schemaContractVersion
if contract.Products == nil {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,676 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package schemacompat
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
const governedSchemaCheckerSource = `package main
import (
"flag"
"fmt"
"os"
"strings"
)
func main() {
normalize := flag.String("normalize", "", "raw schema")
check := flag.String("check", "", "base schema")
current := flag.String("current", "", "candidate schema")
approved := flag.String("approved-flag-migrations", "", "base ledger")
candidate := flag.String("candidate-flag-migrations", "", "candidate ledger")
currentSnapshot := flag.String("migration-current-snapshot", "", "candidate interface snapshot")
baseSnapshot := flag.String("migration-base-snapshot", "", "base interface snapshot")
stableSnapshot := flag.String("migration-stable-snapshot", "", "stable interface snapshot")
flag.Parse()
if *normalize != "" {
data, err := os.ReadFile(*normalize)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
fmt.Print(string(data))
return
}
if *check == "" || *current == "" {
fmt.Fprintln(os.Stderr, "missing check inputs")
os.Exit(2)
}
currentData, err := os.ReadFile(*current)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
if !strings.Contains(string(currentData), "COMMITTED_CANDIDATE") {
fmt.Fprintf(os.Stderr, "candidate schema did not come from detached commit: %s\n", currentData)
os.Exit(2)
}
paths := []string{*approved, *candidate, *currentSnapshot, *baseSnapshot, *stableSnapshot}
for _, path := range paths {
if path == "" {
fmt.Fprintln(os.Stderr, "governed checker did not receive all migration inputs")
os.Exit(2)
}
}
for _, fixture := range []struct {
path string
want string
}{
{*approved, "{\"version\":1,\"migrations\":[{\"authority\":\"BASE\"}]}\n"},
{*candidate, "{\"version\":1,\"migrations\":[{\"candidate\":\"COMMITTED\"}]}\n"},
{*currentSnapshot, "COMMITTED_CANDIDATE"},
{*baseSnapshot, "BASE_AUTHORITY"},
{*stableSnapshot, "STABLE_RELEASE"},
} {
data, err := os.ReadFile(fixture.path)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
if !strings.Contains(string(data), fixture.want) {
fmt.Fprintf(os.Stderr, "migration input %s does not contain %q: %s\n", fixture.path, fixture.want, data)
os.Exit(2)
}
}
for _, path := range []string{*currentSnapshot, *baseSnapshot, *stableSnapshot} {
data, err := os.ReadFile(path)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
if !strings.Contains(string(data), "INTERFACE_HELPER=BASE") {
fmt.Fprintf(os.Stderr, "interface snapshot did not use base internal helper: %s\n", data)
os.Exit(2)
}
}
fmt.Fprintln(os.Stderr, "BASE_SCHEMA_CHECKER_ENFORCED")
os.Exit(23)
}
`
const bootstrapSchemaCheckerSource = `package main
import (
"flag"
"fmt"
"os"
"strings"
)
func main() {
normalize := flag.String("normalize", "", "raw schema")
check := flag.String("check", "", "base schema")
current := flag.String("current", "", "candidate schema")
flag.Parse()
if *normalize != "" {
data, err := os.ReadFile(*normalize)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
fmt.Print(string(data))
return
}
if *check == "" || *current == "" {
fmt.Fprintln(os.Stderr, "missing check inputs")
os.Exit(2)
}
data, err := os.ReadFile(*current)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
if !strings.Contains(string(data), "COMMITTED_CANDIDATE") {
fmt.Fprintf(os.Stderr, "candidate schema did not come from detached commit: %s\n", data)
os.Exit(2)
}
baselineData, err := os.ReadFile(*check)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
switch {
case strings.Contains(string(baselineData), "BASE_AUTHORITY"):
fmt.Fprintln(os.Stdout, "OLD_BASE_SCHEMA_CONTRACT_CHECKED")
case strings.Contains(string(baselineData), "STABLE_RELEASE"):
fmt.Fprintln(os.Stderr, "OLD_BASE_STABLE_SCHEMA_CHECKER_ENFORCED")
os.Exit(23)
default:
fmt.Fprintf(os.Stderr, "unexpected historical Schema contract: %s\n", baselineData)
os.Exit(2)
}
}
`
const stableSchemaGuardCheckerSource = `package main
import (
"flag"
"fmt"
"os"
"strings"
)
func main() {
normalize := flag.String("normalize", "", "raw schema")
check := flag.String("check", "", "historical schema")
current := flag.String("current", "", "candidate schema")
approved := flag.String("approved-flag-migrations", "", "base ledger")
candidate := flag.String("candidate-flag-migrations", "", "candidate ledger")
currentSnapshot := flag.String("migration-current-snapshot", "", "candidate interface snapshot")
baseSnapshot := flag.String("migration-base-snapshot", "", "base interface snapshot")
stableSnapshot := flag.String("migration-stable-snapshot", "", "stable interface snapshot")
flag.Parse()
if *normalize != "" {
data, err := os.ReadFile(*normalize)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
fmt.Print(string(data))
return
}
if *check == "" || *current == "" {
fmt.Fprintln(os.Stderr, "missing check inputs")
os.Exit(2)
}
for _, fixture := range []struct {
path string
want string
}{
{*approved, "\"authority\":\"BASE\""},
{*candidate, "\"candidate\":\"COMMITTED\""},
{*currentSnapshot, "COMMITTED_CANDIDATE"},
{*baseSnapshot, "BASE_AUTHORITY"},
{*stableSnapshot, "STABLE_RELEASE"},
} {
if fixture.path == "" {
fmt.Fprintln(os.Stderr, "stable Schema guard did not receive all migration inputs")
os.Exit(2)
}
data, err := os.ReadFile(fixture.path)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
if !strings.Contains(string(data), fixture.want) {
fmt.Fprintf(os.Stderr, "stable Schema guard input %s does not contain %q: %s\n", fixture.path, fixture.want, data)
os.Exit(2)
}
}
currentData, err := os.ReadFile(*current)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
if !strings.Contains(string(currentData), "COMMITTED_CANDIDATE") {
fmt.Fprintf(os.Stderr, "candidate schema did not come from detached commit: %s\n", currentData)
os.Exit(2)
}
baselineData, err := os.ReadFile(*check)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
switch {
case strings.Contains(string(baselineData), "BASE_AUTHORITY"):
fmt.Fprintln(os.Stdout, "BASE_SCHEMA_CONTRACT_CHECKED")
case strings.Contains(string(baselineData), "STABLE_RELEASE"):
fmt.Fprintln(os.Stderr, "STABLE_SCHEMA_CONTRACT_ENFORCED")
os.Exit(23)
default:
fmt.Fprintf(os.Stderr, "unexpected historical Schema contract: %s\n", baselineData)
os.Exit(2)
}
}
`
const authorityInterfaceHelperSource = `package main
import (
"flag"
"fmt"
"os"
"example.com/dws-schema-compat-authority-test/internal/interfacesnapshot"
)
func main() {
if len(os.Args) < 2 || os.Args[1] != "generate" {
fmt.Fprintln(os.Stderr, "expected generate")
os.Exit(2)
}
flags := flag.NewFlagSet("generate", flag.ExitOnError)
output := flags.String("output", "", "snapshot output")
_ = flags.Parse(os.Args[2:])
marker, err := os.ReadFile("SNAPSHOT_MARKER")
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
marker = append(marker, []byte("INTERFACE_HELPER="+interfacesnapshot.AuthorityMarker()+"\n")...)
if err := os.WriteFile(*output, marker, 0o600); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
fmt.Printf("BASE_INTERFACE_HELPER_GENERATE=%s\n", interfacesnapshot.AuthorityMarker())
}
`
const schemaGovernedSnapshotSource = `package interfacesnapshot
import "example.com/dws-schema-compat-authority-test/internal/corecmd/runtimeannotate"
var (
_ = runtimeannotate.AnnotationFlagAliasOf
_ = runtimeannotate.AnnotationFlagAliasOrigin
_ = runtimeannotate.FlagAliasOriginCorecmdV1
)
func AuthorityMarker() string { return "BASE" }
`
const schemaBaseSnapshotSource = `package interfacesnapshot
func AuthorityMarker() string { return "BASE" }
`
const schemaStableSnapshotSource = `package interfacesnapshot
func AuthorityMarker() string { return "STABLE" }
`
const schemaCandidateSnapshotSource = `package interfacesnapshot
func AuthorityMarker() string { return "CANDIDATE" }
`
const schemaAliasContractSource = `package runtimeannotate
const (
AnnotationFlagAliasOf = "dws.compat.alias_of"
AnnotationFlagAliasOrigin = "dws.compat.alias_origin"
FlagAliasOriginCorecmdV1 = "corecmd.flag_spec_aliases.v1"
)
`
const candidateNoopSource = `package main
import "fmt"
func main() {
fmt.Println("CANDIDATE_NOOP")
}
`
func schemaBinarySource(marker string) string {
return fmt.Sprintf(`package main
import "fmt"
func main() {
fmt.Println(%q)
}
`, `{"kind":"schema","marker":"`+marker+`"}`)
}
type authorityScenario struct {
name string
baseGovernance string
candidateMutation string
checkerMode string
want string
authorityMarker string
}
func TestCrossPlatformCoverageSchemaCompatibilityUsesBaseOwnedAuthority(t *testing.T) {
tests := []authorityScenario{
{
name: "governed base rejects candidate checker and live workspace",
baseGovernance: "complete",
authorityMarker: "BASE_SCHEMA_CHECKER_ENFORCED",
},
{
name: "governed checker protects stable Schema contract",
baseGovernance: "complete",
checkerMode: "stable-schema-guard",
authorityMarker: "STABLE_SCHEMA_CONTRACT_ENFORCED",
},
{
name: "bootstrap uses old base checker and committed canonical empty ledger",
baseGovernance: "none",
authorityMarker: "OLD_BASE_STABLE_SCHEMA_CHECKER_ENFORCED",
},
{
name: "partial base fails closed",
baseGovernance: "partial",
want: "incomplete Schema flag migration governance artifact set",
},
{
name: "base ledger ancestor symlink fails closed",
baseGovernance: "ancestor-symlink",
want: "incomplete Schema flag migration governance artifact set",
},
{
name: "governed candidate must preserve schema checker",
baseGovernance: "complete",
candidateMutation: "delete-checker",
want: "must preserve the complete Schema flag migration governance artifact set",
},
{
name: "governed candidate must preserve migrations implementation",
baseGovernance: "complete",
candidateMutation: "delete-migrations",
want: "must preserve the complete Schema flag migration governance artifact set",
},
{
name: "governed candidate ledger must be a regular file",
baseGovernance: "complete",
candidateMutation: "symlink-ledger",
want: "must preserve the complete Schema flag migration governance artifact set",
},
{
name: "governed candidate helper parent must be a Git tree",
baseGovernance: "complete",
candidateMutation: "symlink-helper-parent",
want: "must preserve the complete Schema flag migration governance artifact set",
},
{
name: "candidate cannot forge alias evidence token",
baseGovernance: "complete",
candidateMutation: "forge-alias-token",
want: "may not forge framework-owned flag alias evidence",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
runSchemaAuthorityCase(t, test)
})
}
}
func runSchemaAuthorityCase(t *testing.T, test authorityScenario) {
t.Helper()
repoRoot := schemaRepositoryRoot(t)
managedRoot := filepath.Join(repoRoot, ".worktrees", "schemacompat-authority-integration")
if err := os.MkdirAll(managedRoot, 0o755); err != nil {
t.Fatalf("create managed test root: %v", err)
}
fixtureRoot, err := os.MkdirTemp(managedRoot, fmt.Sprintf("case-%d-", time.Now().UnixNano()))
if err != nil {
t.Fatalf("create managed fixture repo: %v", err)
}
t.Cleanup(func() {
if err := os.RemoveAll(fixtureRoot); err != nil {
t.Errorf("remove managed fixture repo: %v", err)
}
})
schemaCopyFile(t,
filepath.Join(repoRoot, "scripts", "policy", "check-authoritative-schema-compatibility.sh"),
filepath.Join(fixtureRoot, "scripts", "policy", "check-authoritative-schema-compatibility.sh"),
0o755,
)
schemaCopyFile(t,
filepath.Join(repoRoot, "scripts", "policy", "policy-runtime.sh"),
filepath.Join(fixtureRoot, "scripts", "policy", "policy-runtime.sh"),
0o755,
)
schemaCopyFile(t,
filepath.Join(repoRoot, "scripts", "release", "release-lib.sh"),
filepath.Join(fixtureRoot, "scripts", "release", "release-lib.sh"),
0o755,
)
schemaWriteFile(t, filepath.Join(fixtureRoot, "go.mod"), "module example.com/dws-schema-compat-authority-test\n\ngo 1.23\n", 0o644)
schemaWriteFile(t, filepath.Join(fixtureRoot, "cmd", "main.go"), schemaBinarySource("STABLE_RELEASE"), 0o644)
schemaWriteFile(t, filepath.Join(fixtureRoot, "cmd", "interface-snapshot", "main.go"), candidateNoopSource, 0o644)
schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "snapshot.go"), schemaStableSnapshotSource, 0o644)
schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "compare.go"), "package interfacesnapshot\n", 0o644)
schemaWriteFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "schema-compat", "main.go"), bootstrapSchemaCheckerSource, 0o644)
schemaWriteFile(t, filepath.Join(fixtureRoot, "SNAPSHOT_MARKER"), "STABLE_RELEASE\n", 0o644)
schemaRun(t, fixtureRoot, "git", "init", "-b", "main")
schemaRun(t, fixtureRoot, "git", "config", "user.name", "Schema Compatibility Test")
schemaRun(t, fixtureRoot, "git", "config", "user.email", "schema-compat-test@example.invalid")
schemaRun(t, fixtureRoot, "git", "add", ".")
schemaRun(t, fixtureRoot, "git", "commit", "-m", "stable release")
stableCommit := strings.TrimSpace(schemaRun(t, fixtureRoot, "git", "rev-parse", "HEAD"))
schemaRun(t, fixtureRoot, "git", "tag", "v1.0.0", stableCommit)
schemaWriteFile(t, filepath.Join(fixtureRoot, "cmd", "main.go"), schemaBinarySource("BASE_AUTHORITY"), 0o644)
schemaWriteFile(t, filepath.Join(fixtureRoot, "cmd", "interface-snapshot", "main.go"), authorityInterfaceHelperSource, 0o644)
schemaWriteFile(t, filepath.Join(fixtureRoot, "SNAPSHOT_MARKER"), "BASE_AUTHORITY\n", 0o644)
schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "snapshot.go"), schemaBaseSnapshotSource, 0o644)
switch test.baseGovernance {
case "complete":
checkerSource := governedSchemaCheckerSource
if test.checkerMode == "stable-schema-guard" {
checkerSource = stableSchemaGuardCheckerSource
}
schemaWriteFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "schema-compat", "main.go"), checkerSource, 0o644)
schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "snapshot.go"), schemaGovernedSnapshotSource, 0o644)
schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "migrations.go"), "package interfacesnapshot\n", 0o644)
schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "runtimeannotate", "interface_alias.go"), schemaAliasContractSource, 0o644)
schemaWriteFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-flag-migrations-v1.json"), "{\"version\":1,\"migrations\":[{\"authority\":\"BASE\"}]}\n", 0o644)
case "partial":
schemaWriteFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-flag-migrations-v1.json"), "{\"version\":1,\"migrations\":[]}\n", 0o644)
case "ancestor-symlink":
schemaWriteFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "schema-compat", "main.go"), governedSchemaCheckerSource, 0o644)
schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "migrations.go"), "package interfacesnapshot\n", 0o644)
schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "runtimeannotate", "interface_alias.go"), "package runtimeannotate\n", 0o644)
schemaWriteFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "authority-ledger", "approved-flag-migrations-v1.json"), "{\"version\":1,\"migrations\":[]}\n", 0o644)
if err := os.Symlink("authority-ledger", filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations")); err != nil {
t.Fatalf("symlink base Schema ledger parent: %v", err)
}
case "none":
default:
t.Fatalf("unknown base governance state %q", test.baseGovernance)
}
schemaRun(t, fixtureRoot, "git", "add", ".")
schemaRun(t, fixtureRoot, "git", "commit", "-m", "base authority")
baseRef := strings.TrimSpace(schemaRun(t, fixtureRoot, "git", "rev-parse", "HEAD"))
schemaRun(t, fixtureRoot, "git", "tag", "v1.1.0", baseRef)
schemaRun(t, fixtureRoot, "git", "update-ref", "refs/tags/withdrawn/v1.1.0", baseRef)
if test.baseGovernance == "ancestor-symlink" {
ledgerPath := filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations")
if err := os.Remove(ledgerPath); err != nil {
t.Fatalf("remove inherited base ledger parent symlink: %v", err)
}
if err := os.Rename(
filepath.Join(fixtureRoot, "scripts", "policy", "authority-ledger"),
ledgerPath,
); err != nil {
t.Fatalf("restore regular candidate ledger tree: %v", err)
}
}
schemaWriteFile(t, filepath.Join(fixtureRoot, "cmd", "main.go"), schemaBinarySource("COMMITTED_CANDIDATE"), 0o644)
schemaWriteFile(t, filepath.Join(fixtureRoot, "cmd", "interface-snapshot", "main.go"), candidateNoopSource, 0o644)
schemaWriteFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "schema-compat", "main.go"), candidateNoopSource, 0o644)
schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "snapshot.go"), schemaCandidateSnapshotSource, 0o644)
schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "migrations.go"), "package interfacesnapshot\n", 0o644)
schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "runtimeannotate", "interface_alias.go"), "package runtimeannotate\n", 0o644)
candidateLedger := "{\"version\":1,\"migrations\":[]}\n"
if test.baseGovernance == "complete" {
candidateLedger = "{\"version\":1,\"migrations\":[{\"candidate\":\"COMMITTED\"}]}\n"
}
schemaWriteFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-flag-migrations-v1.json"), candidateLedger, 0o644)
schemaWriteFile(t, filepath.Join(fixtureRoot, "SNAPSHOT_MARKER"), "COMMITTED_CANDIDATE\n", 0o644)
switch test.candidateMutation {
case "":
case "delete-checker":
if err := os.Remove(filepath.Join(fixtureRoot, "scripts", "policy", "schema-compat", "main.go")); err != nil {
t.Fatalf("remove candidate schema checker: %v", err)
}
case "delete-migrations":
if err := os.Remove(filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "migrations.go")); err != nil {
t.Fatalf("remove candidate migrations implementation: %v", err)
}
case "symlink-ledger":
ledger := filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-flag-migrations-v1.json")
if err := os.Remove(ledger); err != nil {
t.Fatalf("remove candidate ledger: %v", err)
}
if err := os.Symlink("../../../CANONICAL_EMPTY", ledger); err != nil {
t.Fatalf("symlink candidate ledger: %v", err)
}
schemaWriteFile(t, filepath.Join(fixtureRoot, "CANONICAL_EMPTY"), "{\"version\":1,\"migrations\":[]}\n", 0o644)
case "forge-alias-token":
schemaWriteFile(t,
filepath.Join(fixtureRoot, "internal", "evil", "forged.go"),
"package evil\n\nconst forged = \"dws.compat.alias_of\"\n",
0o644,
)
case "symlink-helper-parent":
helperPath := filepath.Join(fixtureRoot, "internal", "interfacesnapshot")
alternatePath := filepath.Join(fixtureRoot, "internal", "interfacesnapshot-alt")
if err := os.Rename(helperPath, alternatePath); err != nil {
t.Fatalf("move candidate Schema helper behind ancestor symlink: %v", err)
}
if err := os.Symlink("interfacesnapshot-alt", helperPath); err != nil {
t.Fatalf("symlink candidate Schema helper parent: %v", err)
}
default:
t.Fatalf("unknown candidate mutation %q", test.candidateMutation)
}
schemaRun(t, fixtureRoot, "git", "add", ".")
schemaRun(t, fixtureRoot, "git", "commit", "-m", "candidate revision")
candidateRef := strings.TrimSpace(schemaRun(t, fixtureRoot, "git", "rev-parse", "HEAD"))
// 让调用者工作区前进到另一提交,并放入可自批的二进制与 ledger;显式
// candidate ref 必须隔离这些内容。
schemaWriteFile(t, filepath.Join(fixtureRoot, "cmd", "main.go"), schemaBinarySource("LIVE_WORKTREE_BINARY"), 0o644)
liveLedger := filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-flag-migrations-v1.json")
if err := os.Remove(liveLedger); err != nil && !os.IsNotExist(err) {
t.Fatalf("remove selected candidate ledger from live revision: %v", err)
}
schemaWriteFile(t, liveLedger, "{\"version\":1,\"migrations\":[{\"live\":true}]}\n", 0o644)
schemaRun(t, fixtureRoot, "git", "add", ".")
schemaRun(t, fixtureRoot, "git", "commit", "-m", "unselected live revision")
schemaWriteFile(t, filepath.Join(fixtureRoot, "dws"), "#!/bin/sh\nprintf '%s\\n' 'LIVE_WORKTREE_BINARY'\n", 0o755)
cmd := exec.Command(
"sh",
"scripts/policy/check-authoritative-schema-compatibility.sh",
"--base-ref", baseRef,
"--stable-ref", "v1.0.0",
"--candidate-ref", candidateRef,
)
cmd.Dir = fixtureRoot
cmd.Env = schemaIntegrationEnvironment(fixtureRoot)
output, runErr := cmd.CombinedOutput()
got := string(output)
if runErr == nil {
t.Fatalf("Schema compatibility script unexpectedly succeeded; output:\n%s", got)
}
for _, forbidden := range []string{"CANDIDATE_NOOP", "LIVE_WORKTREE_BINARY"} {
if strings.Contains(got, forbidden) {
t.Fatalf("candidate-controlled authority %q took effect; output:\n%s", forbidden, got)
}
}
if test.authorityMarker != "" {
if !strings.Contains(got, test.authorityMarker) {
t.Fatalf("Schema compatibility script did not use base authority %q; output:\n%s", test.authorityMarker, got)
}
if test.baseGovernance == "complete" && strings.Count(got, "BASE_INTERFACE_HELPER_GENERATE=BASE") != 3 {
t.Fatalf("governed Schema check did not generate three base-owned interface snapshots; output:\n%s", got)
}
wrongStable := exec.Command(
"sh",
"scripts/policy/check-authoritative-schema-compatibility.sh",
"--base-ref", baseRef,
"--stable-ref", "HEAD",
"--candidate-ref", candidateRef,
)
wrongStable.Dir = fixtureRoot
wrongStable.Env = schemaIntegrationEnvironment(fixtureRoot)
wrongOutput, wrongErr := wrongStable.CombinedOutput()
if wrongErr == nil || !strings.Contains(string(wrongOutput), "is not the expected highest GA tag") {
t.Fatalf("arbitrary Schema stable ref was not rejected: err=%v output:\n%s", wrongErr, wrongOutput)
}
return
}
if !strings.Contains(got, test.want) {
t.Fatalf("Schema compatibility script output missing %q:\n%s", test.want, got)
}
}
func schemaRepositoryRoot(t *testing.T) string {
t.Helper()
_, sourceFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("resolve test source path")
}
root := filepath.Clean(filepath.Join(filepath.Dir(sourceFile), "..", "..", ".."))
if _, err := os.Stat(filepath.Join(root, "scripts", "policy", "check-authoritative-schema-compatibility.sh")); err != nil {
t.Fatalf("resolve repository root %s: %v", root, err)
}
return root
}
func schemaCopyFile(t *testing.T, source, destination string, mode os.FileMode) {
t.Helper()
content, err := os.ReadFile(source)
if err != nil {
t.Fatalf("read %s: %v", source, err)
}
schemaWriteFile(t, destination, string(content), mode)
}
func schemaWriteFile(t *testing.T, path, content string, mode os.FileMode) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("create parent for %s: %v", path, err)
}
if err := os.WriteFile(path, []byte(content), mode); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
func schemaRun(t *testing.T, dir, name string, args ...string) string {
t.Helper()
cmd := exec.Command(name, args...)
cmd.Dir = dir
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("%s %s failed: %v\n%s", name, strings.Join(args, " "), err, output)
}
return string(output)
}
func schemaIntegrationEnvironment(fixtureRoot string) []string {
overrides := map[string]string{
"DWS_BIN": filepath.Join(fixtureRoot, "dws"),
"DWS_POLICY_TMPDIR": "",
"GOCACHE": filepath.Join(fixtureRoot, ".cache", "go-build"),
"GOMODCACHE": filepath.Join(fixtureRoot, ".cache", "go-mod"),
"GOTMPDIR": "",
"GOTOOLCHAIN": "local",
"GOWORK": "off",
}
environment := make([]string, 0, len(os.Environ())+len(overrides))
for _, item := range os.Environ() {
key, _, _ := strings.Cut(item, "=")
if _, replaced := overrides[key]; !replaced {
environment = append(environment, item)
}
}
for key, value := range overrides {
environment = append(environment, key+"="+value)
}
return environment
}
+3 -2
View File
@@ -40,9 +40,10 @@ coverage:
- mono: drive
multi_skill: dingtalk-drive
- mono: event
multi_skill: dingtalk-misc
multi_skill: dingtalk-event
multi_refs:
- references/event.md
- references/event-im.md
- references/event-oa.md
- mono: hrbrain
multi_skill: dingtalk-misc
multi_refs:
+6 -4
View File
@@ -1,6 +1,6 @@
---
name: dws
description: 管理钉钉产品能力(AI表格/AI搜问/日历/通讯录/群聊与机器人/待办/审批/考勤/日志/DING消息/开放平台文档/钉钉文档/钉钉云盘/原生Markdown文件/AI听记/邮箱/在线电子表格/知识库等)。当用户需要操作表格数据、管理日程会议、模糊找人/查谁负责某事项、查询通讯录、管理群聊、机器人发消息、创建待办、提交审批、查看考勤、提交日报周报(钉钉日志模版)、读写钉钉文档、上传下载云盘文件、读取或修改原生.md文件、查询听记纪要、收发邮件、读写在线电子表格(axls)、管理钉钉知识库,或订阅个人 IM 事件、实时监听群成员加入、群成员退出、群改名和群解散时使用。
description: 管理钉钉产品能力(AI表格/AI搜问/日历/通讯录/群聊与机器人/待办/审批/考勤/日志/DING消息/开放平台文档/钉钉文档/钉钉云盘/原生Markdown文件/AI听记/邮箱/在线电子表格/知识库等)。当用户需要操作表格数据、管理日程会议、模糊找人/查谁负责某事项、查询通讯录、管理群聊、机器人发消息、创建待办、提交审批、查看考勤、提交日报周报(钉钉日志模版)、读写钉钉文档、上传下载云盘文件、读取或修改原生.md文件、查询听记纪要、收发邮件、读写在线电子表格(axls)、管理钉钉知识库,或订阅个人 IM 事件或 OA 审批事件、实时监听群成员加入、群成员退出、群改名和群解散、审批实例发起/终止/完成,以及审批任务创建/完成/转交时使用。
cli_version: ">=1.0.15"
---
@@ -22,7 +22,7 @@ cli_version: ">=1.0.15"
- 单次批量操作不超过 30 条记录
- 所有命令必须**严格遵循**对应产品参考文档里面规定的参数格式(如:如果有参数值,则参数和参数值之间至少用一个空格隔开)
- **脚本只用于明确覆盖的复合任务**:[scripts/](./scripts/) 下的脚本可封装 AI 表格批量导入导出、AI 应用创建轮询、文档创建后写内容、钉盘目录树等流程;当公开 `+` Shortcut 已提供目标唯一解析、分页/部分失败 ledger 和确认语义时,优先 Shortcut。Chat 历史导出与机器人广播已完全下沉 Runtime,不再发布兼容脚本
- **实时个人消息事件例外**:用户要监听消息、订阅事件、自动回复消息或事件驱动 Agent 时,默认走 `dws event +listen-im ...`;只有明确需要多个原始 EventKey、Filter DSL、subscribe_id 或原始 envelope 时才用 `dws event consume ... --flatten`,不要写脚本轮询消息历史
- **实时个人事件例外**:普通 IM 消息、reaction、已读和撤回默认走 `dws event +listen-im ...`;OA 审批、群生命周期、明确的原始 EventKey、Filter DSL、subscribe_id 或原始 envelope 使用 `dws event consume ... --flatten`。不要写脚本轮询消息历史或审批列表
## Shortcut 与原子命令的使用原则
@@ -50,7 +50,7 @@ cli_version: ">=1.0.15"
| `contact` | 14 | `dingtalk-contact` |
| `devapp` | 19 | `dingtalk-misc` |
| `ding` | 4 | `dingtalk-misc` |
| `doc` | 41 | `dingtalk-doc` |
| `doc` | 45 | `dingtalk-doc` |
| `drive` | 7 | `dingtalk-drive` |
| `mail` | 10 | `dingtalk-mail` |
| `minutes` | 6 | `dingtalk-minutes` |
@@ -94,7 +94,7 @@ cli_version: ">=1.0.15"
| `todo` | 待办:创建(含优先级/截止时间/循环)/查询/修改/标记完成/删除 | [todo.md](./references/products/todo.md) |
| `wiki` | 知识库:空间创建/详情/列表/搜索 + 成员管理 + 知识库动态查询 | [wiki.md](./references/products/wiki.md) |
| `whiteboard` | 文档内嵌白板:读取 OpenNodes、追加节点、整页重建 | [whiteboard.md](./references/products/whiteboard.md) |
| `event` | 个人 IM 事件:监听消息接收、指定发送人、已读、撤回、表情回应,NDJSON 输出(实时驱动 Agent)| [event.md](./references/products/event.md) |
| `event` | 个人 IM/OA 事件:监听消息、群生命周期、审批任务与审批实例事件,NDJSON 输出(实时驱动 Agent)| [event.md](./references/products/event.md) |
## 意图判断决策树
@@ -122,6 +122,7 @@ cli_version: ">=1.0.15"
用户提到"创建知识库/知识库列表/搜索知识库空间/wiki/团队空间/知识库成员管理/我的文档个人空间" → `wiki`
用户提到"文档内嵌白板/画布/OpenNodes/白板节点/连接线/整页重建白板" → `whiteboard`;创建空白板卡片先走 `doc whiteboard insert`
用户提到"监听有人@我/监听单聊或群消息/监听所有单聊或群消息/监听某人发送的消息/监听消息已读/监听消息撤回/监听消息贴表情或表情回应/订阅个人 IM 事件/实时接收钉钉事件/监听并自动回复消息/驱动 Agent 处理消息" → `event +listen-im`;群成员加入/退出、群改名/解散或明确原始 EventKey/Filter DSL → `event consume`
用户提到"监听待我审批的任务/监听审批任务创建、完成或转交/监听审批单发起或终止/监听我发起的审批完成/监听审批实例完成/订阅 OA 事件/event consume user_oa_approval_*" → `event consume`
普通消息、reaction、已读、撤回监听优先由一个 `dws event +listen-im` 进程表达目标;不同用户、不同群或不同过滤条件拆成独立进程。只有高级事件控制才生成 `dws event consume <event_key> [event_key...] --flatten`。
@@ -131,6 +132,7 @@ cli_version: ">=1.0.15"
关键区分: doc(在线富文本文档/adoc) vs markdown(原生 .md 纯文本文件) vs drive(通用文件存储与传输)
关键区分: contact(基础通讯录档案:学历/家庭/银行卡/紧急联系人/合同/部门角色) vs aisearch person(通用语义找人:谁负责/上级/下级/多维度模糊搜索) vs hrbrain(人才池/员工档案专项模块数据/职业历程/绩效/结构化高级人才搜索)
关键区分: oa tasks(审批 taskId,审批/拒绝用) vs oa list-pending(收件箱 processInstanceId,查看用)
关键区分: oa(查询或操作审批) vs event user_oa_approval_*(当前用户审批事件长连接监听)
> 更多易混淆场景见 [intent-guide.md](./references/intent-guide.md)
+19 -33
View File
@@ -1,27 +1,20 @@
# 文档 (doc) 命令参考
## 查询命令帮助
## 路由与帮助预算
当你不确定某个命令的具体参数、格式或可选项时,**优先执行 `--help` 查询**,不要猜测参数名或凭记忆编造。
已知精确命令和参数时直接执行,禁止预调用 Help。参数语义不确定时先查精确 leaf Schema;真实返回 `unknown flag` 后最多读取一次该 leaf Help。`unknown command` 只执行一次 `dws shortcut list --service doc --format json`,禁止试探候选后缀或执行产品级 `dws doc --help | grep/head`。
本页已给出精确命令且参数足够时,禁止读取 reference 子页。只有复杂 JSONML、block/划词评论高级参数、分页或部分成功恢复仍无法由本页和精确 leaf Schema处理时,才最多读取一个对应子 reference;普通创建、全文读取、append/overwrite、导出和导入直接执行。
版本快照、列表和恢复的 Agent 主入口固定为:
```bash
# 查看 doc 下所有子命令
dws doc --help
# 查看具体命令的完整参数说明
dws doc read --help
dws doc create --help
dws doc block insert --help
# 查看子命令组下的所有命令
dws doc block --help
dws doc permission --help
dws doc +version-save --node <DOC_ID>
dws doc +version-list --node <DOC_ID> --limit 20
dws doc +version-revert --node <DOC_ID> --version <N>
```
规则:
- 参数名不确定时 → 先 `--help`,再调用
- 报错 "unknown flag" 时 → `--help` 确认正确的 flag 名称
- 不确定某个功能是否存在时 → `dws doc --help` 查看命令列表
`+history-*` 仅兼容已有调用,不用于新的 Agent 选路。
## 命令总览
@@ -1005,26 +998,25 @@ dws doc read --node "https://alidocs.dingtalk.com/document/preview?cid=749936706
| `--content -` | 从 stdin 读取(可配合 heredoc/pipe) |
| `--content-file path` | 从文件读取(UTF-8),推荐 |
### 短/中等长度(< 200KB)— 单步创建
### 创建并写入 — Runtime 自动分片
```bash
# 1. 把内容写入 UTF-8 文本文件:
# Linux/Mac: /tmp/<name>.md;Windows: %TEMP%\<name>.md
# 2. 一步创建+写入:
dws doc create --name "<文档名>" --content-file <tmp> [--folder <ID>] [--workspace <ID>]
# 2. 一步创建、必要时自动分片并回读验证:
dws doc +create --name "<文档名>" --content @<工作目录相对文件> [--folder <ID>] [--workspace <ID>]
```
### 超长(> 200KB 兜底)— 创建空文档 + 分片追加
`+create` 会按安全边界拆分长 Markdown,首片创建、后续片追加,每个写调用最多执行一次。若返回 `partial_success` 或 `unknown`,先按返回的 `nodeId/steps` 回读文档,不得重跑整个创建。
### 更新并验证
```bash
# 1. 创建空文档拿 nodeId
dws doc create --name "<文档名>" [--folder/--workspace] # → nodeId
# 2. 按 markdown 标题或段落边界切成 ≤200KB 的片段(不要切断表格)
# 3. 逐个追加:
dws doc update --node <nodeId> --content-file <part> --mode append
dws doc +update --node <nodeId> --command append --content @<工作目录相对文件>
```
Runtime 自动分片并在最后回读验证。重要覆盖使用 `+checkpoint-update`;不要自行编写重试循环,创建和追加在超时后可能已经提交。
### stdin 变体
```bash
@@ -1085,12 +1077,6 @@ dws doc media upload --node <DOC_ID> --file ./icon.svg \
media upload 返回的 `resourceId` / `resourceUrl` 只能用于同一 nodeId 下的白板
Vector/SVG。完整协议见 [whiteboard.md](./whiteboard.md)。
## 自动化脚本
| 脚本 | 场景 | 用法 |
|------|------|------|
| [doc_create_and_write.py](../../scripts/doc_create_and_write.py) | 创建文档并写入 Markdown 内容 | `python doc_create_and_write.py --name "周报" --content "# 本周总结"` |
## 相关产品
- [aitable](./aitable.md) — 结构化数据表格(行列/字段/记录),不是富文本文档
+50 -13
View File
@@ -1,6 +1,6 @@
# dws event — 个人 IM 事件
# dws event — 个人 IM 与 OA 审批事件
通过个人 Stream 长连接监听当前用户的钉钉消息接收、已读、撤回、表情回应和群生命周期事件,NDJSON 输出到 stdout,用于驱动事件触发的 Agent。普通 IM 监听默认使用 `dws event +listen-im`;群生命周期、显式 EventKey、Filter DSL、subscribe_id 复用或原始 envelope 才使用 `dws event consume`。不要写脚本轮询消息历史。
通过个人 Stream 长连接监听当前用户的钉钉消息接收、已读、撤回、表情回应、群生命周期,以及审批任务和审批实例事件,NDJSON 输出到 stdout,用于驱动事件触发的 Agent。普通 IM 监听默认使用 `dws event +listen-im`;OA 审批、群生命周期、显式 EventKey、Filter DSL、subscribe_id 复用或原始 envelope 使用 `dws event consume`。不要写脚本轮询消息历史或审批列表。
## 运行方式
@@ -14,6 +14,7 @@
| Command | Purpose |
|---|---|
| `dws event +listen-im --kind ... [flags]` | 普通 message/reaction/read/recall 监听;支持自然姓名/群名唯一解析 |
| `dws event list --category oa` | 查看当前公开的 OA 个人事件目录 |
| `dws event schema <event_key> --flatten` | 查看 Agent 使用的顶层业务字段 schema |
| `dws event consume <event_key> [event_key...] --flatten [flags]` | 高级入口:阻塞消费显式 EventKey,事件写到 stdout,用 `-f ndjson` |
| `dws event status --event <event_key>` | 查看个人订阅、bus、本地 consume |
@@ -42,8 +43,14 @@
| `user_im_group_member_added` | 指定群聊有成员加入 | `--group` |
| `user_im_group_member_exited` | 指定群聊有成员退出 | `--group` |
| `user_im_group_disbanded` | 指定群聊被解散 | `--group` |
| `user_oa_approval_task_created` | 审批任务创建,发送给审批人 | 无 |
| `user_oa_approval_task_finished` | 审批任务已完成 | 无 |
| `user_oa_approval_task_redirected` | 审批任务已转交 | 无 |
| `user_oa_approval_instance_started` | 审批实例已发起 | 无 |
| `user_oa_approval_instance_terminated` | 审批实例已终止 | 无 |
| `user_oa_approval_instance_finished` | 审批实例完成,发送给审批单发起人 | 无 |
只承认上表 16 个事件码。默认身份就是当前用户,不要额外加身份切换 flag。
只承认上表 22 个事件码。默认身份就是当前用户,使用当前用户 OAuth 登录态,不要额外加身份切换 flag。六个 OA 事件订阅当前用户相关的全部审批事件,规则均为 `all`、空 `filterRule`,不需要目标参数。
## Intent mapping
@@ -65,20 +72,27 @@
| "监听并自动回复某人的单聊消息" | `+listen-im --kind sender --user-query "<姓名>"`;事件到达后把稳定 ID 交给 `chat +messages-send` |
| "同时监听同一人的消息、已读和撤回" | 一个 `+listen-im --kind sender --events message,read,recall` 生命周期 |
| "同时监听同一群的消息、改名和解散" | 生命周期事件不在 Shortcut 范围;一个高级 consume 放入 3 个 EventKey,共享 `--group` |
| "查看个人消息事件 schema" | `dws event schema <event_key> --flatten` |
| "监听新的待我审批任务" / "有审批任务创建时通知我" | `event consume`,事件码 `user_oa_approval_task_created`,参数 `--flatten -f ndjson` |
| "审批任务完成时通知我" | `event consume`,事件码 `user_oa_approval_task_finished`,参数 `--flatten -f ndjson` |
| "审批任务被转交时通知我" | `event consume`,事件码 `user_oa_approval_task_redirected`,参数 `--flatten -f ndjson` |
| "有审批单发起时通知我" | `event consume`,事件码 `user_oa_approval_instance_started`,参数 `--flatten -f ndjson` |
| "有审批单终止时通知我" | `event consume`,事件码 `user_oa_approval_instance_terminated`,参数 `--flatten -f ndjson` |
| "监听我发起的审批何时完成" / "审批实例完成时通知我" | `event consume`,事件码 `user_oa_approval_instance_finished`,参数 `--flatten -f ndjson` |
| "同时监听全部已公开 OA 事件" | 一个 consume 放入六个 OA event key,不加目标或消息过滤参数 |
| "查看个人事件 schema" | `dws event schema <event_key> --flatten` |
| "看个人事件订阅状态" | `dws event status --event <event_key>` |
| "停止这个个人事件订阅" | `dws event stop <subscribe_id> --dry-run`,确认后改用 `--yes` |
多候选让用户确认。缺必填 ID 且解析不出先追问,不要猜。企业内部 userId 使用 `--user`;明确给出 openDingtalkId,或目标是外部联系人、机器人、跨组织身份时使用 `--open-dingtalk-id`。两者严格二选一,不得混填、猜测或自动转换身份类型。
“我和某人的单聊”使用 `receive_o2o`;“某人发给我的消息/某人发送的消息”使用 `receive_user`,后者覆盖该发送人的单聊和群聊消息。只有明确说“所有”时才使用 `receive_o2o_all/receive_group_all`,指定对象仍使用范围更小的事件。用户要求执行“撤回消息”时走 `dws chat`;只有“监听/订阅消息撤回”才走 `dws event`。“贴标签”表示给消息贴表情时,对应 `reaction` 表情回应事件。
“我和某人的单聊”使用 `receive_o2o`;“某人发给我的消息/某人发送的消息”使用 `receive_user`,后者覆盖该发送人的单聊和群聊消息。只有明确说“所有”时才使用 `receive_o2o_all/receive_group_all`,指定对象仍使用范围更小的事件。用户要求执行“撤回消息”时走 `dws chat`;只有“监听/订阅消息撤回”才走 `dws event`。“贴标签”表示给消息贴表情时,对应 `reaction` 表情回应事件。OA 事件不接受 `--user`、`--open-dingtalk-id`、`--group`、`--query` 或 `--filter-json`。
## Call flow
1. 普通 IM 意图选择 `+listen-im` 的 `--kind` 与 `--events`;人名/群名直接用 `--user-query`/`--chat-query` 唯一解析。高级事件控制才手选 EventKey。
1. 普通 IM 意图选择 `+listen-im` 的 `--kind` 与 `--events`;人名/群名直接用 `--user-query`/`--chat-query` 唯一解析。OA 审批与高级事件控制手选 EventKey。
2. 需要了解字段时运行 `dws event schema <event_key> --flatten`,读取 `schema.properties`;此模式的 `jq_root_path` 为 `.`。
3. 普通任务启动 `dws event +listen-im ... -f ndjson`;高级任务启动 `dws event consume <event_key> [event_key...] ... --flatten -f ndjson`。等待真实 ready marker;多事件记录每条 subscription,再等待整体 ready。不要用 `sleep` 猜测。
4. stdout 每行是一个扁平事件 JSON;消息、动作及群成员加入/退出事件读取顶层业务字段。群标题变更和群解散只读取公共字段和 `payload` 中实际存在的字段。
3. 普通 IM 启动 `dws event +listen-im ... -f ndjson`;OA 或高级任务启动 `dws event consume <event_key> [event_key...] ... --flatten -f ndjson`。等待真实 ready marker;多事件记录每条 subscription,再等待整体 ready。不要用 `sleep` 猜测。
4. stdout 每行是一个扁平事件 JSON;消息、动作、群成员加入/退出及 OA 审批事件读取顶层业务字段。群标题变更和群解散只读取公共字段和 `payload` 中实际存在的字段。
5. 需要确认监听状态时运行 `dws event status --event <event_key>`,查看 `Subscriptions` 和 `Consumers`。
6. 任务完成后优雅结束 consume;本次新建的订阅会自动取消。复用已有订阅或需要从外部主动取消时,先运行 `dws event stop <subscribe_id> --dry-run`,向用户确认后再以 `--yes` 执行;自测可在 consume 加 `--max-events` 或 `--duration` 自动退出。
@@ -112,6 +126,12 @@ dws event schema user_im_group_updated --flatten
dws event schema user_im_group_member_added --flatten
dws event schema user_im_group_member_exited --flatten
dws event schema user_im_group_disbanded --flatten
dws event schema user_oa_approval_task_created --flatten
dws event schema user_oa_approval_task_finished --flatten
dws event schema user_oa_approval_task_redirected --flatten
dws event schema user_oa_approval_instance_started --flatten
dws event schema user_oa_approval_instance_terminated --flatten
dws event schema user_oa_approval_instance_finished --flatten
```
```bash
@@ -133,6 +153,12 @@ dws event consume user_im_group_updated --group <openConversationId> --flatten -
dws event consume user_im_group_member_added --group <openConversationId> --flatten -f ndjson
dws event consume user_im_group_member_exited --group <openConversationId> --flatten -f ndjson
dws event consume user_im_group_disbanded --group <openConversationId> --flatten -f ndjson
dws event consume user_oa_approval_task_created --flatten -f ndjson
dws event consume user_oa_approval_task_finished --flatten -f ndjson
dws event consume user_oa_approval_task_redirected --flatten -f ndjson
dws event consume user_oa_approval_instance_started --flatten -f ndjson
dws event consume user_oa_approval_instance_terminated --flatten -f ndjson
dws event consume user_oa_approval_instance_finished --flatten -f ndjson
```
同一目标、同一过滤条件的兼容事件优先使用一个多事件命令:
@@ -153,9 +179,19 @@ dws event consume \
--group <openConversationId> \
--flatten \
-f ndjson
dws event consume \
user_oa_approval_task_created \
user_oa_approval_task_finished \
user_oa_approval_task_redirected \
user_oa_approval_instance_started \
user_oa_approval_instance_terminated \
user_oa_approval_instance_finished \
--flatten \
-f ndjson
```
用户类事件共享 `--user` 或 `--open-dingtalk-id`,群类事件共享 `--group`,无目标事件可加入任一组合。用户类与群类、不同目标或不同过滤条件要拆成多个进程。多事件共享 `--query` / `--filter-json` 时,所选事件必须全部是消息接收事件。
用户类事件共享 `--user` 或 `--open-dingtalk-id`,群类事件共享 `--group`,无目标 IM 事件可加入任一组合。用户类与群类、不同目标或不同过滤条件要拆成多个进程。六个 OA 事件可以同进程消费并共享 personal bus,但各自建立独立订阅。多事件共享 `--query` / `--filter-json` 时,所选事件必须全部是 IM 消息接收事件;OA 事件单独或组合消费都禁止使用这两个消息过滤参数。
上述所有 `*_o2o` 命令和 `user_im_message_receive_user` 都可将 `--user <userId>` 替换为 `--open-dingtalk-id <openDingtalkId>`,但两个参数不能同时使用。
@@ -180,7 +216,7 @@ dws event stop --all --yes
## 订阅创建失败与重试预算
以下约束适用于上表全部 16 个事件以及多事件命令中的每一项,只治理 `[event] ready` 之前的订阅创建;ready 之后的 Stream 断线由长连接重连机制处理。
以下约束适用于上表全部 22 个公开个人事件(16 个 IM + 6 个 OA)以及多事件命令中的每一项,只治理 `[event] ready` 之前的订阅创建;ready 之后的 Stream 断线由长连接重连机制处理。
- `0/2/1` 是 **Agent/host 编排约束**,不是 CLI 持久化硬总次数上限。每次 `dws event consume` 调用对每个逻辑订阅最多发送一次订阅创建 HTTP 请求,进程内不会自动重试。CLI 本地状态只持久化 `in_flight`、`cooldown`、`terminal_hold` 三种保护状态,不持久化或计算跨调用的 Agent/host 尝试次数。
- 解析人名或群名、执行 `event consume` 以及后续 `event status/stop` 必须使用同一个 `--profile`。不得把其它 profile 下解析出的 userId、openDingtalkId 或 openConversationId 直接带入当前 profile 的订阅。
@@ -210,6 +246,7 @@ dws event stop --all --yes
- 表情回应事件直接读取 `operator/operator_open_dingtalk_id/reaction_name/reaction_text/operation_type/operation_time`。
- 群成员加入/退出事件读取 `conversation_id/operator/operator_open_dingtalk_id/members/event_time`。`operator` 是执行操作的人,`members` 是本次加入或退出的成员数组,成员项包含 `nick/open_dingtalk_id`;系统操作或成员自行退出时操作人字段可能为空。
- 群标题变更和群解散当前只承诺 `type/event_id/timestamp/subscribe_id/payload`;以实际 `payload` 为准,不猜测群标题、操作者等字段。
- OA 事件读取顶层 `process_instance_id/process_code/title/status/create_time/event_time`;任务事件另有 `task_id`,完成、转交或终止事件按对应 schema 提供 `finish_time`,任务完成、任务转交和实例完成还提供 `result`。`status/result` 保留服务端实际值,不推断完整枚举;缺少稳定 ID 或 payload 非法时 stderr 会输出 warning,stdout 回退为原始 transport envelope。
- 图片、文件等媒体消息的 `content` 可能是可读描述;合并转发媒体的下载定位信息位于对应 `forward_messages[].content`。需要实际媒体文件时调用 `dws chat message download-media`。
- 正常动作事件输出不含内部 `payload/uid/corpid/clientId/filterSubId/bizid`;原始排查才使用 `-f raw` 或 `--debug-raw-events`。
- 自己发的消息不作为事件回来(`isSelfLoop` 过滤);自发验证会看到 0 事件,测试投递使用别人或机器人发消息。
@@ -225,6 +262,6 @@ dws event stop --all --yes
## Full reference
- multi skill: `skills/multi/dingtalk-misc/SKILL.md`(产品索引 → event)
- IM reference: `skills/multi/dingtalk-misc/references/event.md`
- IM task index: `skills/multi/dingtalk-misc/references/event-im.md`
- multi skill: `skills/multi/dingtalk-event/SKILL.md`
- IM task index: `skills/multi/dingtalk-event/references/event-im.md`
- OA reference: `skills/multi/dingtalk-event/references/event-oa.md`
-181
View File
@@ -1,181 +0,0 @@
#!/usr/bin/env python3
"""
在指定目录创建文档并写入 Markdown 内容(一键完成)
用法:
python doc_create_and_write.py \
--name "项目周报" \
--content "# 本周总结\n\n## 完成事项\n- 任务A"
python doc_create_and_write.py \
--name "会议纪要" \
--content-file notes.md
python doc_create_and_write.py \
--name "知识库文档" --content "# 内容" --folder FOLDER_ID
python doc_create_and_write.py --name "test" --content "hello" --dry-run
"""
import sys
import json
import time
import subprocess
import argparse
from pathlib import Path
from typing import List, Any, Optional
def run_dws(
args: List[str], dry_run: bool = False,
) -> Optional[Any]:
cmd = ['dws'] + args
if dry_run:
print(f"[dry-run] {' '.join(cmd)}")
return {'dry_run': True}
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=60
)
if result.returncode != 0:
print(f" ✗ 错误:{result.stderr.strip()}")
return None
return json.loads(result.stdout)
except (subprocess.TimeoutExpired, json.JSONDecodeError,
FileNotFoundError) as e:
print(f" ✗ 错误:{e}")
return None
def run_dws_with_retry(
args: List[str],
dry_run: bool = False,
max_retries: int = 3,
retry_delay: float = 1.0,
) -> Optional[Any]:
"""带重试机制的 dws 命令执行"""
last_error = None
for attempt in range(1, max_retries + 1):
result = run_dws(args, dry_run=dry_run)
if result is not None:
return result
if attempt < max_retries:
print(f" ⚠️ 第 {attempt} 次尝试失败,{retry_delay}秒后重试...")
time.sleep(retry_delay)
retry_delay *= 1.5 # 指数退避
return None
def main():
parser = argparse.ArgumentParser(
description='创建文档并写入内容'
)
parser.add_argument('--name', required=True, help='文档名称')
parser.add_argument('--content', default='', help='Markdown 内容')
parser.add_argument('--content-file', default='', help='内容文件')
parser.add_argument('--folder', default='', help='目标文件夹 ID 或 URL')
parser.add_argument('--workspace', default='', help='目标知识库 ID')
parser.add_argument(
'--mode', default='append', choices=['overwrite', 'append'],
help='写入模式: overwrite=覆盖, append=追加 (默认 append)',
)
parser.add_argument(
'--max-retries', type=int, default=3,
help='每块写入失败时的最大重试次数 (默认 3)',
)
parser.add_argument('--dry-run', action='store_true')
args = parser.parse_args()
content = args.content
if args.content_file:
p = Path(args.content_file)
if not p.exists():
print(f"错误:文件不存在: {p}")
sys.exit(1)
content = p.read_text(encoding='utf-8')
if not content:
print('错误:需要 --content 或 --content-file')
sys.exit(1)
chunk_size = 30000
create_args = ['doc', 'create', '--name', args.name, '--format', 'json']
if args.folder:
create_args.extend(['--folder', args.folder])
if args.workspace:
create_args.extend(['--workspace', args.workspace])
print(f'\n📝 创建文档: {args.name}')
create_data = run_dws(create_args, dry_run=args.dry_run)
node_id = None
if not args.dry_run:
if not create_data:
sys.exit(1)
node_id = (create_data.get('nodeId')
or create_data.get('dentryUuid')
or create_data.get('id', ''))
print(f" ✓ 文档已创建 (ID: {node_id})")
if len(content) <= chunk_size:
mode_label = '追加' if args.mode == 'append' else '覆盖'
print(f'\n✍️ 写入内容 (模式: {mode_label}, {len(content)} 字符)...')
write_data = run_dws([
'doc', 'update',
'--node', node_id or '<NODE_ID>',
'--content', content,
'--mode', args.mode,
'--format', 'json',
], dry_run=args.dry_run)
if write_data:
print(f" ✓ 内容已写入 ({len(content)} 字符)")
else:
chunks = []
pos = 0
while pos < len(content):
end = min(pos + chunk_size, len(content))
if end < len(content):
newline_pos = content.rfind('\n', pos, end)
if newline_pos > pos:
end = newline_pos + 1
chunks.append(content[pos:end])
pos = end
total_chunks = len(chunks)
print(f'\n✍️ 内容较长 ({len(content)} 字符), 分 {total_chunks} 块写入...')
success_chunks = 0
for idx, chunk in enumerate(chunks):
chunk_mode = args.mode if idx == 0 else 'append'
write_data = run_dws_with_retry(
[
'doc', 'update',
'--node', node_id or '<NODE_ID>',
'--content', chunk,
'--mode', chunk_mode,
'--format', 'json',
],
dry_run=args.dry_run,
max_retries=args.max_retries,
)
if write_data:
print(f" ✓ 块 {idx + 1}/{total_chunks} 已写入 ({len(chunk)} 字符)")
success_chunks += 1
elif not args.dry_run:
# 写入失败,报告部分写入状态
print(f"\n❌ 块 {idx + 1}/{total_chunks} 写入失败(已重试 {args.max_retries} 次)")
print(f"\n⚠️ 文档处于部分写入状态:")
print(f" - 文档 ID: {node_id}")
print(f" - 已写入: {success_chunks}/{total_chunks} 块")
print(f" - 失败位置: 第 {idx + 1} 块")
if args.mode == 'overwrite':
print(f" - 模式: 覆盖模式,文档可能包含不完整内容")
print(f" - 建议: 手动检查文档内容,或删除后重新创建")
else:
print(f" - 模式: 追加模式,已写入内容已保存")
print(f" - 建议: 可手动补充剩余内容,或重新运行脚本")
sys.exit(1)
print('\n✅ 完成!')
if __name__ == '__main__':
main()
+2 -2
View File
@@ -52,7 +52,7 @@ metadata:
| 查看全部会话 | `dws chat +conversation-list --page-all` | 检查 `complete` / `failures` |
| 读取并下载消息资源 | 查询命令加 `--download-resources` | 不另起手工下载循环;下载失败项保留在结果中 |
| <!-- dws-intent: chat.conversation.list-top -->查看置顶会话 | `dws chat +conversation-list-top` | 会话 Top 与消息 Pin、消息 Top、Favorite 不同 |
| 监听 IM 事件 | 切换 `dingtalk-misc` → [event.md](../dingtalk-misc/references/event.md) | 由事件参考选择确定的 EventKey 和生命周期 |
| 监听未来 IM 事件 | [`dingtalk-event`](../dingtalk-event/SKILL.md) | 常规监听走 `+listen-im`;生命周期/高级控制走 `consume` |
以下次级入口在意图明确时直接使用,不需要先加载完整 Catalog:
@@ -75,7 +75,7 @@ metadata:
- `+messages-send`:文件、Bot、Webhook、复杂 @ 或幂等控制。user 已知 ID 可直接传,也可用 `--user-query` / `--chat-query` 运行同一只读解析链;Bot 多群使用 `--groups/--groups-file`,返回 `im.batch-write.v1`;bot/webhook 只使用下层真实支持的文本/Markdown 能力。
- 文件直接传 `+messages-send --file <相对路径>`;不要先独立上传并提取 mediaId。
- Webhook 使用 `+messages-send --as webhook --webhook-token <token>`;不要退回原子 Webhook 命令。
- 流式卡片不是普通消息内容,使用 `+messages-send-card`;当前只支持 streaming text create/update,不支持 Card JSON 或 callback。
- 流式卡片用 `+messages-send-card`;群聊@传 ID/`--at-all`,Runtime 把 create 返回前缀加到 `--content`;禁写占位符;仅 text。
## 关键结果语义
@@ -7,9 +7,18 @@ Runtime 会唯一解析为 openDingTalkId;已有 openDingTalkId 时传
- 只传目标:创建卡片并从真实结果取得 `bizId`,供后续更新。
- 同时传 `--content`:Runtime 串行执行 create → 从返回提取 `bizId` → update;默认
`--flow-status 3`。
- 群聊可传 `--at-open-dingtalk-ids` 或 `--at-all`;艾特对象只进入初始
`create_and_send_card`。同一次调用带 `--content` 时,Runtime 将 create 返回的
`atTag` 自动加在正文前,再调用 `update_streaming_card`;调用方不要拼 ID
或艾特占位符。
- `--dry-run` 仍执行只读 userId 解析,只输出两步计划,不执行写入。
创建成功但自动更新失败时,错误会保留真实 `bizId`。不要重复创建;使用该 `bizId` 继续
`+messages-update-card` 或人工处理。
当前内容仅为 streaming text,不接受 Lark Card JSON、组件树或按钮 callback。
```bash
dws chat +messages-send-card --group <openConversationId> --at-open-dingtalk-ids <mentionedOpenDingTalkId> --content "请确认"
dws chat +messages-send-card --group <openConversationId> --at-all --content "请大家确认"
```
@@ -286,12 +286,14 @@ dws chat message reply --conversation-id <openConversationId> --ref-msg-id <open
流式卡片优先使用公开 Shortcut;创建可选在同一次调用中写入内容:
```bash
dws chat +messages-send-card --group <openConversationId> --content "开始处理" --flow-status 1
dws chat +messages-send-card --group <openConversationId> --at-open-dingtalk-ids <mentionedOpenDingTalkId> --content "开始处理" --flow-status 1
dws chat +messages-update-card --biz-id <bizId> --content "更新的卡片内容" --flow-status 2
dws chat +messages-update-card --biz-id <bizId> --content "最终内容" --flow-status 3
```
`flow-status`:1=处理中,2=输入中,3=完成,4=执行中,5=错误,Runtime 拒绝范围外值。
群聊还可传 `--at-all`;两种艾特参数只随创建请求发送。send-card 同时带正文时,
Runtime 会把创建响应的 `atTag` 自动放在正文前;不要自行写 ID 或占位符。
当前只支持 streaming text;不支持 Card JSON 组件或 action callback。精确边界见
[card references](../card/schema.md)。
+72 -156
View File
@@ -1,6 +1,6 @@
---
name: dingtalk-doc
description: 钉钉文档(adoc):创建、读取、编辑、块、评论、附件、白板卡片、导出、版本及Markdown/JSONML写入。原生 .md→dingtalk-misc;文件→dingtalk-drive;知识库→dingtalk-wiki;axls→dingtalk-misc,able→dingtalk-aitable。
description: 钉钉在线文字文档(adoc)的查找、读取、创建、编辑、块、评论、附件、导入导出、模板、版本和权限协作。普通文件走 dingtalk-drive,知识库空间走 dingtalk-wiki。命令前缀:dws doc。
metadata:
cli_version: ">=0.2.14"
category: product
@@ -11,179 +11,95 @@ metadata:
# 钉钉文档 Skill
## 前置条件 — 执行操作前必读
<!-- DWS_RUNTIME_CONTRACT_START -->
## 最小 DWS 执行契约
> **CRITICAL — 执行任何 `dws` 操作前,MUST 先用 Read 工具完整读取 [`dingtalk-shared`](../dingtalk-shared/SKILL.md)。**该轻量文件包含全局执行契约、安全底线及 shared references 的按需加载导航;不要预加载其全部 references。
> 命令参考:[doc.md](references/doc.md);剧本:[04-document.md](references/04-document.md)。
## 参数硬约束
- 创建文档只用 `--name`,不要写 `--title`。
- 目标文件夹只用 `--folder <文档文件夹nodeId或URL>`,不要写 `--parent` / `--parent-node` / `--parent-id`。
- 目标知识库只用 `--workspace <workspaceId或URL>`,不要写 `--space-id` / `--spaceId`。
- 文档内容只用 `--content` / `--content-file`,不要写 `--markdown`。
- 复杂内容(换行、表格、代码块、长 Markdown)先写临时 `.md`,再用 `--content-file`,不要把大段 Markdown 塞进命令行。
- 每次 `create` / `update` / `block insert` / `media insert` 后必须 `dws doc read` 或 `dws doc block list` 回读关键内容。
- `doc whiteboard insert`、`doc media upload` 属于远端写入;必须先获得用户确认,再加 `--yes`。
- 只通过 `dws` CLI 操作钉钉;结构化读取使用 `--format json`,按真实返回判断结果。
- 已知命令直接执行。只有 leaf 参数或安全语义不确定时读取精确 Schema,只有 Cobra flag 不确定时读取精确 leaf Help;不要加载产品级 Catalog 代替选路。
- 不猜命令、flag、字段、ID、账号或时间。后续 ID 必须来自真实返回;零命中、多候选或类型不明时停止并消歧。
- 解析目标、读取上下文和最终执行必须使用同一 profile;不得跨组织复用 userId、openDingTalkId 或 openConversationId。多账号组织只使用明确的 `isOrgCurrent=true` 默认账号;没有默认账号时要求用户指定,禁止选择第一项、最近登录或最近使用账号。
- 不输出或记录 token、refresh token、appSecret、webhook token 等凭据;宿主已注入认证时不要索要凭据。
- 写操作必须符合用户明确意图。是否需要确认以最终 Runtime gate 和 Schema 为准;需要确认时先说明对象、动作与影响,再追加 `--yes`。
- 写后按任务结果契约验证;不能仅凭退出码宣称成功。部分结果、未知投递状态和失败项必须如实保留。
- 时间戳面向用户展示时转换为带时区的可读时间;默认使用当前会话时区,必要时同时保留原值。
- 遇到认证、权限、profile、confirmation 或未知错误时,只加载 `dingtalk-shared` 中对应 reference;不要连续猜测替代命令。
<!-- DWS_RUNTIME_CONTRACT_END -->
<!-- VISIBLE_SHORTCUTS_START -->
## Shortcuts(无专用脚本/recipe 时优先)
## Shortcut 发现(按需)
以下 shortcut 同时进入公开 catalog 与 Runtime Schema。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。命令已选中时直接执行;只在参数或安全语义不确定时读取 Agent leaf Schema(例如 `dws schema --cli-path "doc +<shortcut>" --compact --format json`),在当前 Cobra flags 不确定时读取 `dws doc <shortcut> --help`。只有参数映射、接口绑定或 provenance 审计才省略 `--compact`。仅当现有路由和 reference 都无法定位低频能力时,才用 `dws shortcut list --service doc --format json` 批量发现。
`doc` 当前有 45 条公开 shortcut,完整清单保留在 Runtime Catalog 与 Schema,不在高频产品根 Skill 中重复展开。已知意图按下方路由。
| Shortcut | 风险 | 适用场景 |
|---|---|---|
| `dws doc +access-change` | write | 预检已有协作者后变更文档角色 |
| `dws doc +access-grant` | write | 按姓名解析后批量授予文档权限 |
| `dws doc +access-revoke` | high-risk-write | 预检并移除指定协作者的文档权限 |
| `dws doc +background-delete` | write | 清除文档背景色 |
| `dws doc +background-update` | write | 设置文档 #RRGGBB 背景纯色 |
| `dws doc +checkpoint-update` | write | 先保存可回滚版本,再更新并读回验证 |
| `dws doc +comment-create` | write | 在文档上创建一条评论 |
| `dws doc +comment-delete` | high-risk-write | 永久删除指定文档评论 |
| `dws doc +comment-list` | read | 查询文档评论列表 |
| `dws doc +comment-reply` | write | 回复文档中的一条评论 |
| `dws doc +comment-update` | write | 更新指定文档评论正文和 mention |
| `dws doc +copy` | write | 复制文档/文件到指定文件夹或知识库 |
| `dws doc +create` | write | 从 Markdown 或 JSONML 创建在线文字文档 |
| `dws doc +create-from-template` | write | 按 templateId 直达或搜索消歧后创建文档 |
| `dws doc +doc-append` | write | 在文档末尾追加一段文本(安全追加,不改动原有内容) |
| `dws doc +export` | read | 提交、轮询并安全下载在线文档导出文件 |
| `dws doc +export-get` | read | 根据 jobId 查询文档导出任务结果 |
| `dws doc +export-submit` | read | 提交在线文档导出任务 (docx/markdown/pdf),返回 jobId |
| `dws doc +fetch` | read | 读取完整或局部文档内容,并按 detail 控制保真度 |
| `dws doc +find-doc` | read | 按关键词搜索云文档并投影关键字段(只读) |
| `dws doc +grant-and-share` | write | 确保目标角色后按姓名逐人发送文档链接 |
| `dws doc +import` | write | 上传本地文件并等待转换成在线文档对象;白名单外格式自动改走文件上传原样入库 |
| `dws doc +inspect` | read | 聚合文档元信息,并按需附带样式、权限、历史、媒体和评论 |
| `dws doc +list` | read | 列出文件夹或知识库下的直接子节点 |
| `dws doc +media-download` | read | 安全下载文档正文附件到工作目录 |
| `dws doc +media-insert` | write | 上传本地图片或文件并插入文档正文 |
| `dws doc +media-list` | read | 列出文档正文中的图片和附件资源 |
| `dws doc +media-preview` | read | 下载正文媒体到受控临时目录并返回预览路径 |
| `dws doc +move` | write | 移动文档/文件到指定文件夹或知识库 |
| `dws doc +resource-delete` | high-risk-write | 幂等清除文档封面 |
| `dws doc +resource-download` | read | 读取并安全下载当前文档封面 |
| `dws doc +resource-update` | write | 从本地图片或 HTTPS URL 设置文档封面 |
| `dws doc +review` | read | 聚合未解决评论、引用原文和块上下文 |
| `dws doc +search` | read | 按关键词搜索有权限的文档 (不传则返回最近访问) |
| `dws doc +share-doc` | write | 按姓名把文档链接私信发给某人(自动解析 userId) |
| `dws doc +template-list` | read | 获取文档模板列表 |
| `dws doc +template-search` | read | 根据关键词搜索文档模板 |
| `dws doc +update` | write | 追加、覆盖或按 block 精确更新文档内容 |
| `dws doc +version-list` | read | 查看文档历史版本列表 |
| `dws doc +version-revert` | high-risk-write | 回滚文档到指定历史版本 |
| `dws doc +version-save` | write | 手动保存文档版本快照 |
仅当现有路由和 reference 都无法定位低频能力时,才执行 `dws shortcut list --service doc --format json` 做最后回退;不要为已知高频意图加载完整 Shortcut Catalog 或产品级 Schema。
<!-- VISIBLE_SHORTCUTS_END -->
## 意图表
## Golden Route
| 用户说 | 命令 |
|--------|------|
| "创建文档(短内容)" | `dws doc create --name "<标题>" --content "<内容>"` |
| "创建+写入(长内容自动分块)" | `python scripts/doc_create_and_write.py --name "<标题>" --content "<内容>" [--mode append\|overwrite]` |
| "搜在线文字文档 / 找在线文字文档" | `dws drive search --query "<关键词>" --format json` → `dws drive info --node <nodeId> --format json` → 仅 `extension=adoc` 使用 `dws doc read --node <nodeId> --format json` |
| "读在线文字文档(adoc)内容" | `dws doc read --node <nodeId> --format json` |
| "更新文档内容 / 分块追加" | `dws doc update --node <nodeId> --content "<分块>" --mode append` |
| "删除块" | `dws doc block delete`(需用户确认) |
| "导出 docx / markdown / pdf" | `dws doc export --node <nodeId> --export-format <docx|markdown|pdf> --output <path>` |
| "导入本地文件为在线文档" | `dws doc import --file <path> --folder <FOLDER_NODE_ID> --name "<标题>" --format json`(详见 `references/doc/doc-import.md`) |
| "查模板 / 套用模板创建文档" | `dws doc template list|search|apply`(详见 `references/doc.md` 模板管理) |
| "保存 / 查看 / 回滚在线文字文档(adoc)版本" | `dws doc version save/list/revert` |
| "在文档里创建空白板" | `dws doc whiteboard insert --node <nodeId> --yes --format json` |
| "为白板上传 SVG/Vector 资源" | `dws doc media upload --node <nodeId> --file <path> --yes --format json` |
选择最小入口。ID/URL 直用;只有标题时先搜索,唯一候选后执行,禁止默认第一项。
## 标准 SOP(必遵流程)
| 用户意图 | 唯一推荐入口 | 关键边界 |
|---|---|---|
| 按标题或主题定位文档 | `dws doc +search --query <关键词>` | 检查候选类型与分页;需要正文时再用真实 `nodeId` 执行 `+fetch` |
| 已知 ID/URL 读取正文或局部内容 | `dws doc +fetch --node <ID或URL>` | `--scope keyword/section/range` 可减少无关正文;非 adoc 切换对应产品 |
| 聚合查看信息、权限、版本、媒体或评论 | `dws doc +inspect --node <ID或URL>` | 仅打开任务所需的 `--include-*`,不要默认全取 |
| 新建在线文字文档并写入内容 | `dws doc +create --name <标题> --content @<相对文件>` | 长或复杂 Markdown 使用 `@file`/stdin;根据结果验证真实 `nodeId` |
| 追加、覆盖或精确编辑 block | `dws doc +update --node <ID或URL> --command <动作>` | `--expected-revision` 仅用于 JSONML 原子覆盖;先消费 Schema confirmation |
| 重要内容更新且需要恢复点 | `dws doc +checkpoint-update` | 自动保存版本、更新并回读;检查 `steps` 和 `compensation` |
| 版本操作 | `dws doc +version-save --node` / `dws doc +version-list --node` / `dws doc +version-revert --node --version` | 快照/列表/回滚 |
| 导出为 docx/markdown/pdf | `dws doc +export --export-format <格式>` | 格式必须显式指定;普通文件下载切 `dingtalk-drive` |
| 导入本地文件为在线对象 | `dws doc +import --file <相对路径>` | folder/workspace 可选,缺省导入默认根;纯上传切 `dingtalk-drive` |
| 浏览模板 | `dws doc +template-list [--source MY\|PUBLIC]` | 没有名称/关键词时使用;不调用 search/create |
| 搜索模板 | `dws doc +template-search --query <名称或关键词>` | 有查询词才使用;零或多候选停止 |
| 从模板创建 | `dws doc +create-from-template --template-id <唯一ID>` | 已有唯一 templateId 才创建;不重复 list/search |
| 创建评论或聚合待处理评论 | `dws doc +comment-create [--selection]` / `+review` | 划词统一用 `+comment-create`;后续操作使用真实 `commentKey` |
| 添加/调整/移除协作者权限 | `dws doc +access-grant/+access-change/+access-revoke` | 先读取现有权限;姓名歧义或 profile 不一致时禁止写入 |
| 授权后向多人分享链接 | `dws doc +grant-and-share` | 返回逐人执行账本;部分失败不等于整体成功 |
| 插入或下载正文媒体 | `dws doc +media-insert/+media-download` | 本地路径必须位于工作目录;下载默认 no-clobber |
> 命中以下意图**必须**按对应 SOP 顺序执行;**禁止**跳步、替换命令、编造 nodeId/blockId。结构化命令必须带 `--format json`,执行后必须按"验证"步回读真实字段。文件类操作(上传/下载/复制/移动)切 `dingtalk-drive`;知识库节点管理切 `dingtalk-wiki`。
## 关键结果语义
### SOP-1 查找并读取文档(query-doc)
- 保留真实 `nodeId`、URL、资源类型和容器;标题不能替代稳定 ID。
- 按状态恢复:`partial_success` 只补未完成步骤;`unknown` 先回读且不重试写入;`retryable` 仅限明确未开始;权限、参数、认证失败停止。
- 仅在结果明确且关键内容回读匹配后报告写入完成。
- 搜索、列表和批量结果必须检查 `complete`、`hasMore`、continuation 和失败项;单页结果不得表述为完整集合。
- 导出/下载仅用工作目录相对路径,默认不覆盖并原子落盘。
**触发**:查文档/读文档/某文档在哪/搜文档内容。
## 参数与安全边界
1. **定位(必须)**:用户已提供 URL / `nodeId` 时直接使用原值;未提供目标时才执行 `dws drive search --query "<关键词>" --format json`,再取候选结果的真实 `nodeId`。
2. **探测(必须)**:对选中的候选执行 `dws drive info --node <nodeId> --format json`,从真实返回读取 `extension`;不得因为搜索结果标题像“文档”就跳过探测。
3. **按类型读取(必须)**:
- `extension=adoc`:`dws doc read --node <nodeId> --format json`;大文档只抽取用户需要的章节。
- `extension=md`:切到 `dingtalk-misc` 的 `references/markdown.md`,用 `dws markdown fetch --node <nodeId> --format json` 读取原文;仅需文件实体下载时切 `dingtalk-drive` 用 `drive download`。
- `extension=axls`:切到 `dingtalk-misc`,读取 `references/sheet.md` 后按电子表格意图执行。
- `extension=able`:切到 `dingtalk-aitable`。
- `extension=xlsx` / `xls` / `xlsm` / `csv` 或其他普通文件:切到 `dingtalk-drive`;不得执行 `dws doc read`。
- `@file` 统一协议:已有或临时文件先暂存到 cwd 后传 `@相对路径`;单次生成文本优先 `--content -` (stdin);禁止绝对路径和 `..`。
- `doc +update` 的动作由 `--command` 指定;block 操作的 ID 必须来自 `+fetch --detail with-ids` 或真实 block 列表。
- Schema 门禁:已知只读路由且参数明确时直接执行;写命令或 selection/参数不清、准备 Help 时,本轮仅查一次 `dws schema --cli-path "doc +<leaf>" --compact --fields use_when,avoid_when,parameters,constraints,confirmation -f json`;禁用产品级/`--all`。
- 执行前消费其中的 `confirmation`:`user_required` 且用户已确认同一目标/动作/参数才加 `--yes`,否则预览/询问;禁止靠失败探测门禁。
- JSONML 顶层必须是单个非空元素;禁止 `[[...]]` 元素数组包裹。
**禁止**:用户未提供目标时跳过搜索并猜 nodeId、未探测类型就执行 `doc read`、把整篇文档原样贴给用户。
## 按需加载
### SOP-2 创建文档并写入(create-doc)
Golden Route 已给出命令且参数足够时,禁止读取 reference。其余仅在下列语义不明确时,才最多读取一个 reference:
**触发**:新建文档/写一篇/建文字文档。
| 触发条件 | Reference |
|---|---|
| 低频/无 shortcut 意图消歧 | [intent-guide.md](references/intent-guide.md) / [doc.md](references/doc.md) 对应章节 |
| 分页、`partial_success`、`status=unknown` 或恢复 | [contracts.md](references/contracts.md) |
| 复杂 JSONML/非默认操作 | [create](references/doc/doc-create.md) / [read](references/doc/doc-read.md) / [update](references/doc/doc-update.md) |
| block/划词评论/媒体高级参数 | [block](references/doc/doc-block.md) / [comment](references/doc/doc-comment.md) / [media](references/doc/doc-media.md) |
| 导出/导入失败恢复 | [export](references/doc/doc-export.md) / [import](references/doc/doc-import.md) |
1. **执行(必须)**:`dws doc create --name "<标题>" --content-file <tmp.md> [--folder <FOLDER_NODE_ID> | --workspace <WORKSPACE_ID>] --format json`(长/多行内容用 `--content-file`,不要用 `--content` 拼长串;用户未指定位置时省略两个位置参数,创建到“我的文档”根目录)。
2. **验证(必须)**:从返回取 `nodeId`,立即 `dws doc info --node <nodeId> --format json` 回读确认。
`+create`、`+fetch`、`+update` append/overwrite、`+export`、`+import` 禁止读取 reference;禁止预加载或连读。
**禁止**:创建后不回读就答复"已创建"、把 `--folder` 当成空间 ID 传入。
## 错误最短路径
### SOP-3 覆盖/追加内容(write-content)
1. 零命中、多候选、类型不明或分页不完整:停止后续写入,展示候选或 continuation;禁止默认第一项。
2. Help 不参与选路;按上方门禁先消费一次精确 leaf Schema。只有真实 `unknown flag`/契约漂移后才查一次 leaf Help;`unknown command` 只查一次 shortcut 清单,禁止试探后缀和 `dws doc --help | grep/head`。
3. `REVISION_CONFLICT`:重新读取当前 revision,展示差异;未经用户确认不得改成无 revision 覆盖。
4. `partial_success`:保留已完成步骤,优先执行补偿或从 checkpoint 继续,不重放成功步骤。
5. `doc_write_commit_unknown`:先回读文档确认状态;禁止自动重试创建或追加。
6. 认证、权限或 profile 错误:只读取 `dingtalk-shared` 对应 reference;不要尝试同义底层命令绕过。
7. 导出或媒体失败:保留 `jobId/resourceId/nodeId` 后停止;禁止 `curl`、安装依赖、Python 文档库或手写 HTTP 兜底。
**触发**:覆盖写/追加内容/改文档正文。
## 跨产品边界
1. **执行(必须)**:覆盖先执行 `dws doc update --node <nodeId> --mode overwrite --content-file <tmp.md> --dry-run --format json` 预览,用户确认后改用 `--yes` 实际覆盖;追加执行 `dws doc update --node <nodeId> --mode append --content-file <tmp.md> --format json`。
2. **验证(必须)**:写后 `dws doc read --node <nodeId> --format json` 抽取受影响段落核对。
**禁止**:不加 `--yes` 反复重试覆盖、跳过 `--dry-run` 直接覆盖未确认的长文档。
### SOP-4 导出 / 下载(export-doc)
**触发**:导出文档/下载文档/转 PDF·Markdown。
1. **判类型(必须)**:先 `dws drive info --node <nodeId> --format json`;`extension=adoc` → `dws doc export --node <nodeId> --export-format <pdf|markdown|docx> --output <path> --format json`;普通文件 → 切 `dingtalk-drive` 用 `dws drive download --node <nodeId> --output <path> --format json`。
**禁止**:不分类型一律走 `doc export`(普通文件会失败)、跳过 `drive info` 判断。
### SOP-5 块级编辑(block-edit)
**触发**:插引用块/代码块/表格/分栏/图片/附件,或删除某块。
1. **先列块(必须)**:`dws doc block list --node <nodeId> --format json`,当前响应的可操作块 ID 位于 `blocks[].element.id`(部分版本可能回显为 `blockId`);必须从目标内容对应项读取,不得编造。空文档的占位空段落可能不能作为 `--ref-block`。
2. **按动作执行(必须)**:
- 插入:默认追加用 `dws doc block insert --node <nodeId> --text "<内容>" --format json`;只有明确要求相对位置时才加 `--ref-block <非空参照块ID> --where before|after`,容器内插入使用 `--parent-block <父块ID> --index <位置>`。插入命令**不接受** `--block-id`。
- 更新:`dws doc block update --node <nodeId> --block-id <目标blockId> --text "<新内容>" --format json`。
- 删除:用户确认后执行 `dws doc block delete --node <nodeId> --block-id <目标blockId> --yes --format json`。
3. **验证(必须)**:再次执行 `dws doc block list --node <nodeId> --format json` 核对插入、更新或删除结果。
4. **复杂块(必须)**:插入引用/代码/表格/分栏/附件/图片前,**必须**先读 [doc.md](references/doc.md) 对应小节,**禁止**只停在"准备查看 help"——说"我将插入..."后必须立即执行命令。
**禁止**:编造 blockId、未确认就删除、把完整 `--help` 输出当成最终结果答复用户。
### SOP-6 导入本地文件为在线文档(import-file)
**触发**:导入 Word / Excel / Markdown / 本地文件为在线文档。
1. **判类型(必须)**:确认用户意图是“导入为在线文档”,不是“上传到钉盘”。仅上传存储时切 `dingtalk-drive`。
2. **执行(必须)**:`dws doc import --file <path> --folder <FOLDER_NODE_ID> --name "<标题>" --format json`;复杂参数和限制见 [doc-import.md](references/doc/doc-import.md)。
3. **验证(必须)**:拿到返回 `nodeId` 后执行 `dws doc info --node <nodeId> --format json`,必要时 `dws doc read --node <nodeId> --format json` 抽样核对内容。
**禁止**:把上传文件到钉盘误当成 doc import;不知道目标文件夹 nodeId 时先切 `dingtalk-drive`/`dingtalk-wiki` 查询。
## 多步文档短路径
- 在目标文件夹创建文字文档:`dws doc create --name "<标题>" --folder <FOLDER_NODE_ID> --content-file <tmp.md> --format json`。拿到 `nodeId` 后立即回读。
- 块级编辑固定顺序:`doc block list --node <nodeId>` → 插入用 `--ref-block`/`--parent-block`,更新或删除用 `--block-id` → `doc block list` 验证。删除块必须已有用户明确删除意图或二次确认。
- 插入引用块、代码块、表格、分栏、附件、图片时,优先读 [doc.md](references/doc.md) 对应小节,不要只停在"准备查看 help"。说出"我将插入..."后必须立即执行对应 terminal 调用。
- 用户要求多个子文档/附件/块操作时,按 checklist 串行完成;最后一条 assistant 消息不能停在"接下来我要...",必须有实际工具调用或明确失败原因。
- 用户说“读取并下载/导出”时,先 `drive info --node ... --format json` 按
`extension` 判断类型:`adoc` 用 `doc export`,普通文件切到
`dingtalk-drive` 用 `drive download`。
- 所有结构化 dws 命令带 `--format json`。仅参数不确定时查 `--help`,不要把完整 help 当成最终结果。
## 危险操作
`block delete` 不可逆,必须确认再加 `--yes`。
## 跨产品协作
- 文件存储 / 上传下载 → 切到 `dingtalk-drive`
- 知识库空间管理 → 切到 `dingtalk-wiki`
- 数据表 → 切到 `dingtalk-aitable`
- 原生 `.md` 文件读取、创建、全量覆盖或局部替换 → 切到 `dingtalk-misc` 的 `references/markdown.md`
- 长篇报告生成(多源采集 + 写文档)→ 此 skill 提供 `doc_create_and_write.py` 脚本
## 局部意图与短流程
- [局部意图消歧](references/intent-guide.md);[短流程](references/lite-recipes.md)。
- 普通文件、目录、纯上传下载、节点存储权限 → `dingtalk-drive`
- 知识库空间、节点层级和成员管理 → `dingtalk-wiki`
- 原生 `.md` 文件读取和编辑 → `dingtalk-misc`
- `axls` / `able` → 对应电子表格或多维表 Skill
- 持续监听文档事件 → `dingtalk-misc`
@@ -1,70 +1,57 @@
# 文档知识
# 文档组合任务
> lite recipe 见 [SKILL.md 速查表](../SKILL.md)。
本页只描述跨步骤组合任务。单一创建、读取、更新、导出或媒体操作直接使用根 Skill Golden Route,不要加载本页。
| Recipe | 行动指南(固定路线) |
|--------|-------------------|
| import-file | 1. **直接执行** `dws doc import --file <本地文件路径> --format json`(一条命令完成上传+转换+创建)<br>2. 从返回中提取 `documentUrl` 并告知用户<br>3. **禁止先 Read 文件内容再 `doc create` + `doc update`**——`doc import` 是服务端格式转换,客户端无需解析文件内容<br>4. 可选参数:`--folder <文件夹ID>` 指定目标文件夹、`--workspace <知识库ID>` 指定目标知识库、`--name "文档名"` 自定义名称<br>5. 格式映射:docx/doc→文档, xlsx/xls→表格, xmind/mark→脑图, md/txt→文档<br>6. 超时或中断时 CLI 返回 `taskId`,用 `dws doc import get --task-id <taskId>` 手动查询<br>详见 [doc-import.md](./doc/doc-import.md) |
| write-doc | 0. 阅读 [doc-create-workflow.md](./doc/style/doc-create-workflow.md) 的 §前置必读 + §关键词速查表,锁定文档类型和起稿路径:**决策型/含对比的知识沉淀型/用户要求美观 → JSONML 起稿**(`.json`);执行型/说明型 → Markdown 起稿(`.md`)<br>1. 按选定路径执行 doc-create-workflow.md(JSONML 路径有骨架范例可直接复制修改)<br>2. `doc create --content-file /tmp/<name>.json --content-format jsonml`(或 `.md` + `--content-format markdown`)<br>3. 大内容默认依赖 DWS 自动分片;只有 `CONTENT_TRUNCATED`、部分写入失败或回读发现缺失时,才按 workflow 的恢复流程手工补片<br>4. **回读校验(必须)**:所有写入完成后,执行 `doc read --node <nodeId>`,校验关键标题/段落/表格是否完整写入 |
| search-docs-and-share | 1. `dws drive search --query "<关键词>" --format json` → 取候选 `nodeId` + 标题建索引(不读全文)<br>2. 对追问选中的候选执行 `dws drive info --node <nodeId> --format json`<br>3. 仅 `extension=adoc` 使用 `dws doc read --node <nodeId> --format json`(最多 2 篇);`md` / `axls` / `able` / 普通文件分别切到 markdown / sheet / aitable / drive,禁止固定执行 `doc read` |
| create-knowledge-base | 1. 创建知识库空间取 `WS_ID`<br>2. `wiki node create --workspace <WS_ID> --name "<文档名>"` → 取 `nodeId`<br>3. `wiki node list --workspace <WS_ID>` 确认 |
| migrate-doc | 1. `doc read --node <源nodeId>` → 取正文并写入临时文件 `<tmp>.md`<br>2. `doc create --name "<文档名>" --folder <DOC_FOLDER_NODE_ID> --content-file <tmp>.md` → 取新 `nodeId`(`--folder` 只传文档文件夹 nodeId / alidocs 文件夹 URL,不传数字 dentryId;正文 <200KB 单步到位)<br>2a. 若正文 >200KB:**必须先向用户提示截断风险**(详见下方「分块 append 截断风险提示」),用户确认后再执行:`doc create --name "<文档名>" --folder <DOC_FOLDER_NODE_ID>` → `nodeId` → 按段落切片 → 每片 `doc update --node <nodeId> --content-file <part> --mode append`<br>3. **回读校验**:`doc read --node <nodeId>` 校验内容完整性 |
| update-doc-section | 1. `dws drive search --query "<关键词>" --format json` → 取 `nodeId`<br>2. `dws drive info --node <nodeId> --format json`,仅 `extension=adoc` 继续;其他类型切到对应 skill/reference<br>3. **形态选择(按 [doc-update-workflow.md §1.3](./doc/style/doc-update-workflow.md) 优先级)**:目标段落含 callout / 分栏 / 颜色 / @人 / 附件 / 嵌套结构 → 走 `jsonml-node-edit`;纯文本替换且确认无富结构 → 继续本 recipe<br>4. `dws doc read --node <nodeId> --format json` 定位目标章节<br>5. `dws doc update --node <nodeId> --content "<替换内容>" --mode overwrite --yes --format json`<br>6. **回读校验**:`dws doc read --node <nodeId> --format json` 确认 overwrite 未被降级为 append、内容完整无截断<br>**overwrite 须用户确认**;完整改写流程见 [doc-update-workflow.md](./doc/style/doc-update-workflow.md) |
| rewrite-doc | 1. 阅读并执行 [doc-update-workflow.md](./doc/style/doc-update-workflow.md):先看 §1.3 编辑形态优先级(**JSONML 首选**),再按 §3 速查表选路径,跳 §4 对应小节执行<br>2. 单块改写 / 含富结构 → §4.4 路径 B;多处保真改写或改 root → §4.4 路径 A;纯文本骨架重写 → §4.5 markdown<br>3. 整篇 overwrite 前必须按 workflow §4.5 向用户提示风险并等待确认<br>4. **回读校验(必须)**:按 workflow §6 的校验要点逐项核查;@人、附件、图片等保真要素必须原样保留<br>**适用场景**:用户提供已有 nodeId/链接,需要改写、润色、章节补充、段落形态转换、整篇重写 |
| doc-to-message | 1. `doc read --node <nodeId>` → 取正文(大文档只摘要+链接)<br>2. `aisearch person --keyword "<姓名>" --dimension name` → 取 `openDingTalkId`(推荐);或 `chat search --query "<群名>"` → 取 `openConversationId`<br>3. `chat message send --open-dingtalk-id <openDingTalkId> --text "<内容>"`(推荐)或 `--group <openConversationId> --text "<内容>"` 发送。仅当无法获取 openDingTalkId 时才用 `--user <userId>`(备选) |
| lossless-doc-edit | 1. `doc read --node <nodeId> --content-format jsonml --output /tmp/doc.json` → 获取完整 JSONML 结构(输出含 `revision`,并发敏感时记下来;默认改写不需要)<br>2. 解析 JSON 文件,修改 `jsonml` 数组中的目标节点(节点结构参见 [doc-jsonml-schema.md](./doc/format/doc-jsonml-schema.md))<br>3. 将修改后的内容写回临时文件 `/tmp/doc_modified.json`,格式为 `{"jsonml": [...]}`<br>4. `doc update --node <nodeId> --content-file /tmp/doc_modified.json --content-format jsonml --mode overwrite`(默认不做并发检查;担心多 agent 同时改时加 `--revision <第 1 步拿到的 N>` 触发并发检查,版本不一致返回 `VersionConflict` 时回到第 1 步重读重写)<br>5. **回读校验**:`doc read --node <nodeId> --content-format jsonml` 确认写入成功<br>**适用场景**:保留样式、精准插入特定节点类型、改属性不动文本;普通文本编辑仍优先用 markdown 模式。完整 JSONML 改写流程见 [doc-update-workflow.md §4.4](./doc/style/doc-update-workflow.md) |
| jsonml-node-edit | 1. `doc block list --node <nodeId> --content-format jsonml` → 获取 JSONML 节点列表(含 uuid)<br>2. 根据 uuid 定位目标节点<br>3. `doc block list --node <nodeId> --content-format jsonml --block-id <uuid>` → 读取完整子树<br>4. 修改 JSONML 节点内容(节点结构参见 [doc-jsonml-schema.md](./doc/format/doc-jsonml-schema.md),可复制范例见 [doc-jsonml-cookbook.md](./doc/format/doc-jsonml-cookbook.md))<br>5. `doc block update --node <nodeId> --block-id <uuid> --content-format jsonml --element '<修改后的 JSONML>'` → 写回<br>**适用场景**:只改一个 block 的结构/样式,无需全文回写;写入端默认 normalize(自动补 uuid、裸字符串包成 canonical 文本),可用 `--no-fix-jsonml` 关闭全部修复;`--fix-jsonml` 额外启用 JSON 语法修复(推荐 agent 调用) |
## 创建成稿
## template-based-generation
1. 把最终正文写到工作目录内的 `body.md` 或 `body.json`。
2. 执行一次 `dws doc +create --name "<标题>" --content @body.md --format json`。
3. 使用返回的 `verified/nodeId` 判断完成;只有用户明确要求额外结构验收时再做局部 `+fetch`。
当用户提供已有 alidocs 文档并要求“按模板生成、复刻、生成同形态的新版本”时,必须走保形复制链路:
复杂排版才按需读取 style/JSONML Reference;执行入口仍使用 `+create`。禁止调用已删除的创建脚本。
1. `dws drive info --node <源文档ID或URL> --format json`,确认源节点存在并记录类型。
2. `dws drive copy --node <源文档ID或URL> [--folder <目标文件夹>] [--workspace <目标知识库>] --format json`,从返回中提取副本 `nodeId`。
3. 如需改名,只对副本执行 `dws drive rename --node <副本nodeId> --name "<新名称>" --format json`。
4. `dws doc block list --node <副本nodeId> --content-format jsonml` 定位需要替换的块;仅对副本执行 `dws doc block update`。涉及多个块或富结构时,按 [doc-update-workflow.md](./doc/style/doc-update-workflow.md) 的 JSONML 路径操作。
5. 再次读取副本的 `drive info` 和目标块,确认名称、内容与结构;禁止修改源文档。
**禁止**用 `doc read` → `doc create` 重建模板。Markdown 是有损投影,会丢失行高、单元格背景色、字号和部分富结构。
---
## 分块 append 截断风险提示
### 触发条件
当内容总大小 **超过 200KB**,需要拆分为多片通过 `doc update --mode append` 分块写入时,**必须在执行前向用户发出截断风险提示**,等待用户确认后再继续。
### 提示话术(参考模板)
> 注意: 内容较长(约 {size}),需要分 {n} 片写入。分块 append 存在以下风险:
> - 部分片段可能写入失败但返回 success,导致文档**内容截断或缺失**
> - 片段之间的表格、代码块等跨块元素可能**被截断破坏**
> - 写入顺序异常可能导致**段落错乱**
>
> 建议:写入完成后我会回读校验文档完整性。是否继续?
### 执行规范
1. **提示时机**:在执行第一片 append **之前**提示,而非写入过程中
2. **分片原则**:按段落/标题边界切分,**禁止**在表格、代码块、列表内部截断
3. **逐片校验**(推荐):每写入一片后记录已写入的最后一个标题/段落标记,供最终回读时比对
4. **最终回读**(必须):所有片段写入完成后,执行 `doc read --node <nodeId>` 回读全文,逐片比对关键标记是否完整(详见下方「doc update 回读校验规范」)
5. **失败处理**:若回读发现缺失片段,向用户报告具体缺失位置,建议针对缺失部分单独重试 append
---
## doc update 回读校验规范
**所有 `doc update`(含 overwrite 和 append)执行后都必须回读校验**——返回 `success=true` 不等于内容真的写入完整。
完整规范(静默失败场景、校验流程、异常处理路径、"先清空再重建"命令样板)见 [doc-update-workflow.md §6「回读验收」](./doc/style/doc-update-workflow.md)。
最小流程:
## 导入文件
```bash
dws doc update --node <nodeId> --content-file /tmp/new-content.md --mode overwrite
dws doc read --node <nodeId> # 校验关键标题、段落首句、表格、@人、附件
dws doc +import --file ./report.docx --folder <FOLDER_ID> --format json
```
**禁止**在未回读的情况下向用户报告「已完成」。
导入是服务端格式转换。禁止先读文件内容再走 `create + update`。单纯保存普通文件切换到 `dingtalk-drive` 上传。
## 查找并读取
```bash
dws doc +fetch --query "<唯一标题>" --format json
```
`+fetch --query` 会跨页解析唯一在线文字文档。零命中、多候选或类型不是 `adoc` 时停止并按返回候选消歧;不要继续调用 drive/wiki/aisearch 做无界穷举。
## 更新章节
1. `+fetch --node <ID> --scope section|keyword` 读取最小必要上下文。
2. 普通编辑用 `+update`;重要覆盖用 `+checkpoint-update`。
3. 使用 shortcut 的验证结果;`partial_success/unknown` 时只恢复缺失步骤。
## 从模板创建
1. `+template-search --query <名称>`。
2. 唯一命中才取得 `templateId`;多候选要求用户选择。
3. `+create-from-template --template-id <ID>` 只执行一次。
模板保形复制已有文档是另一种任务:使用 drive copy 复制源文档,再只修改副本。禁止 `doc read → doc create` 重建富格式模板。
## 导出并归档
1. 在线文字文档使用 `+export` 导出到工作目录。
2. 检查 `localPath/sizeBytes`。
3. 用户要求归档到钉盘时,再用 `dingtalk-drive` 上传该文件。
导出失败不得安装本地转换依赖或直接下载临时 URL。
## 文档转消息/待办
1. 使用局部 `+fetch` 提取必要内容。
2. 在目标产品中解析真实用户/群/任务标识。
3. 根据目标产品 Runtime gate 确认后写入。
跨产品步骤必须复用稳定 ID;失败后不能重放已经成功的文档写入步骤。
@@ -0,0 +1,38 @@
# Doc Runtime Contracts
本文只定义 Agent 需要稳定消费的文档运行时语义。字段以 leaf Schema 和实际 JSON 返回为准;不要从终端展示文本反推状态。
## 目标
文档目标至少保留 `nodeId`、资源类型、canonical URL(服务返回时)和容器信息。名称或标题不是稳定身份。按自然标题解析出现零命中、多候选或分页不完整时,写操作必须停止。
## 写操作状态
| status | 含义 | 后续动作 |
|---|---|---|
| `success` | 所有计划步骤完成,且需要验证的内容已经回读 | 可以向用户报告完成 |
| `partial_success` | 已发生部分副作用,后续步骤失败 | 检查 `steps` 与 `compensation`,不得重放成功步骤 |
| `unknown` | 请求已发出但无法确定服务端是否提交 | 先回读目标;创建和追加禁止自动重试 |
| `retryable` | 服务端明确业务执行尚未开始,且允许重试 | 遵循 `retry_after`,最多有界重试一次 |
| `failed` | 已确认没有完成目标动作 | 根据 `retryable` 和 `suggestedActions` 决定是否重试 |
`ok=true` 或进程退出码为零不能替代 `status/verified`。写回执应保留 operation、target、steps、warnings、failures、compensation 和 verification 摘要。
## 分页与完整性
列表和搜索结果需要区分:
- `complete=true`:已证明覆盖请求范围;
- `hasMore=true`:仍有后续页,必须保留有效 continuation;
- `truncated=true`:因 maxPages/maxItems/timeout 等边界停止;
- `failures[]`:已返回部分数据但某一页或后处理失败。
只有 `complete=true` 且没有未处理失败时,才能把结果描述为完整集合。
## 错误
结构化错误至少区分 validation、not_found、ambiguous、type_mismatch、revision_conflict、confirmation_required、permission_denied、partial_success 和 commit_unknown,并提供 failure stage、retryable 和 suggested actions。权限、认证和参数错误直接进入 `failed`;写请求只有明确 `execution_started=false` 才能进入 `retryable`,其余传输异常进入 `unknown`。`retryable=false` 表示自动重放不安全,不代表用户检查状态后永远不能重新发起。
## 安全落盘
导出、下载和媒体预览只使用工作目录内相对路径。完成条件包括临时文件写入成功、内容校验、文件关闭和原子发布;失败时不得留下看似最终产物的半成品。默认 no-clobber,覆盖必须由用户显式选择。
+48 -483
View File
@@ -1,510 +1,75 @@
# 文档 (doc) 命令参考
# dingtalk-doc 低频能力索引
> **渐进式文档**:本文件为路由层(命令索引 + 场景索引 + 意图判断 + 工作流),各命令的详细参数、示例和踩坑说明在 [doc/](./doc/) 目录下按需加载。
本页只在根 Skill 的 Golden Route 和精确任务 Reference 都无法选路时加载。它不是创建、读取或更新任务的前置必读,也不要求预加载样式、JSONML 或完整产品帮助。
## 文档地址 (URI)
## 高频入口
| 资源 | URI 格式 |
|------|----------|
| 文档节点 | `https://alidocs.dingtalk.com/i/nodes/{dentryUuid}` |
| edit / preview 链接 | `https://alidocs.dingtalk.com/document/{edit\|preview}?...&dentryKey={key}` |
| 意图 | 推荐命令 | 精确 Reference |
|---|---|---|
| 搜索在线文字文档 | `dws doc +search --query <关键词>` | [doc-info.md](doc/doc-info.md) |
| 读取正文或局部内容 | `dws doc +fetch --node <ID或URL>` | [doc-read.md](doc/doc-read.md) |
| 创建并写入 | `dws doc +create` | [doc-create.md](doc/doc-create.md) |
| 追加、覆盖、block 编辑 | `dws doc +update` | [doc-update.md](doc/doc-update.md) |
| 重要更新与恢复点 | `dws doc +checkpoint-update` | [doc-update.md](doc/doc-update.md) |
| 导出本地文件 | `dws doc +export` | [doc-export.md](doc/doc-export.md) |
| 导入为在线对象 | `dws doc +import` | [doc-import.md](doc/doc-import.md) |
| 评论聚合与操作 | `dws doc +review/+comment-*` | [doc-comment.md](doc/doc-comment.md) |
| 媒体插入、列表、下载 | `dws doc +media-*` | [doc-media.md](doc/doc-media.md) |
> **操作后请返回文档 URI**:每次执行 create / read / update 等操作后,从返回数据中提取 `docUrl` 直接返回;缺失时用 `doc info --node <ID>` 补查。
命令已选定但参数不确定时读取精确 leaf Schema;只有 Cobra flag 与 Schema 冲突时读取精确 leaf Help。不要加载 `dws doc --help` 或完整 Catalog 代替选路。
## 前置条件 — 执行操作前必读
## 模板
**CRITICAL — 执行对应操作前,MUST 先用 Read 工具读取以下子文件:**
1. **解析 URL / 定位文档**(几乎所有命令都需要先拿 nodeId)
→ 必读 [`doc/doc-info.md`](doc/doc-info.md)(URL/dentryKey 提取规则、ID 边界、extension 路由、**获取 nodeId 三种方式 A/B/C**)
2. **创建或编辑文档内容**(`doc create` / `doc update` / `doc block insert|update`)
→ 必读 [`doc/style/doc-update-workflow.md`](./doc/style/doc-update-workflow.md)(**形态优先级硬规则:JSONML > element JSON > markdown**;markdown overwrite 会丢富结构)
- 从零创建时加读 [`doc/style/doc-create-workflow.md`](./doc/style/doc-create-workflow.md)
- **任何 `doc create` 都必须先读 [`doc/style/doc-style-guideline.md`](./doc/style/doc-style-guideline.md) §2.0 类型决策表 + §1 硬规则**(决定骨架 + 全局约束,不读就不知道用哪种骨架)
- 涉及 callout / 分栏 / 富 block 精修时再加读 style-guideline §4-§7 + [`doc/format/doc-jsonml-cookbook.md`](./doc/format/doc-jsonml-cookbook.md)
**未读以上文件就改写已有文档会导致富结构丢失、参数错误或样式不达标。其他命令(阅读 / 评论 / 权限 / 附件 / 下载导出 / 文件操作)按需查下方 §命令索引表跳转对应子文件加载,不必提前加载。**
## 查询命令帮助
当你不确定某个命令的具体参数、格式或可选项时,**优先执行 `--help` 查询**,不要猜测参数名或凭记忆编造。
只有名称时先只读搜索:
```bash
# 查看 doc 下所有子命令
dws doc --help
# 查看具体命令的完整参数说明
dws doc read --help
dws doc create --help
dws doc block insert --help
# 查看子命令组下的所有命令
dws doc block --help
dws doc media --help
dws doc +template-search --query "周报" --source PUBLIC --format json
```
规则:
- `selection.status=resolved`:取唯一候选的 `templateId`。
- `selection.status=not_found`:报告零命中后停止。
- `selection.status=selection_required`:展示候选并要求用户选择,禁止默认第一项。
- 参数名不确定时 → 先 `--help`,再调用
- 报错 "unknown flag" 时 → `--help` 确认正确的 flag 名称
- 不确定某个功能是否存在时 → `dws doc --help` 查看命令列表
## 命令索引表
> 命令名 → 单文件,按需加载子文档。复杂任务请优先看下方 §场景索引。
### 阅读 / 元信息
| 命令 | 用途 | 必填参数 | 详见 |
|------|------|----------|------|
| `doc info` | 文档元信息(含 contentType / extension) | `--node` | [`doc/doc-info.md`](./doc/doc-info.md) |
| `doc read` | 读取正文(markdown 或 jsonml) | `--node` | [`doc/doc-read.md`](./doc/doc-read.md) |
### 创建 / 写入 / 块编辑
| 命令 | 用途 | 必填参数 | 详见 |
|------|------|----------|------|
| `doc create` | 创建文字文档(adoc) | `--name` | [`doc/doc-create.md`](./doc/doc-create.md) |
| `doc update` | 整篇 / 段落级更新(markdown / jsonml) | `--node` `--mode` | [`doc/doc-update.md`](./doc/doc-update.md) |
| `doc block list/insert/update/delete` | 块级精细编辑(含 JSONML 节点操作) | `--node` (+ `--block-id`) | [`doc/doc-block.md`](./doc/doc-block.md) |
### 附件 / 评论 / 导出
| 命令 | 用途 | 必填参数 | 详见 |
|------|------|----------|------|
| `doc media insert/download` | 附件 / 图片插入与下载 | `--node` `--file` 或 `--resource-id` | [`doc/doc-media.md`](./doc/doc-media.md) |
| `doc comment list/create/reply/update/delete/create-inline` | 文档评论与划词评论 | `--node` (+ ...) | [`doc/doc-comment.md`](./doc/doc-comment.md) |
| `doc export` / `doc export get` | 在线文档导出 docx/markdown/pdf;`export get` 输出已对齐统一 TaskResult 格式 | `--node` `--output` | [`doc/doc-export.md`](./doc/doc-export.md) |
### 导入
| 命令 | 用途 | 必填参数 | 详见 |
|------|------|----------|------|
| `doc import` / `doc import get` | 本地文件导入为在线文档(docx→文档 / xlsx→表格 / xmind→脑图 / md→文档);`import get` 输出已对齐统一 TaskResult 格式 | `--file` | [`doc/doc-import.md`](./doc/doc-import.md) |
### 版本管理
| 命令 | 用途 | 必填参数 | 详见 |
|------|------|----------|------|
| `doc version save` | 手动保存文档版本快照 | `--node` | 本节 |
| `doc version list` | 查看文档历史版本列表 | `--node` | 本节 |
| `doc version revert` | 回滚文档到指定版本 | `--node` `--version` `--yes` | 本节 |
### 模板管理
| 命令 | 用途 | 必填参数 | 详见 |
|------|------|----------|------|
| `doc template list` | 获取文档模板列表 | 无 | 本节 |
| `doc template search` | 搜索文档模板 | `--query` | 本节 |
| `doc template apply` | 应用文档模板创建新文档 | `--template-id` | 本节 |
#### 手动保存文档版本
```
Usage:
dws doc version save [flags]
Example:
dws doc version save --node <nodeId>
Flags:
--node string 文档 ID 或 URL (必填)
```
> 仅支持 adoc 类型文档。保存后会创建一个 USER_SAVE 类型的版本记录。
#### 查看文档历史版本列表
```
Usage:
dws doc version list [flags]
Example:
dws doc version list --node <nodeId>
dws doc version list --node <nodeId> --limit 10
Flags:
--node string 文档 ID 或 URL (必填)
--limit int 返回版本数量上限 (可选)
--cursor string 分页游标 (可选)
```
#### 回滚文档到指定版本
> **CAUTION:** 不可逆操作 — 执行前必须向用户确认。
```
Usage:
dws doc version revert [flags]
Example:
dws doc version revert --node <nodeId> --version 3 --yes
Flags:
--node string 文档 ID 或 URL (必填)
--version int 目标版本号 (必填,从 version list 获取)
--yes 跳过确认提示 (非交互终端必须传)
```
#### 获取文档模板列表
```
Usage:
dws doc template list [flags]
Example:
dws doc template list
dws doc template list --source MY
dws doc template list --source PUBLIC
dws doc template list --limit 20
Flags:
--source string 模板来源: MY(我的模版)/PUBLIC(公开模版),不传默认 MY
--limit int 返回数量上限 (可选)
--cursor string 分页游标 (可选)
```
#### 搜索文档模板
```
Usage:
dws doc template search [flags]
Example:
dws doc template search --query "周报"
dws doc template search --query "会议纪要" --limit 10
dws doc template search --query "项目" --source PUBLIC
Flags:
--query string 搜索关键词 (必填)
--source string 模板来源: MY(我的模版)/PUBLIC(公开模版),不传默认 MY
--limit int 返回数量上限 (可选)
--cursor string 分页游标 (可选)
```
#### 应用文档模板
```
Usage:
dws doc template apply [flags]
Example:
dws doc template apply --template-id TPL_ID --name "我的周报"
dws doc template apply --template-id TPL_ID --name "项目方案" --folder FOLDER_ID
Flags:
--template-id string 模板 ID (必填,从 template list/search 获取)
--name string 新文档名称 (可选)
--folder string 目标文件夹 ID (可选)
--workspace string 知识库 ID (可选)
```
### 文件操作(已迁移,以下命令 deprecated)
> **迁移提示**:文件管理命令已按产品领域架构重新归属。**Agent 必须使用右侧推荐命令,禁止使用左侧旧命令**(旧命令仅供人类用户过渡参考,可能已不可用)。
| 旧命令 | 推荐命令 | 详见 |
|--------|----------|------|
| `doc upload` | `dws drive upload` | [`drive.md`](../../dingtalk-drive/references/drive.md) |
| `doc download` | `dws drive download` | [`drive.md`](../../dingtalk-drive/references/drive.md) |
| `doc copy` | `dws drive copy` | [`drive.md`](../../dingtalk-drive/references/drive.md) |
| `doc move` | `dws drive move` | [`drive.md`](../../dingtalk-drive/references/drive.md) |
| `doc rename` | `dws drive rename` | [`drive.md`](../../dingtalk-drive/references/drive.md) |
| `doc delete` | `dws drive delete` | [`drive.md`](../../dingtalk-drive/references/drive.md) |
| `doc folder create` | `dws drive folder create` | [`drive.md`](../../dingtalk-drive/references/drive.md) |
| `doc file create` | `dws wiki node create --type <type>` | [`wiki.md`](../../dingtalk-wiki/references/wiki.md) |
| `doc permission *` | `dws drive permission *` | [`drive.md`](../../dingtalk-drive/references/drive.md) |
| `doc list` | `dws drive list --workspace` / `dws wiki node list` | [`drive.md`](../../dingtalk-drive/references/drive.md) / [`wiki.md`](../../dingtalk-wiki/references/wiki.md) |
| `doc search` | `dws drive search` / `dws wiki node search` | [`drive.md`](../../dingtalk-drive/references/drive.md) / [`wiki.md`](../../dingtalk-wiki/references/wiki.md) |
### 排版规范 / JSONML 参考
| 资源 | 用途 | 详见 |
|------|------|------|
| 创建工作流 | 标题、位置、骨架、回读校验 | [`doc/style/doc-create-workflow.md`](./doc/style/doc-create-workflow.md) |
| 改写工作流 | 编辑形态优先级、分片 append、回读验收 | [`doc/style/doc-update-workflow.md`](./doc/style/doc-update-workflow.md) |
| 排版规范 | 文档类型 / 骨架 / 元素边界 / 颜色语义 | [`doc/style/doc-style-guideline.md`](./doc/style/doc-style-guideline.md) |
| JSONML 范例 | 所有节点类型的可复制命令 | [`doc/format/doc-jsonml-cookbook.md`](./doc/format/doc-jsonml-cookbook.md) |
| JSONML 节点结构 | 字段定义 + JSON Schema | [`doc/format/doc-jsonml-schema.md`](./doc/format/doc-jsonml-schema.md) |
## 场景索引
> 任务驱动的入口:知道任务但不确定要读哪些命令文件时,按本表**一次性 Read 文件组**,不要逐个跳。
| 任务场景 | 一次性读取 | 主命令 |
|---------|-----------|--------|
| 定位 nodeId / URL 解析 | [`doc-info.md`](./doc/doc-info.md)(搜索请用 `dws drive search` / `dws wiki node search`;遍历请用 `dws drive list` / `dws wiki node list`) | `drive search` / `wiki node search` |
| 阅读已有文档 | [`doc-info.md`](./doc/doc-info.md) + [`doc-read.md`](./doc/doc-read.md) | read |
| 创建新文档 | [`doc-create.md`](./doc/doc-create.md) + [`doc-update.md`](./doc/doc-update.md)(写入管道)+ [`style/doc-create-workflow.md`](./doc/style/doc-create-workflow.md) + [`style/doc-style-guideline.md`](./doc/style/doc-style-guideline.md) | create |
| 创建文档且包含图片/截图/图文并茂 | [`doc-create.md`](./doc/doc-create.md) + [`doc-media.md`](./doc/doc-media.md) + [`style/doc-create-workflow.md`](./doc/style/doc-create-workflow.md) + [`style/doc-style-guideline.md`](./doc/style/doc-style-guideline.md) | create → media insert |
| 局部改写 / 段落替换(保真) | [`doc-read.md`](./doc/doc-read.md) + [`doc-update.md`](./doc/doc-update.md) + [`doc-block.md`](./doc/doc-block.md) + [`format/doc-jsonml-cookbook.md`](./doc/format/doc-jsonml-cookbook.md) + [`style/doc-update-workflow.md`](./doc/style/doc-update-workflow.md) | block update |
| 整篇 overwrite(可选并发检查) | [`doc-read.md`](./doc/doc-read.md) + [`doc-update.md`](./doc/doc-update.md) + [`format/doc-jsonml-schema.md`](./doc/format/doc-jsonml-schema.md) + [`style/doc-update-workflow.md`](./doc/style/doc-update-workflow.md) | update |
| 插入富 block(callout / 分栏 / 表格) | [`doc-block.md`](./doc/doc-block.md) + [`format/doc-jsonml-cookbook.md`](./doc/format/doc-jsonml-cookbook.md) + [`style/doc-style-guideline.md`](./doc/style/doc-style-guideline.md) | block insert |
| 上传图片 / 附件 | [`doc-media.md`](./doc/doc-media.md) | media insert |
| 评论 / 划词评论(含 @人 / 更新 / 删除) | [`doc-comment.md`](./doc/doc-comment.md)(+ `dws aisearch person` 取 mention 用 userId) | comment create |
| 文档分享 / 节点级权限 | [`drive.md`](../../dingtalk-drive/references/drive.md)(已迁移:`dws drive permission add/update/list/remove`) | `drive permission` |
| 导出 PDF / DOCX / Markdown | [`doc-info.md`](./doc/doc-info.md) + [`doc-export.md`](./doc/doc-export.md) | export |
| 导入本地文件为在线文档 | [`doc-info.md`](./doc/doc-info.md) + [`doc-import.md`](./doc/doc-import.md) | import |
| 文件下载 / 上传 / 移动 / 重命名 / 复制 | [`drive.md`](../../dingtalk-drive/references/drive.md)(已迁移:`dws drive upload/download/copy/move/rename/delete`) | `drive *` |
| 版本管理(保存/列出/回滚) | 本文件 §版本管理(**仅 adoc**) | `dws doc version save/list/revert`(adoc);其他文件类型当前 CLI 不支持版本管理 |
## 意图判断
用户说"找文档/搜文档/最近文档":
- 全局搜索 → `dws drive search --query "<关键词>"`(聚合钉盘+文档空间)
- 空间内搜索 → `dws wiki node search --workspace <WS_ID> --query "<关键词>"`
- 遍历文件夹 → `dws drive list --workspace <WS_ID>` 或 `dws wiki node list --workspace <WS_ID>`
- 最近访问/最近编辑 → `dws drive recent`(见 [`drive.md`](../../dingtalk-drive/references/drive.md))
用户说"看文档/读内容/文档内容":
- 读取 → `read`(需文档 ID 或 URL)
- 元信息 → `info`
用户说"写文档/创建文档":
- 新建文字文档(adoc)→ `doc create`
- 追加内容 → `update --mode append`
- 覆盖替换 → `update --mode overwrite`
- 指定父目录时,只有明确的文档文件夹 `nodeId` / `alidocs` 文件夹 URL 才能放入 `--folder`;上一步若只返回了纯数字 `dentryId`、`spaceId` 或 drive `parent-id`,不要把它传给 doc 的 `--folder`
> 严禁把「创建表格」路由到 `doc create`:
>
> - 用户说"创建表格/新建表格/建个电子表格/在线表格" → 走 [`dws sheet create`](../../dingtalk-misc/references/sheet/sheet-workbook.md#创建钉钉表格文档)(axls 在线电子表格)
> - 用户说"创建多维表格/新建 AI 表格/建 base/数据库表" → 走 [`dws aitable base create`](../../dingtalk-aitable/references/aitable.md#base-base-管理)(able 多维表格)
用户说"建文件夹/新建目录":
- 创建 → `dws drive folder create`
用户说"上传文件/传文件/上传到文档/上传到知识库":
- 上传 → `dws drive upload`(需本地文件路径)
用户说"下载/导出/下载到本地/导出文档/导出为Word/导出为docx/把文档导出来":
- **必须先判断目标文件类型**,再决定走 `export` 还是 `download`:
- 在线文档 (alidocs/adoc) → **`doc export`**(导出是内容层操作,仅对 adoc 有意义)
- 已有文件(PDF、图片、附件、视频等非在线文档) → **`dws drive download`**
- **任务查询**:`doc export` 内部自动轮询完成导出;仅在超时或中断后,用 `dws doc export get --job-id <jobId>` 手动查询任务状态
- 判断方法:
1. 如果用户明确说了"导出文档"、"导出为Word/docx" → 直接走 `doc export`
2. 如果用户明确说了"下载PDF/图片/附件" → 直接走 `drive download`
3. 不确定时,先用 `drive info --node <ID>` 查询节点信息,根据返回的 `extension` 字段判断:
- `extension` 为 `adoc` → 走 `doc export`
- `extension` 为其他(txt/pdf/docx/md/xlsx 等)→ 走 `drive download`
> **严禁将"导出文档"直接路由到 `download`**。`download` 只能下载已有文件(原样下载),`export` 是将在线文档格式转换后导出为 docx 或 markdown 或 pdf,两者完全不同。
>
> **导出任务查询**:`doc export` 超时后可用 `dws doc export get --job-id <ID>` 查询任务状态。
用户说"导入文件/导入docx/导入Word/导入Excel/导入脑图/导入xmind/导入思维导图/把文件转成文档/文件转在线文档/导入到知识库/打开本地文件":
- **直接调用 `dws doc import --file <文件路径> --workspace <WORKSPACE_ID>`**(或 `--folder <FOLDER_ID>`),一条命令自动完成上传+转换+创建在线文档,**无需先读取文件内容再手动创建**。**注意:`--folder` 和 `--workspace` 至少传一个,不传会报错。导入到"我的文档"需先执行 `dws wiki space list --type myWikiSpace` 获取 workspaceId。**
- 支持格式与映射:docx/doc→文档, xlsx/xls→表格, xmind/mark→脑图, md/txt→文档
- 文件大小限制:20MB
- 导入后会自动转换为钉钉在线文档,返回在线文档 URL
- **任务查询**:`doc import` 内部自动轮询完成转换;仅在超时或中断后,用 `dws doc import get --task-id <taskId>` 手动查询任务状态
> **严禁先 Read 文件再 `doc create` + `doc update`**。`doc import` 是一体化命令,服务端负责格式转换,客户端无需解析文件内容。
>
> **导入任务查询**:`doc import` 超时后可用 `dws doc import get --task-id <ID>` 查询任务状态。
用户说"复制文档/拷贝文件/复制到":
- 复制 → `dws drive copy`
用户说"移动文档/搬到/移到/转移文件":
- 移动 → `dws drive move`
用户说"重命名/rename/改名/改文档名/修改文档名称/修改文档标题/把这个文档叫做...":
- 重命名 → `dws drive rename --node <DOC_ID_OR_URL> --name "新名称"`
- 只有用户明确说"正文里的标题/章节标题/段落标题/H1 标题"时,才走 `block update`。
用户说"删除文档/删掉这个文件/移到回收站/丢掉这篇文档":
- 删除节点 → `dws drive delete`
用户说"插入附件/上传附件到文档/往文档里加文件/加附件":
- 插入附件 → `media insert`(需文档 ID 或 URL + 本地文件路径)
用户说"插入图片/加图片/放张图/嵌入图片/往文档里插图":
- 插入图片 → `media insert`(需文档 ID 或 URL + 本地图片文件路径)
- 注意:图片也是通过 `media insert` 作为附件块插入文档,不是通过 `block insert`
用户说"下载附件/获取附件/取出文档里的附件":
- 下载附件 → `media download`(需文档 ID 或 URL + 资源 ID)
用户说"编辑块/改段落/插入标题/删除块":
- 查看结构 → `block list`
- 插入 → `block insert`
- 修改 → `block update`
- 删除 → `block delete`
用户说"保存版本/创建版本快照/手动保存一下版本":
> **前置守卫**:以下 `doc version` 命令仅适用于钉钉在线文档(adoc)。其他文件类型(txt/pdf/docx/xlsx/md 等)当前 CLI 不支持版本管理。
- 保存版本 → `version save`(需文档 ID 或 URL)
用户说"版本历史/版本列表/查看修改历史/历史版本":
- 查看版本列表 → `version list`(需文档 ID 或 URL)
用户说"回滚版本/恢复到某个版本/版本回退/回退到之前的版本":
- 回滚到指定版本 → `version revert`(需文档 ID 或 URL + 版本号,危险操作需确认)
用户说"给某人开权限/分享给某人/授权某文档/把这篇文档给 xxx 看":
- 新增权限 → `dws drive permission add`
- 修改权限 → `dws drive permission update`
- 查看谁有权限 → `dws drive permission list`
- 移除权限 → `dws drive permission remove`
> **关键区分**:
>
> - "把**某篇文档**授权给某人" → `drive permission add`(节点级,包括「我的文档」下的文档都支持)
> - "把**某个知识库**整体授权给某人" → `wiki member add`(容器级,但**「我的文档」个人空间不支持**)
> 补充:如果用户直接粘贴的是原始 `alidocs` URL,先按 [链接规范](../../dingtalk-shared/references/url-patterns.md#alidocs-url-类型探测流程) 用 `dws drive info` probe;只有 probe 确认 `extension=adoc` 后,才继续按下列意图执行。其他类型(txt/pdf/docx/md/xlsx/file/folder 等)走 [`drive.md`](../../dingtalk-drive/references/drive.md) 或对应产品。
**用户直接粘贴文档 URL(无其他指令)**:
- 默认 → `read`(读取文档内容)
- 如 URL 明显是文件夹 → `list`(列出文件夹内容)
**用户粘贴 URL + 附加指令**:
- "帮我看看这个文档" → `read`
- "这个文档的信息" → `info`
- "往这个文档追加内容" → `update --mode append`
- "把这个文档标题改成 X" / "这个文档改名为 X" → `dws drive rename`
- "把正文里的一级标题/章节标题改成 X" → `block update`
关键区分: doc(文档编辑/阅读) vs aitable(数据表格操作) vs drive(文件管理)
## 核心工作流
> 步骤性指引。"读哪些文件"组合参见上方 §场景索引;命令详细参数参见对应子文件。
选定后只创建一次:
```bash
# ── 工作流 1: 定位并阅读文档 ──
dws drive search --query "<关键词>" --format json # 1. 搜索定位 nodeId(或 wiki node search)
dws drive info --node <DOC_ID> --format json # 2. 必须确认 extension=adoc
dws doc read --node <DOC_ID> --format json # 3. 仅 adoc 读正文;其他 extension 切对应 skill
# ── 工作流 2: 创建文档(含分片自动写入)──
dws drive folder create --name "项目资料" --format json # 1. (可选) 创建文件夹
dws doc create --name "项目周报" --content-file /tmp/x.md \
--folder <DOC_FOLDER_NODE_ID> --format json # 2. 创建 + 写入
dws doc read --node <DOC_ID> --format json # 3. 回读校验(必须)
# ── 工作流 3: 局部改写(保真,首选 JSONML)──
dws doc block list --node <DOC_ID> --content-format jsonml # 1. 取 uuid
dws doc block list --node <DOC_ID> --content-format jsonml --block-id <UUID> # 2. 读子树
dws doc block update --node <DOC_ID> --block-id <UUID> \
--content-format jsonml --element '[...]' # 3. 写回(uuid 必须 == --block-id)
dws doc read --node <DOC_ID> --content-format jsonml # 4. 回读
# ── 工作流 4: 整篇 overwrite(仅在新骨架重写时)──
dws doc read --node <DOC_ID> --content-format jsonml --output /tmp/doc.json # 1. 读出当前 JSONML
# 修改 /tmp/doc.json 中的 jsonml 数组
dws doc update --node <DOC_ID> --content-file /tmp/doc.json \
--content-format jsonml --mode overwrite # 2. 写回(默认不做并发检查)
dws doc read --node <DOC_ID> --content-format jsonml # 3. 回读
# 担心被并发覆盖时,可加 --revision <N> 触发并发检查(详见 doc-update.md)
# ── 工作流 5: 上传独立文件 vs 插入附件到文档正文 ──
dws drive upload --file ./report.pdf --folder <ID> # 上传作为独立文件(存储层)
dws doc media insert --node <DOC_ID> --file ./report.pdf # 上传并作为附件块插入正文(内容层)
# ── 工作流 6: 导入本地文件为在线文档 ──
dws doc import --file ./report.docx --workspace <WS_ID> --format json # 一体化导入(推荐)
dws doc import --file ./notes.md --folder <FOLDER_ID> --format json # 指定目标文件夹
dws doc import --file ./data.xlsx --workspace <WS_ID> --format json # 指定目标知识库
# ── 工作流 7: 下载 vs 导出(先 info 判 extension)──
dws drive info --node <NODE_ID> --format json # 必须先查 extension
dws doc export --node <NODE_ID> --output ~/downloads/ # extension=adoc 走 export(内容层)
dws drive download --node <NODE_ID> --output ~/downloads/ # extension≠adoc 走 drive download(存储层)
# ── 工作流 8: 评论 + 划词(@人 需 userId)──
dws aisearch person --keyword "张三" --dimension name --format json # 取 userId
dws doc comment create --node <DOC_ID> --content "请确认" --mention <uid> --format json
dws doc block list --node <DOC_ID> --format json # 划词需先取 blockId / paragraph.text
dws doc comment create-inline --node <DOC_ID> --block-id <BLOCK_ID> \
--start 0 --end 10 --content "建议调整" --selected-text "原文" --format json
dws doc comment update --node <DOC_ID> --comment-key <COMMENT_KEY> --content "已按最新数据修正" --format json
dws doc comment delete --node <DOC_ID> --comment-key <COMMENT_KEY> --yes --format json # 删除不可逆,必须先确认
# ── 工作流 9: 权限授予(节点级,已迁移到 drive)──
dws aisearch person --keyword "张三" --dimension name --format json # 取 userId
dws drive permission add --node <DOC_ID> --user <uid1>,<uid2> --role EDITOR --format json
dws drive permission list --node <DOC_ID> --format json # 校验
# ── 工作流 10: 文件操作(已迁移到 drive)──
# 第一步:获取 nodeId
# 方式 A(优先):用户直接提供 URL / nodeId → 直接传 --node
# 方式 B:按关键字找:dws drive search --query "项目周报" --format json
# 方式 C:按文件夹遍历:dws drive list --workspace <WS_ID> --format json
# 第二步:执行
dws drive copy --node <DOC_ID_OR_URL> --folder <TARGET_FOLDER> --format json
dws drive move --node <DOC_ID_OR_URL> --folder <TARGET_FOLDER> --format json
dws drive rename --node <DOC_ID_OR_URL> --name "新名称" --format json
dws drive delete --node <DOC_ID_OR_URL> --yes --format json
dws doc +create-from-template --template-id <TEMPLATE_ID> --name "我的周报" --format json
```
禁止通过实际创建多个候选文档来预览模板。`+create-from-template --query` 仅保留兼容,不能作为新的 Agent Golden Route。
## 历史版本
```bash
# ── 工作流 11: 文档版本管理 ──
dws doc version save --node <DOC_ID> --format json # 1. 手动保存版本快照
dws doc version list --node <DOC_ID> --format json # 2. 查看版本列表(提取 version 号)
dws doc version revert --node <DOC_ID> --version <N> --yes --format json # 3. 回滚到指定版本(危险操作,需 --yes)
dws doc +version-save --node <DOC_ID> --format json
dws doc +version-list --node <DOC_ID> --limit 20 --format json
dws doc +version-revert --node <DOC_ID> --version <N> --format json
```
## 上下文传递表
`+version-save/list/revert` 分别用于快照、浏览和恢复,命中后直接执行,不预读 Help。`+history-*` 仅兼容已有调用,不用于新的 Agent 选路。重要内容更新优先使用 `+checkpoint-update`,不要手工编排保存、写入和回读。回滚必须确认,以 leaf Schema 与 Runtime gate 为准。
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `drive search` / `wiki node search` | 文档 `nodeId` / URL | doc read / info / update 等所有 `--node` 入参 |
## 权限与分享
| `drive list` / `wiki node list` | `nodes[].nodeId` | doc read / info / update / block 操作的 --node |
| `create` | `nodeId` | update / block 操作的 --node |
| `block list` | `blockId` | block insert 的 --ref-block, block update/delete 的 --block-id |
| `read --content-format jsonml` | `revision` | update --content-format jsonml 的 --revision(可选,并发检查时使用) |
| `media insert` | `resourceId` | 附件已插入文档,可通过 block list 查看附件块 |
| `media download` | 附件下载链接 `downloadUrl` | 下载文档中的附件资源 |
| `block list` | attachment 块的 `resourceId` | media download 的 --resource-id |
| `comment list` | `commentList[].commentKey` | comment reply/update/delete 的 --comment-key |
| `comment create` | `commentKey` | comment reply/update/delete 的 --comment-key |
| `comment create-inline` | `commentKey` | comment reply/update/delete 的 --comment-key |
| `block list` | `blocks[].element.id` | comment create-inline 的 --block-id |
| `block list` | `blocks[].element.paragraph.text` | 计算 create-inline 的 --start / --end 偏移量 |
| `aisearch person` | `userId` | comment create/reply/create-inline 的 --mention;drive permission 的 --user |
| `version list` | `version`(版本号) | version revert 的 --version |
| `version save` | 版本信息 | 确认保存成功 |
| `import` | `documentUrl` / `documentName` / `documentType` | 导入完成后的在线文档地址和名称 |
| `import`(中断后) | `taskId` | `import get` 的 `--task-id`(查询后取结果获取文档地址) |
- 查询或聚合权限:`+inspect --include-permissions`。
- 新增、变更、移除权限:`+access-grant/+access-change/+access-revoke`。
- 授权后发链接:`+grant-and-share`。
- 姓名、群聊或组织 profile 多候选时必须停止消歧。
## 白板卡片与白板媒体资源
## 高级原子能力
创建文档内空白板前先确认目标文档,获得用户确认后执行:
以下能力在 shortcut 未公开所需参数时才使用原子 leaf:
```bash
dws doc whiteboard insert --node <DOC_ID> --yes --format json
```
- 特殊 JSONML block、白板或样式字段
- 需要原始 MCP 响应的诊断
- shortcut 明确返回 capability unavailable 的低频操作
返回的 `blockId` 用于 `doc block delete`,`whiteboardId` 是白板 partId,用于
`dws whiteboard query/update`。两者不可混用。为白板 Vector/SVG 准备资源时:
进入高级通道前只读取对应 leaf Schema 和一个精确 Reference。原子命令不是 shortcut 失败后的自动兜底,不能用来绕过权限、确认、类型或路径检查。
```bash
dws doc media upload --node <DOC_ID> --file ./icon.svg \
--mime-type image/svg+xml --yes --format json
```
## 本地与跨产品边界
上传与后续白板更新必须使用同一 nodeId;仅使用稳定返回的 `resourceId` 和
`resourceUrl`,禁止使用临时 uploadUrl 或跨文档复用资源。白板内容协议与更新
流程见 `dingtalk-misc` 的 `references/whiteboard.md`。
- 普通文件上传、下载、目录和文件树:`dingtalk-drive`
- 知识库空间与节点层级:`dingtalk-wiki`
- 原生 Markdown 文件:`dingtalk-misc`
- `axls` / `able`:对应电子表格或多维表 Skill
## 相关产品
- [wiki](../../dingtalk-wiki/references/wiki.md) — 知识库空间级管理(创建/查询/列出/搜索知识库),doc 中的文档存储在 wiki 知识库中
- [aitable](../../dingtalk-aitable/references/aitable.md) — 结构化数据表格(行列/字段/记录),不是富文本文档
- [drive](../../dingtalk-drive/references/drive.md) — 钉盘文件存储/上传/下载,不是文档内容编辑
- [report](../../dingtalk-misc/references/report.md) — 钉钉日志系统(日报/周报模版),不是在线文档
导出或媒体错误必须保留稳定 ID 后停止。禁止隐式执行 `curl/wget`、`pip/brew install`、Python Office 库、本地 OCR 或手写 HTTP 来伪造 DWS 任务结果。
@@ -1,268 +1,29 @@
# doc block(块级精细编辑:list / insert / update / delete)
# 块级编辑 Golden Route
> **前置条件(MUST READ):** 执行本命令前,必须先用 Read 工具读取以下文件:
> 1. [`../doc.md`](../doc.md) — 命令路由 + 场景索引 + 意图判断 + 工作流
> 2. [`./style/doc-update-workflow.md`](./style/doc-update-workflow.md) — 改写流程(编辑形态优先级、JSONML validator 行为)
> 3. [`./format/doc-jsonml-cookbook.md`](./format/doc-jsonml-cookbook.md) — JSONML 范例(含 callout / 分栏 / 表格 / 标题等节点的完整命令)
> 4. [`./format/doc-jsonml-schema.md`](./format/doc-jsonml-schema.md) — JSONML 节点结构字段定义
>
> **同任务常配合**:[`doc-update.md`](./doc-update.md)(整篇 overwrite / 末尾追加纯文本)/ [`./format/doc-jsonml-cookbook.md`](./format/doc-jsonml-cookbook.md)(JSONML 复制范例)
## 普通路径
> **改写已有文档优先 JSONML**:保真度最高、callout / 分栏 / 表格 / @人 / 附件 / 颜色 / 嵌套都能 1:1 round-trip;写入端有 validator 兜底。详见 [`./style/doc-update-workflow.md` §1.3 编辑形态优先级](./style/doc-update-workflow.md)。
---
## doc block list(查询块元素)
```
Usage:
dws doc block list [flags]
Example:
dws doc block list --node <DOC_ID>
dws doc block list --node <DOC_ID> --start-index 0 --end-index 5
dws doc block list --node <DOC_ID> --block-type heading
dws doc block list --node <DOC_ID> --content-format jsonml
dws doc block list --node <DOC_ID> --content-format jsonml --block-id <UUID>
Flags:
--node string 文档 ID 或 URL (必填)
--start-index int 起始位置 (从 0 开始)
--end-index int 终止位置 (含)
--block-type string 按块类型过滤
--content-format string 输出格式: 默认为 element,可选 jsonml(返回 JSONML 节点数组)
--block-id string 指定块 UUID(content-format=jsonml 时读取完整子树)
```
### content-format=jsonml 返回示例
每个 block 包含 `jsonml` 字段(JSON string,解析后为 JSONML 数组):
```json
{
"blocks": [
{
"blockId": "mpeurp5mj5o9xz4hnj",
"blockType": "h2",
"index": 0,
"jsonml": "[\"h2\",{\"uuid\":\"mpeurp5mj5o9xz4hnj\"},...]"
}
],
"totalCount": 5
}
```
---
## doc block insert(插入块元素)
```
Usage:
dws doc block insert [flags]
Example:
dws doc block insert --node <DOC_ID> --text "这是一段文字"
dws doc block insert --node <DOC_ID> --heading "二级标题" --level 2
dws doc block insert --node <DOC_ID> --element '{"blockType":"paragraph","paragraph":{"text":"内容"}}'
dws doc block insert --node <DOC_ID> --text "在此处之前插入" --ref-block <BLOCK_ID> --where before
dws doc block insert --node <DOC_ID> --content-format jsonml --element '["p",{"uuid":"..."},["span",{"data-type":"text"},["span",{"data-type":"leaf"},"新段落"]]]'
# 插入引用块(blockquote)
dws doc block insert --node <DOC_ID> --element '{"blockType":"blockquote","blockquote":{"text":"这是一段引用内容"}}'
# 插入分栏块(columns):2 栏,children 为每栏的内容
dws doc block insert --node <DOC_ID> --element '{"blockType":"columns","columns":{"size":2},"children":[{"blockType":"paragraph","paragraph":{"text":"左栏内容"}},{"blockType":"paragraph","paragraph":{"text":"右栏内容"}}]}'
# 插入表格(table):2 行 3 列
dws doc block insert --node <DOC_ID> --element '{"blockType":"table","table":{"rolSize":2,"colSize":3,"cells":[["姓名","部门","职位"],["张三","工程部","开发"]]}}'
# 插入行内图片(inline image):构造一个空 paragraph,在 children 中用 image elementType 指定图片 src
dws doc block insert --node <DOC_ID> --element '{"blockType":"paragraph","paragraph":{},"children":[{"elementType":"image","properties":{"src":"https://example.com/photo.png"}}]}'
# 插入附件块(attachment):resourceId 为资源ID,type 为 MIME 类型,viewType 可选 preview/summary
dws doc block insert --node <DOC_ID> --element '{"blockType":"attachment","attachment":{"resourceId":"12345-xxx-xxx-123-xxxxx","type":"application/pdf","name":"报告.pdf","viewType":"preview"}}'
# 插入分割线:使用 doc update --mode append 以 Markdown 格式追加
dws doc update --node <DOC_ID> --content "---" --mode append
Flags:
--node string 文档 ID 或 URL (必填)
--text string 快捷: 段落文本内容
--heading string 快捷: 标题文本
--level int 标题级别 1-6 (配合 --heading,默认 1)
--element string 块元素 JSON (高级);content-format=jsonml 时为 JSONML 数组字符串
--content-format string 输入格式: 默认为 element,可选 jsonml
--fix-jsonml 启用 JSON 语法修复(括号/逗号补全),推荐 agent 调用时使用
--index int 参照位置索引 (从 0 开始)
--where string 插入方向: before / after (默认 after)
--ref-block string 参照块 ID (优先级高于 --index)
```
> **content-format=jsonml 完整范例**:参见 [`./format/doc-jsonml-cookbook.md`](./format/doc-jsonml-cookbook.md),包含所有节点类型的正确 insert/update 命令示例。
---
## doc block update(更新块元素)
```
Usage:
dws doc block update [flags]
Example:
dws doc block update --node <DOC_ID> --block-id <BLOCK_ID> --text "新内容"
dws doc block update --node <DOC_ID> --block-id <BLOCK_ID> --element '{"blockType":"heading","heading":{"text":"新标题","level":1}}'
dws doc block update --node <DOC_ID> --block-id <BLOCK_ID> --content-format jsonml --element '["h1",{"uuid":"<BLOCK_ID>"},["span",{"data-type":"text"},["span",{"data-type":"leaf"},"新标题"]]]'
Flags:
--node string 文档 ID 或 URL (必填)
--block-id string 目标块 ID (必填)
--text string 快捷: 段落文本内容
--heading string 快捷: 标题文本
--level int 标题级别 1-6 (配合 --heading,默认 1)
--element string 块元素 JSON (高级);content-format=jsonml 时为 JSONML 数组字符串
--content-format string 输入格式: 默认为 element,可选 jsonml
--fix-jsonml 启用 JSON 语法修复(括号/逗号补全),推荐 agent 调用时使用
```
> 使用 `--content-format jsonml` 时,`element` 中的 `uuid` **必须**等于 `--block-id`,否则报错。
---
## doc block delete(删除块元素)
> **CAUTION:** 不可逆操作 — 执行前必须向用户确认。
```
Usage:
dws doc block delete [flags]
Example:
dws doc block delete --node <DOC_ID> --block-id <BLOCK_ID> --yes
Flags:
--node string 文档 ID 或 URL (必填)
--block-id string 目标块 ID (必填)
```
---
## JSONML 格式的块操作(首选路径)
使用 `--content-format jsonml` 可以直接以 JSONML 节点格式进行块操作,覆盖所有节点类型(不限于 block element 定义的有限类型)。
先读取最小必要范围并取得稳定 block ID:
```bash
# 列出顶层 JSONML 节点
dws doc block list --node DOC_ID --content-format jsonml
# 读取指定 uuid 的完整子树
dws doc block list --node DOC_ID --content-format jsonml --block-id UUID
# 插入 JSONML 节点(同级定位)
dws doc block insert --node DOC_ID --content-format jsonml \
--element '["p", {}, ["span", {"data-type":"text"}, ["span", {"data-type":"leaf"}, "新段落"]]]' \
--ref-block UUID --where after
# 插入 JSONML 节点(容器内定位)
dws doc block insert --node DOC_ID --content-format jsonml \
--element '["p", {}, ["span", {"data-type":"text"}, ["span", {"data-type":"leaf"}, "新段落"]]]' \
--parent-block UUID --index 2
# 整体替换(update 时 uuid 必须等于 --block-id)
dws doc block update --node DOC_ID --block-id UUID --content-format jsonml \
--element '["p", {"uuid": "UUID"}, ["span", {"data-type":"text"}, ["span", {"data-type":"leaf"}, "修改后内容"]]]'
# 删除(无需 format 区分)
dws doc block delete --node DOC_ID --block-id UUID
dws doc +fetch --node <DOC_ID> --detail with-ids --scope section --start-block-id <KNOWN_BLOCK_ID> --format json
```
> 关于校验:CLI 不做结构修复,裸字符串、缺 uuid 等错误会被 validator 直接抦下。如需同时启用 JSON 语法修复(修复 LLM 遗漏的括号/逗号),用 `--fix-jsonml`。文本结构定义见 [`./format/doc-jsonml-cookbook.md`](./format/doc-jsonml-cookbook.md)。
## 关键说明
- **块类型**:paragraph、heading、blockquote、callout、columns、orderedList、unorderedList、table、sheet、attachment、slot。
- **快捷 vs --element**:`block insert` 优先使用 `--text` 或 `--heading` 快捷方式;复杂块类型(table、callout、columns 等)使用 `--element` JSON 或 `--content-format jsonml`。
- **简单内容追加**:建议用 [`./doc-update.md`](./doc-update.md) `--mode append`,不必走 block insert。
- **JSONML validator**(写入端默认行为):
- 裸字符串、缺 uuid 等结构错误会被 validator 抦下并返回带 path 的错误(如 `$[2][2]: paragraph child must be span wrapper, got raw string.`)。
- `--fix-jsonml` 开启 JSON 语法修复,推荐 agent 调用。
- **图片插入**:插入图片走 [`./doc-media.md`](./doc-media.md) `media insert`(作为附件块),不走 block insert。
- **分割线**:用 [`./doc-update.md`](./doc-update.md) `--content "---" --mode append`,不走 block insert。
## 上下文传递
| 从返回中提取 | 用于 |
|-------------|------|
| element 模式的 `blocks[].element.id`;JSONML 模式的 `blocks[].blockId` | `block insert` 的 `--ref-block`、`block update/delete` 的 `--block-id`;空文档占位空段落不要作为 `--ref-block` |
| `blocks[].element.id` | [`./doc-comment.md`](./doc-comment.md) `comment create-inline` 的 `--block-id` |
| `blocks[].element.paragraph.text` | 计算 [`./doc-comment.md`](./doc-comment.md) `comment create-inline` 的 `--start` / `--end` 偏移量 |
| attachment 块的 `resourceId` | [`./doc-media.md`](./doc-media.md) `media download` 的 `--resource-id` |
## 常用模板
再按意图使用统一更新入口:
```bash
# ── element JSON 形态(次选,老接口)──
# 文本段落
dws doc block insert --node <DOC_ID> --text "这是段落文字" --content-format element
# 标题(level 1-6)
dws doc block insert --node <DOC_ID> --heading "二级标题" --level 2 --content-format element
# 引用块
dws doc block insert --node <DOC_ID> --content-format element \
--element '{"blockType":"blockquote","blockquote":{"text":"引用内容"}}'
# 分栏(2 栏)
dws doc block insert --node <DOC_ID> --content-format element \
--element '{"blockType":"columns","columns":{"size":2},"children":[{"blockType":"paragraph","paragraph":{"text":"左栏"}},{"blockType":"paragraph","paragraph":{"text":"右栏"}}]}'
# 表格(2x3)
dws doc block insert --node <DOC_ID> --content-format element \
--element '{"blockType":"table","table":{"rolSize":2,"colSize":3,"cells":[["姓名","部门","职位"],["张三","工程部","开发"]]}}'
# Callout(element JSON 次选;首选 JSONML container[subType=colorBlocks])
dws doc block insert --node <DOC_ID> --content-format element \
--element '{"blockType":"callout","callout":{"emoji":"⚠️","bgColor":"#FDE2E0","content":[{"text":"高风险操作,先备份"}]}}'
# 在指定 block 之前插入
dws doc block insert --node <DOC_ID> --text "在此之前" --ref-block <BLOCK_ID> --where before --content-format element
# 修改某块文本
dws doc block update --node <DOC_ID> --block-id <BLOCK_ID> --text "修改后" --content-format element
# 修改标题
dws doc block update --node <DOC_ID> --block-id <BLOCK_ID> --content-format element \
--element '{"blockType":"heading","heading":{"text":"新标题","level":2}}'
# 删除(用户确认后)
dws doc block delete --node <DOC_ID> --block-id <BLOCK_ID> --yes
# ── JSONML 形态(首选)──
# 列出 + 取 uuid
dws doc block list --node <DOC_ID> --content-format jsonml
# 读单个 block 完整子树
dws doc block list --node <DOC_ID> --content-format jsonml --block-id <UUID>
# 插入段落(同级定位)
dws doc block insert --node <DOC_ID> --content-format jsonml --ref-block <UUID> --where after \
--element '["p",{},["span",{"data-type":"text"},["span",{"data-type":"leaf"},"新段落"]]]'
# 插入 callout(colorBlocks)
dws doc block insert --node <DOC_ID> --content-format jsonml --ref-block <UUID> --where after \
--element '["container",{"uuid":"co1","subType":"colorBlocks","metadata":{"bgcolor":"#FDE2E0","border":"#F5C2C7"}},["p",{"uuid":"co1p1"},["span",{"data-type":"text"},["span",{"data-type":"leaf"},"高风险操作,先备份"]]]]'
# 插入分栏(columns / column)
dws doc block insert --node <DOC_ID> --content-format jsonml --ref-block <UUID> --where after \
--element '["container",{"uuid":"cols1","subType":"columns","metadata":{"size":"2"}},["container",{"uuid":"cols1c1","subType":"column"},["p",{"uuid":"cols1c1p"},["span",{"data-type":"text"},["span",{"data-type":"leaf"},"左栏"]]]],["container",{"uuid":"cols1c2","subType":"column"},["p",{"uuid":"cols1c2p"},["span",{"data-type":"text"},["span",{"data-type":"leaf"},"右栏"]]]]]'
# 整体替换(update 的 uuid 必须等于 --block-id)
dws doc block update --node <DOC_ID> --block-id <UUID> --content-format jsonml \
--element '["p",{"uuid":"<UUID>"},["span",{"data-type":"text"},["span",{"data-type":"leaf"},"修改后内容"]]]'
# 容器内定位插入(指定 parent + index)
dws doc block insert --node <DOC_ID> --content-format jsonml --parent-block <UUID> --index 2 \
--element '["p",{},["span",{"data-type":"text"},["span",{"data-type":"leaf"},"在容器第 3 项位置插入"]]]'
dws doc +update --node <DOC_ID> --command block_replace --block-id <BLOCK_ID> --content "新内容"
dws doc +update --node <DOC_ID> --command block_insert_after --after-block-id <BLOCK_ID> --content "补充内容"
dws doc +update --node <DOC_ID> --command block_delete --block-id <BLOCK_ID>
```
## 参考
`block-id` 必须来自真实 `+fetch --detail with-ids`、`+review` 或原子 block 列表返回。确认、写入与验证统一由 `+update` 处理,正常成功不追加整篇回读。
- [`../doc.md` §意图判断](../doc.md#意图判断)(如何路由到本命令族)
- [`./doc-update.md`](./doc-update.md)(整篇改写 / 纯文本追加)
- [`./style/doc-update-workflow.md`](./style/doc-update-workflow.md)(编辑形态优先级、validator 行为)
- [`./format/doc-jsonml-cookbook.md`](./format/doc-jsonml-cookbook.md)(所有节点的可复制 JSONML 范例)
- [`./format/doc-jsonml-schema.md`](./format/doc-jsonml-schema.md)(JSONML 节点结构字段定义)
- [`./style/doc-style-guideline.md`](./style/doc-style-guideline.md)(callout 颜色 / 元素边界规范)
## 富结构专家路径
只有需要 shortcut 未公开的 callout、分栏、复杂表格或 JSONML element 参数时:
1. 按需读取 [JSONML schema](format/doc-jsonml-schema.md) 或 [cookbook](format/doc-jsonml-cookbook.md),不要两者都预加载。
2. 读取精确 `doc block` leaf Schema,确认当前 flags。
3. 用原子 block 命令只改目标块;JSONML update 的 uuid 必须等于目标 block ID。
图片和附件不得手写临时 URL 或 OSS 请求,统一走 [`doc-media.md`](doc-media.md) 的 `+media-insert/+media-download`。删除与覆盖的确认以 Runtime gate 为准,示例不得预填 `--yes`。
@@ -1,203 +1,29 @@
# doc comment(文档评论:list / create / reply / update / delete / create-inline)
# 文档评论 Golden Route
> **前置条件(MUST READ):** 执行本命令前,必须先用 Read 工具读取以下文件:
> 1. [`../doc.md`](../doc.md) — 命令路由 + 场景索引 + 意图判断 + 工作流
>
> **同任务常配合**:`dws aisearch person`(查 `--mention` 用 userId)/ `dws chat search`(查群用 openConversationId)/ [`doc-block.md`](./doc-block.md)(划词评论必须先取 blockId 与 paragraph 文本)
---
## doc comment list(查询文档评论列表)
```
Usage:
dws doc comment list [flags]
Example:
dws doc comment list --node <DOC_ID>
dws doc comment list --node <DOC_ID> --type inline --resolve-status unresolved
dws doc comment list --node <DOC_ID> --limit 20 --cursor <TOKEN>
Flags:
--node string 目标文档的标识,支持传入 URL 或 ID (必填)
--limit int 每页返回的评论数量,默认 50,最大 50
--cursor string 分页游标,从上一次请求的返回结果中获取 (首次请求不传)
--type string 按评论类型过滤: global (全文评论) / inline (划词评论)
--resolve-status string 按解决状态过滤: resolved (已解决) / unresolved (未解决)
```
---
## doc comment create(创建全文评论)
```
Usage:
dws doc comment create [flags]
Example:
dws doc comment create --node <DOC_ID> --content "这里需要修改"
dws doc comment create --node <DOC_ID> --content "请review" --mention uid1,uid2
dws doc comment create --node <DOC_ID> --content "请群内关注" --mentioned-open-conversation-id <OPEN_CID>
Flags:
--node string 目标文档的标识,支持传入 URL 或 ID (必填)
--content string 评论的文字内容,纯文本 (必填)
--mention string 被 @ 的用户 uid 列表,逗号分隔
--mentioned-open-conversation-id strings 被 @ 的群 openConversationId,可重复指定或逗号分隔
```
---
## doc comment reply(回复评论)
```
Usage:
dws doc comment reply [flags]
Example:
dws doc comment reply --node <DOC_ID> --comment-key <COMMENT_KEY> --content "同意"
dws doc comment reply --node <DOC_ID> --comment-key <COMMENT_KEY> --content "比心" --emoji
dws doc comment reply --node <DOC_ID> --comment-key <COMMENT_KEY> --content "请确认" --mention uid1,uid2
dws doc comment reply --node <DOC_ID> --comment-key <COMMENT_KEY> --content "请群内确认" --mentioned-open-conversation-id <OPEN_CID>
Flags:
--node string 目标文档的标识,支持传入 URL 或 ID (必填)
--content string 回复的文字内容,表情回复时填写表情名称 (必填)
--comment-key string 被回复评论的 commentKey,格式: {13位毫秒时间戳}{32位UUID},可从 list/create 结果获取 (必填)
--emoji 设为 true 时作为表情贴图回复 (默认 false)
--mention string 被 @ 的用户 uid 列表,逗号分隔
--mentioned-open-conversation-id strings 被 @ 的群 openConversationId,可重复指定或逗号分隔;不得与 --emoji 同时使用
```
---
## doc comment update(更新评论)
```
Usage:
dws doc comment update [flags]
Example:
dws doc comment update --node <DOC_ID> --comment-key <COMMENT_KEY> --content "已按最新数据修正"
dws doc comment update --node <DOC_ID> --comment-key <COMMENT_KEY> --content "更新后的评论内容"
dws doc comment update --node <DOC_ID> --comment-key <COMMENT_KEY> --content "请群内关注" --mentioned-open-conversation-id <OPEN_CID>
Flags:
--node string 目标文档的标识,支持传入 URL 或 ID (必填)
--comment-key string 待更新评论的 commentKey,格式: {13位毫秒时间戳}{32位UUID},可从 list/create/create-inline 结果获取 (必填)
--content string 更新后的评论文字内容,纯文本 (必填)
--mention string 被 @ 的用户 uid 列表,逗号分隔
--mentioned-open-conversation-id strings 被 @ 的群 openConversationId,可重复指定或逗号分隔
```
---
## doc comment delete(删除评论)
> **CAUTION:** 不可逆操作。必须先向用户确认。用户明确同意后,命令加 `--yes` 执行;不加 `--yes` 时 CLI 会在终端中提示输入 `yes/no`。
```
Usage:
dws doc comment delete [flags]
Example:
dws doc comment delete --node <DOC_ID> --comment-key <COMMENT_KEY> --yes
Flags:
--node string 目标文档的标识,支持传入 URL 或 ID (必填)
--comment-key string 待删除评论的 commentKey,格式: {13位毫秒时间戳}{32位UUID},可从 list/create/create-inline 结果获取 (必填)
```
---
## doc comment create-inline(创建划词评论)
> **能力边界:** `create_inline_comment` 后端当前未提供 `mentionedOpenConversationIds`,因此 `create-inline` 不支持 @群。不得只在正文写 `@群名` 冒充真实群 mention;如果用户要求划词评论 @群,应明确提示暂不支持。
```
Usage:
dws doc comment create-inline [flags]
Example:
dws doc comment create-inline --node <DOC_ID> --block-id <BLOCK_ID> --start 0 --end 10 --content "这里需要修改"
dws doc comment create-inline --node <DOC_ID> --block-id <BLOCK_ID> --start 5 --end 20 --content "建议调整" --selected-text "被选中的原文"
dws doc comment create-inline --node <DOC_ID> --block-id <BLOCK_ID> --start 0 --end 10 --content "请review" --mention uid1,uid2
Flags:
--node string 目标文档的标识,支持传入 URL 或 ID (必填)
--block-id string 评论标记所在的块 ID,可通过 dws doc block list 获取 (必填)
--start int 评论标记在块内文本中的起始字符偏移量,从 0 开始 (必填)
--end int 评论标记在块内文本中的结束字符偏移量,必须大于 start (必填)
--content string 评论的文字内容,纯文本 (必填)
--selected-text string 选中文本的内容,填写后评论列表中会展示「引用原文:xxx」
--mention string 被 @ 的用户 uid 列表,逗号分隔
```
## 关键说明
- `--mention` 接受 `userId` 列表(逗号分隔),需要先用 `dws aisearch person --keyword "<姓名>" --dimension name` 拿到 userId。
- `--mentioned-open-conversation-id` 接受群 `openConversationId`,可重复传入或逗号分隔;CLI 稳定去重并拒绝空白值,不会转换成内部 `cid`。
- 群 mention 仅支持全文评论 `create`、`update`、`reply`;`create-inline` 后端当前未提供该字段,不支持 @群。
- 真正的用户 mention 必须传 `mentionedUserIds`,真正的群 mention 必须传 `mentionedOpenConversationIds`。只在 `content` 写 `@名称` 是普通文本,不会创建 mention 节点或通知。
- 用户直接给 openConversationId 时直接使用。用户给群名时,使用当前 DWS profile 执行 `dws chat search --query "<群名>" --format json`。只在唯一匹配时取 `openConversationId`;多个同名候选必须展示群名称、组织等区分信息让用户选择;无匹配或查询失败时不得调用评论接口。
- 不按所属组织在客户端过滤或提前拒绝 mention,最终权限以评论接口为准。服务端失败时保留或准确概括 `errorCode`、`errorMessage`、`logId`,不得删除 mention 参数静默重试;若要降级为普通文本必须先征得用户同意。
- `content` 只保留正文。仅移除已成功解析成真实 mention 的明确 `@群名` token,保持原换行;普通 `@` 文本或未解析名称不得误删。
- `--comment-key` 是 13 位毫秒时间戳 + 32 位 UUID 的拼接字符串,从 `list` / `create` / `create-inline` 返回中提取,用于 `reply` / `update` / `delete`。
- 划词评论的 `--start` / `--end` 是块内文本字符偏移量,从 0 开始;通过 [`./doc-block.md`](./doc-block.md) `block list` 取 `paragraph.text` 后人工或脚本计算。
- `reply` 加 `--emoji` 时 `--content` 填表情名称(如 `比心`、`赞`),不是文字内容。
- `reply --emoji` 与群 mention 冲突;CLI 会在调用服务端前报错,不会静默忽略。
- `delete` 是不可逆操作;AI Agent 必须先让用户确认,再追加 `--yes`,避免 CLI 进入交互等待。
## 上下文传递
| 从返回中提取 | 用于 |
|-------------|------|
| `commentList[].commentKey` | `comment reply/update/delete` 的 `--comment-key` |
| `comment create` `commentKey` | `comment reply/update/delete` 的 `--comment-key` |
| `comment create-inline` `commentKey` | `comment reply/update/delete` 的 `--comment-key` |
| [`./doc-block.md`](./doc-block.md) `block list` 的 `blocks[].element.id` | `comment create-inline` 的 `--block-id` |
| [`./doc-block.md`](./doc-block.md) `block list` 的 `blocks[].element.paragraph.text` | 计算 `create-inline` 的 `--start` / `--end` 偏移量 |
| `dws aisearch person` 的 `userId` | `comment create/reply/create-inline` 的 `--mention` |
| `dws chat search` 的 `openConversationId` | `comment create/update/reply` 的 `--mentioned-open-conversation-id` |
## 常用模板
## Review 聚合
```bash
# 查看文档全部评论
dws doc comment list --node <DOC_ID> --format json
# 仅看未解决的划词评论
dws doc comment list --node <DOC_ID> --type inline --resolve-status unresolved --format json
# 创建全文评论
dws doc comment create --node <DOC_ID> --content "这里需要补充数据来源" --format json
# 创建评论 + @人(先 aisearch person 拿 userId)
dws aisearch person --keyword "张三" --dimension name --format json
# 提取 userId 后:
dws doc comment create --node <DOC_ID> --content "请确认这部分" --mention <uid1>,<uid2> --format json
# 创建评论 + @群(搜索结果唯一后取 openConversationId)
dws chat search --query "项目群" --format json
dws doc comment create --node <DOC_ID> --content "请群内关注" --mentioned-open-conversation-id <OPEN_CID> --format json
# 文字回复
dws doc comment reply --node <DOC_ID> --comment-key <COMMENT_KEY> --content "已修改" --format json
# 回复 + @群
dws doc comment reply --node <DOC_ID> --comment-key <COMMENT_KEY> --content "请群内确认" --mentioned-open-conversation-id <OPEN_CID> --format json
# 更新评论
dws doc comment update --node <DOC_ID> --comment-key <COMMENT_KEY> --content "已按最新数据修正" --format json
# 更新 + 同时 @用户和 @群
dws doc comment update --node <DOC_ID> --comment-key <COMMENT_KEY> --content "请大家确认" --mention <UID> --mentioned-open-conversation-id <OPEN_CID> --format json
# 删除评论(不可逆;必须用户确认后再加 --yes)
dws doc comment delete --node <DOC_ID> --comment-key <COMMENT_KEY> --yes --format json
# 表情回复(--content 填表情名称)
dws doc comment reply --node <DOC_ID> --comment-key <COMMENT_KEY> --content "比心" --emoji --format json
# 划词评论(先 block list 取 blockId + paragraph.text,计算 start/end)
dws doc block list --node <DOC_ID> --format json
# 计算偏移后:
dws doc comment create-inline --node <DOC_ID> --block-id <BLOCK_ID> --start 0 --end 10 --content "这里需要修改" --format json
# 划词评论 + 引用原文 + @人
dws doc comment create-inline --node <DOC_ID> --block-id <BLOCK_ID> --start 5 --end 20 --content "请确认这部分" --selected-text "被选中的原文内容" --mention <uid1>,<uid2> --format json
dws doc +review --node <DOC_ID_OR_URL> --format json
```
## 参考
需要一次看到未解决评论、引用原文和 block 上下文时优先使用 `+review`。从真实返回取得 `commentKey` 和 `blockId`,禁止按数组位置或猜测 ID。
- [`../doc.md` §意图判断](../doc.md#意图判断)(如何路由到本命令族)
- [`./doc-block.md`](./doc-block.md)(取 blockId 与块内文本以计算划词偏移)
- `dws aisearch person`(取 mention 用的 userId,跨产品命令)
## 精确评论动作
```bash
dws doc +comment-list --node <DOC_ID> --resolve-status unresolved --limit 20 --format json
dws doc +comment-list --node <DOC_ID> --limit 20 --cursor <NEXT_TOKEN> --format json
dws doc +comment-create --node <DOC_ID> --content "这里需要补充证据"
dws doc +comment-create --node <DOC_ID> --selection "计划下周发布" --content "请确认日期"
dws doc +comment-reply --node <DOC_ID> --comment-key <COMMENT_KEY> --content "已补充"
dws doc +comment-update --node <DOC_ID> --comment-key <COMMENT_KEY> --content "修订后的意见"
dws doc +comment-delete --node <DOC_ID> --comment-key <COMMENT_KEY>
```
- 全文与划词评论统一使用公开的 `+comment-create`:优先传唯一 `--selection`;已知真实 block 时传 `--block-id --start --end`,CLI 自动回读并校验 `selectedText`。不要选用未公开的 `+comment-create-inline`。
- `--mention` 传单个 uid 或逗号分隔列表,例如 `--mention 550582,123456`;不要传 JSON 数组。
- 列表用 `--limit` 控制页大小(兼容 `--page-size`),有 `nextToken` 时原样传给 `--cursor`;不能把单页当作全部评论。
- 创建、回复、删除等写操作执行前消费 leaf Schema `confirmation`;需确认时先询问,示例不得预填 `--yes`。
- 删除不可恢复,必须核对 node 与 commentKey。部分或未知结果不得自动重试。
只有 shortcut 未公开必要字段时,才读取精确原子 leaf Schema;不要加载整份评论参考或产品 Catalog 来猜参数。
@@ -1,96 +1,42 @@
# doc create(创建文档)
# 创建在线文字文档
> **前置条件(MUST READ):** 执行本命令前,必须先用 Read 工具读取以下文件:
> 1. [`../doc.md`](../doc.md) — 命令路由 + 场景索引 + 意图判断 + 工作流
> 2. [`./style/doc-create-workflow.md`](./style/doc-create-workflow.md) — 创建工作流(标题、位置、骨架、回读校验)
> 3. [`./style/doc-style-guideline.md`](./style/doc-style-guideline.md) — 排版规范(草稿元素清单、骨架样板)
> 4. [`./doc-update.md` §内容写入管道](./doc-update.md) — 长内容自动分片、`--content-file` vs `--content` 选择
> 5. [`./format/doc-jsonml-cookbook.md`](./format/doc-jsonml-cookbook.md) — 仅当使用 `--content-format jsonml` 时必读
本页只处理钉钉在线文字文档(`adoc`)创建。普通文件上传走 `dingtalk-drive`,表格和多维表分别走对应产品。
## 创建路由前置判断(必看)
> `dws doc create` 只能创建在线文字文档(adoc),**不要**用它承接所有「新建 xxx」请求。收到「创建/新建」类需求时,必须先按文件类型分流:
>
> - 用户说「创建表格 / 新建表格 / 建个电子表格 / 在线表格 / 销售数据表」等 → 走 [`dws sheet create`](../../../dingtalk-misc/references/sheet/sheet-workbook.md#创建钉钉表格文档)(钉钉在线电子表格 `axls`),**不要**走 `doc create`
> - 用户说「创建多维表格 / 新建 AI 表格 / 建个 base / 数据库表」等 → 走 [`dws aitable base create`](../../../dingtalk-aitable/references/aitable.md#base-base-管理)(多维表格 `able`),**不要**走 `doc create`
> - 用户说「创建文档 / 新建文档 / 写篇文档 / 会议纪要 / 周报 / 方案」等文字型内容 → 才走 `dws doc create`
>
> 一句话口诀:表格 → sheet/aitable;文档 → doc。
## 命令格式
```
Usage:
dws doc create [flags]
Example:
dws doc create --name "项目周报"
dws doc create --name "Q1 总结" --content "# Q1 总结" --folder <DOC_FOLDER_NODE_ID>
dws doc create --name "知识库文档" --workspace <WS_ID>
dws doc create --name "周报" --content-file ./weekly.md --folder <DOC_FOLDER_NODE_ID>
cat report.md | dws doc create --name "月报" --content -
Flags:
--name string 文档名称 (必填)
--folder string 目标文档文件夹 nodeId 或 alidocs 文件夹 URL;不要传 drive dentryId/parent-id 这类纯数字 ID
--workspace string 目标知识库 ID
--content string 文档初始内容(短文本字面量);传 - 表示从 stdin 读取
--content-file string 从文件读取文档内容(UTF-8)。推荐长/多行/表格内容使用
--content-format string 内容格式: 默认为 markdown,可选 jsonml
--fix-jsonml 启用 JSON 语法修复(括号/逗号补全),推荐 agent 调用时使用
```
## 关键说明
- **`--name` 是 H1**:正文从 `##` 开始;正文内不要再写 `#` 一级标题(除非确需且已说明动机)。
- 不传 `--folder` 和 `--workspace` 时,默认创建在「我的文档」根目录。
- `--folder` 仅接受文档文件夹 `nodeId` / `dentryUuid` / alidocs 文件夹 URL;**禁止**传入 drive `dentryId`、`parentId`、`spaceId` 这类纯数字 ID。
- 输入方式选择见 [`./doc-update.md` §内容写入管道](./doc-update.md)(与 update 共用)。短文本字面量可 `--content`,多行/表格/特殊字符必须 `--content-file` 或 `--content -`。
- 长内容(>30000 字符)CLI 自动分片:先创建空文档拿 `nodeId`,再按 markdown 标题边界切分后逐片 append;调用方无需手动编排。
## 上下文传递
| 从返回中提取 | 用于 |
|-------------|------|
| `nodeId` | [`./doc-update.md`](./doc-update.md) / [`./doc-block.md`](./doc-block.md) / [`./doc-media.md`](./doc-media.md) 的 `--node` |
| `docUrl` | 最终交付给用户的链接;缺失时用 [`./doc-info.md`](./doc-info.md) 补查 |
| `chunksWritten` | 判断是否触发自动分片;> 1 时重点检查章节顺序 |
## 回读验收(必读)
CLI **不会**自动回读校验。**每次创建后**都必须执行 `doc read --node <nodeId>` 校验关键标题、段落首句、表格表头是否完整。详见 [`./style/doc-create-workflow.md` «回读验收»](./style/doc-create-workflow.md)。
## 常用模板
## 唯一推荐入口
```bash
# 默认创建到「我的文档」根目录(推荐文件路径)
dws doc create --name "<文档名>" --content-file /tmp/<name>.md --content-format markdown
# 创建到指定文件夹
dws doc create --name "<文档名>" --content-file /tmp/<name>.md --folder <DOC_FOLDER_NODE_ID> --content-format markdown
# 创建到知识库
dws doc create --name "<文档名>" --content-file /tmp/<name>.md --workspace <WS_ID> --content-format markdown
# 创建空文档(仅取 nodeId 后再分步写入,适合 >200KB 兜底)
dws doc create --name "<文档名>" [--folder <ID> | --workspace <ID>] --content-format markdown
# 短纯文本字面量(< 2KB 且无换行/表格才允许)
dws doc create --name "<文档名>" --content "短内容" --content-format markdown
# stdin(heredoc / pipe)
cat report.md | dws doc create --name "月报" --content - --content-format markdown
# JSONML 起稿(决策型 / 对展示效果有要求时直接用 JSONML 构造)
# 详见 doc-create-workflow.md §JSONML 起稿判定
dws doc create --name "<文档名>" --content-file /tmp/<name>.json --content-format jsonml
# JSONML 创建到指定文件夹
dws doc create --name "<文档名>" --content-file /tmp/<name>.json --content-format jsonml --folder <DOC_FOLDER_NODE_ID>
dws doc +create --name "<文档名>" [--content "短文本"] [--folder <ID> | --workspace <ID>] --format json
dws doc +create --name "<文档名>" --content @body.md --format json
dws doc +create --name "<文档名>" --content @body.json --doc-format jsonml --format json
```
## 参考
- 统一输入协议:已有或临时文件先暂存到当前工作目录后传 `@相对文件`;单次生成文本可用 `--content -` 从 stdin 读取。
- `@file` 禁止绝对路径和 `..` 逃逸;不要直接引用宿主临时目录。
- `--name` 是文档名称,不等于正文中的 heading。用户明确要求“正文一级标题”时,正文仍须包含对应 H1。
- JSONML 顶层必须是数组;仅在确实需要富结构时加载 JSONML cookbook。
- [`../doc.md` §意图判断](../doc.md#意图判断)(如何路由到本命令)
- [`./doc-update.md`](./doc-update.md)(写入管道、长 markdown、追加段落、回读补救)
- [`./style/doc-create-workflow.md`](./style/doc-create-workflow.md)(创建流程 + 回读验收)
- [`./style/doc-style-guideline.md`](./style/doc-style-guideline.md)(草稿排版规范)
- [`./format/doc-jsonml-cookbook.md`](./format/doc-jsonml-cookbook.md) / [`./format/doc-jsonml-schema.md`](./format/doc-jsonml-schema.md)(JSONML 节点结构与范例)
`+create` 负责创建、长 Markdown 分片和最终回读验证。正常成功结果至少包含:
```json
{
"status": "success",
"complete": true,
"data": {
"nodeId": "...",
"verified": true
}
}
```
## 结果处理
- `status=success` 且 `verified=true`:可以报告创建完成,并保留真实 `nodeId`/URL。
- `status=partial_success`:文档或部分分片已经创建;按 `steps` 回读现状,禁止重跑整条创建。
- `status=unknown`:服务端可能已经提交;先定位并读取文档,禁止自动重试。
- 没有真实 `nodeId` 或写回执时,禁止声称“已创建”。
## 高级通道
只有 shortcut 未公开所需的底层参数或需要原始响应时,才读取精确 leaf Schema 后使用 `dws doc create`。不要因为熟悉旧参数就默认退回原子命令,也不要使用已删除的 Python 创建脚本。
复杂排版按需读取 [doc-style-guideline.md](style/doc-style-guideline.md);命令选路仍以本页的 `+create` 为准。

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