Compare commits
140
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ba5a0aece | ||
|
|
dc96ecc7a6 | ||
|
|
e49ba1ae71 | ||
|
|
77dc7d30a0 | ||
|
|
4f77f4463d | ||
|
|
70e03887d4 | ||
|
|
6ac2bbb7cf | ||
|
|
5812276f46 | ||
|
|
74baac23a1 | ||
|
|
b31eaec78d | ||
|
|
60f591b447 | ||
|
|
18511dad1d | ||
|
|
34c5118e85 | ||
|
|
6e4ea0980f | ||
|
|
3ce0e001c1 | ||
|
|
c9e5b68356 | ||
|
|
077a5c3b30 | ||
|
|
f3567fba71 | ||
|
|
e7837cdc6b | ||
|
|
30979a6cb1 | ||
|
|
88e2f8e9e2 | ||
|
|
b131726497 | ||
|
|
86ec9733c0 | ||
|
|
8a60334978 | ||
|
|
76a6980244 | ||
|
|
fcbbc0bd9a | ||
|
|
31edcc3c5a | ||
|
|
305ccf0984 | ||
|
|
c2c1131079 | ||
|
|
24ea2505a5 | ||
|
|
566e94a31e | ||
|
|
5c68e4d9cc | ||
|
|
38e387bcd6 | ||
|
|
276ab52aed | ||
|
|
12435e6e54 | ||
|
|
1d8182bcfb | ||
|
|
4243676739 | ||
|
|
4324fa72f2 | ||
|
|
ef5462a4dc | ||
|
|
bdf3048773 | ||
|
|
e1da6ba356 | ||
|
|
ae309b5846 | ||
|
|
57e23d661d | ||
|
|
9ef26055fa | ||
|
|
d1bd518043 | ||
|
|
bac4fded0d | ||
|
|
82bfddc1c2 | ||
|
|
8cf23ee7cb | ||
|
|
5777ea36e9 | ||
|
|
6eceebd701 | ||
|
|
4b898e9011 | ||
|
|
cb14ae96b3 | ||
|
|
aeb4b2dcaa | ||
|
|
09f9289deb | ||
|
|
bbb14c24dc | ||
|
|
79f4be31d5 | ||
|
|
e2a1be5e93 | ||
|
|
69911543c3 | ||
|
|
d4eba7fa96 | ||
|
|
bbc2eb111c | ||
|
|
b58b8c51bf | ||
|
|
a7678472ab | ||
|
|
f413db06be | ||
|
|
3d67d83110 | ||
|
|
9de722ab34 | ||
|
|
df088573fb | ||
|
|
a0c64e5ef4 | ||
|
|
eebd6b2a1c | ||
|
|
181f030350 | ||
|
|
6140e503ec | ||
|
|
9539ae8e40 | ||
|
|
7a140e59c3 | ||
|
|
b1bfe6002d | ||
|
|
38832448d2 | ||
|
|
8e8e3a3ce8 | ||
|
|
3d4e43f4fc | ||
|
|
155ce984c9 | ||
|
|
4b93a1cb28 | ||
|
|
1d384b9189 | ||
|
|
9f4e748404 | ||
|
|
e0dd800378 | ||
|
|
309c39a8e0 | ||
|
|
39d6caa24d | ||
|
|
0d4bd28a08 | ||
|
|
9264323b29 | ||
|
|
1744880648 | ||
|
|
246f4ebaf5 | ||
|
|
a3f5a83527 | ||
|
|
ec5f312fd6 | ||
|
|
3c81741e2e | ||
|
|
931af6af59 | ||
|
|
44311d0160 | ||
|
|
afb25ae0e9 | ||
|
|
293c085634 | ||
|
|
f81d09fb95 | ||
|
|
f8258576ef | ||
|
|
cd1ba34d96 | ||
|
|
2cc410db6c | ||
|
|
f57c002ae7 | ||
|
|
06ec207d17 | ||
|
|
b17634ef7d | ||
|
|
e0fd344a26 | ||
|
|
c38e988b14 | ||
|
|
66468c703f | ||
|
|
773e76a1c6 | ||
|
|
f4e39a219b | ||
|
|
c170a464e1 | ||
|
|
74ef426064 | ||
|
|
4665b42bbf | ||
|
|
16273de554 | ||
|
|
aabee99e3f | ||
|
|
9e3a083c27 | ||
|
|
3a0d814276 | ||
|
|
f3ddbb2db0 | ||
|
|
fbdb5e8d4d | ||
|
|
cc7e7bf0e0 | ||
|
|
461b9b773a | ||
|
|
4bd9f75231 | ||
|
|
8f8f64c391 | ||
|
|
ae9caa06af | ||
|
|
ee0c3507a5 | ||
|
|
72a9902254 | ||
|
|
5f337e0ce5 | ||
|
|
01a7b20026 | ||
|
|
62541947e7 | ||
|
|
596da1343e | ||
|
|
12c7b6eb89 | ||
|
|
bc3d92ccaf | ||
|
|
61adc87987 | ||
|
|
c515f7c1e5 | ||
|
|
8eae408e28 | ||
|
|
9f3df91584 | ||
|
|
37cccdbc0e | ||
|
|
b6851e641e | ||
|
|
3af7adaad6 | ||
|
|
e02e4a666d | ||
|
|
1f127881c9 | ||
|
|
c52f2b6e05 | ||
|
|
d5c8982c00 | ||
|
|
402429ac2a |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Drive file comments** (#961) — adds `dws drive comment list` and `dws drive comment create` for comments on ordinary preview files.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Chat automatic pagination controls** (#970) — adds bounded `--max-items` and cancellable `--page-delay` support to the core IM list shortcuts, with safe continuation metadata and truncation reporting.
|
||||
@@ -0,0 +1,34 @@
|
||||
# Release fragments
|
||||
|
||||
普通功能、修复和面向用户的行为变更不要再修改根目录 `CHANGELOG.md` 的
|
||||
`Unreleased` 区域。每个 PR 在本目录新增一个独立的 Markdown fragment,避免
|
||||
并行 PR 争用同一文件。
|
||||
|
||||
文件名使用能唯一定位变更的短名,通常是 PR 号,例如
|
||||
`1234-chat-reply-mentions.md`。文件名必须匹配
|
||||
`^[a-z0-9][a-z0-9._-]*\.md$`,且必须是普通文件,不能是符号链接。本目录顶层
|
||||
只接受 `README.md`、`released/` 和符合该规则的 fragment:fragment 一律平铺在
|
||||
顶层,不接受任何其它子目录,本目录自身也不能被替换成文件或符号链接。其余条目
|
||||
会被 CI 直接拒绝而不是忽略,以免非法条目跳过校验后拖垮下一个 PR。文件格式
|
||||
严格如下:
|
||||
|
||||
```markdown
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Chat reply mentions** (#1234) — supports mentioning selected members.
|
||||
```
|
||||
|
||||
`category` 只能是 `Added`、`Changed`、`Deprecated`、`Removed`、`Fixed` 或
|
||||
`Security`。正文至少包含一个 Markdown 列表项,且不得包含 `TODO` 或 `TBD`。
|
||||
|
||||
发布 beta 时,`scripts/release/prepare-changelog.sh` 会按分类和文件名稳定排序,
|
||||
将未归档 fragments 汇总为唯一的版本章节,并移动到
|
||||
`.changes/released/<version>/`。因此 release-seal PR 是唯一会修改
|
||||
`CHANGELOG.md` 的 PR;它同时归档已消费的 fragments,供审计追溯。
|
||||
归档只能在同一个 release-seal PR 中以原样移动完成;CI 会拒绝直接修改、
|
||||
删除或重写已归档文件。
|
||||
|
||||
无需面向用户发布说明的改动不添加 fragment。评审者根据改动是否可见来判断该
|
||||
例外是否成立。
|
||||
@@ -19,8 +19,10 @@ repeat the entire CI suite locally only to fill this checklist: CI expands the
|
||||
selected tier from documentation checks, through affected-package tests, to
|
||||
the complete high-risk suite.
|
||||
|
||||
- [ ] Exact in-place `CHANGELOG.md`-only check (otherwise `N/A`):
|
||||
`./scripts/policy/check-changelog-pr.sh --fast-path "$(git merge-base HEAD origin/main)" HEAD`
|
||||
- [ ] Release fragment added for a user-visible behavior/interface change (otherwise `N/A`):
|
||||
`.changes/<unique-name>.md`; ordinary PRs must not edit `CHANGELOG.md`.
|
||||
- [ ] Release-seal validation (otherwise `N/A`):
|
||||
`./scripts/policy/check-changelog-pr.sh --content-only "$(git merge-base HEAD origin/main)" HEAD`
|
||||
- [ ] Targeted test/check commands and results:
|
||||
- [ ] Behavior evidence (test name, CLI output shape, or before/after result):
|
||||
- [ ] Documentation links/content/rendering checked (documentation-only, otherwise
|
||||
|
||||
@@ -1288,6 +1288,12 @@ jobs:
|
||||
./scripts/policy/check-changelog-pr.sh \
|
||||
"$mode" "$PR_BASE_SHA" HEAD
|
||||
|
||||
- name: Validate release fragment lifecycle
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: ./scripts/policy/check-release-fragments.sh "$PR_BASE_SHA" HEAD
|
||||
|
||||
- name: Validate trusted main CHANGELOG-only push
|
||||
if: github.event_name == 'push' && needs.lint.outputs.changelog_only == 'true'
|
||||
env:
|
||||
|
||||
@@ -34,8 +34,8 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: read
|
||||
# 该 job 仅处理 PR;评论写入也限定在 PR Conversation 这一权限域。
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check out default branch tooling
|
||||
uses: actions/checkout@v4
|
||||
@@ -69,12 +69,10 @@ jobs:
|
||||
PARSE_ERROR: ${{ steps.parse.outputs.error }}
|
||||
run: |
|
||||
body="❌ /eval 命令解析失败:${PARSE_ERROR}"
|
||||
jq -n --arg body "$body" '{body: $body}' | curl --fail --silent --show-error \
|
||||
-X POST \
|
||||
-H "Authorization: Bearer ${GH_TOKEN}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
--data @- \
|
||||
"https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null
|
||||
gh api --method POST \
|
||||
"repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \
|
||||
--raw-field body="$body" \
|
||||
> /dev/null
|
||||
exit 1
|
||||
|
||||
- name: Verify reviewed PR head
|
||||
@@ -103,13 +101,9 @@ jobs:
|
||||
set -euo pipefail
|
||||
placeholder_body="🛰️ /eval 请求已通过权限与版本校验,正在生成可验证的评测请求。"
|
||||
response="$(
|
||||
jq -n --arg body "$placeholder_body" '{body: $body}' \
|
||||
| curl --fail --silent --show-error \
|
||||
-X POST \
|
||||
-H "Authorization: Bearer ${GH_TOKEN}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
--data @- \
|
||||
"https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments"
|
||||
gh api --method POST \
|
||||
"repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \
|
||||
--raw-field body="$placeholder_body"
|
||||
)"
|
||||
comment_id="$(
|
||||
printf '%s' "$response" \
|
||||
@@ -277,13 +271,9 @@ jobs:
|
||||
fi
|
||||
body="<!-- eval-dispatch: ${marker_json} -->"$'\n'"🛰️ /eval 已受理:产品集 \`${PRODUCTS}\`${cases_note},评测对象 \`${PR_HEAD_SHA}\`。"$'\n'"受控评测服务将在数分钟内处理,完成后由 bot 回贴报告。"
|
||||
response="$(
|
||||
jq -n --arg body "$body" '{body: $body}' \
|
||||
| curl --fail --silent --show-error \
|
||||
-X PATCH \
|
||||
-H "Authorization: Bearer ${GH_TOKEN}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
--data @- \
|
||||
"https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/comments/${DISPATCH_COMMENT_ID}"
|
||||
gh api --method PATCH \
|
||||
"repos/${GITHUB_REPOSITORY}/issues/comments/${DISPATCH_COMMENT_ID}" \
|
||||
--raw-field body="$body"
|
||||
)"
|
||||
printf '%s' "$response" \
|
||||
| jq -e \
|
||||
@@ -299,12 +289,8 @@ jobs:
|
||||
DISPATCH_COMMENT_ID: ${{ steps.placeholder.outputs.comment_id }}
|
||||
run: |
|
||||
failure_body="❌ /eval 请求准备失败,未生成可消费的评测请求。请稍后重试。"
|
||||
jq -n --arg body "$failure_body" '{body: $body}' \
|
||||
| curl --fail --silent --show-error \
|
||||
-X PATCH \
|
||||
-H "Authorization: Bearer ${GH_TOKEN}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
--data @- \
|
||||
"https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/comments/${DISPATCH_COMMENT_ID}" \
|
||||
gh api --method PATCH \
|
||||
"repos/${GITHUB_REPOSITORY}/issues/comments/${DISPATCH_COMMENT_ID}" \
|
||||
--raw-field body="$failure_body" \
|
||||
> /dev/null \
|
||||
|| true
|
||||
|
||||
@@ -2793,7 +2793,7 @@ jobs:
|
||||
fi
|
||||
if test "${{ needs.dispatch-contract.outputs.mode }}" = plan_release; then
|
||||
echo
|
||||
echo "Plan only: no tag or package was created. Add the exact \`CHANGELOG.md\` section, merge it to main, then run publish."
|
||||
echo "Plan only: no tag or package was created. Render pending \`.changes/*.md\` fragments into the exact \`CHANGELOG.md\` section, merge the release-seal PR to main, then run publish."
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
|
||||
@@ -6,6 +6,51 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.58-beta.4] - 2026-08-12
|
||||
|
||||
### Added
|
||||
|
||||
- **Multi-skill installation and upgrade** — fresh installs, `dws skill setup`,
|
||||
and `dws upgrade` now use the multi-skill layout by default. Existing mono
|
||||
installations migrate during upgrade; mono remains an explicit legacy option.
|
||||
- **Native streaming-card mentions** — `dws chat message send-card` now accepts
|
||||
`--at-open-dingtalk-ids` and `--at-all` for group cards and forwards them to
|
||||
`create_and_send_card`, matching the existing shortcut behavior without
|
||||
changing single-chat card creation.
|
||||
- **Expanded Minutes workflows** — 27 public Minutes shortcuts now cover
|
||||
upload, download, export, recording, analysis, sharing, and recovery flows;
|
||||
every write command keeps an explicit confirmation requirement.
|
||||
- **Chat command discovery** — 30 existing typed Chat commands are now
|
||||
available in the runtime Schema and Agent catalog, with sensitive writes
|
||||
carrying their required confirmation metadata.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Chat read results** — typed commands and shortcuts now expose a consistent
|
||||
top-level `messages` list with stable `messageId` and `text` fields while
|
||||
retaining existing response envelopes and fields.
|
||||
- **Wiki feed results** — Wiki feed list output now formats time fields and
|
||||
trims excess fields. Its `--limit` default is 10 and maximum is 20.
|
||||
- **Developer command results** — the `dev` and selected `devapp` commands now
|
||||
use the unified result envelope for consistent success, pending, partial,
|
||||
and failure reporting.
|
||||
- **Evaluation dispatch hardening** — `/eval` now uses a verifiable polling
|
||||
relay instead of direct access from the hosted runner, binding the workflow,
|
||||
comment, PR head, parameters, and result provenance.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Streaming-card update acknowledgement** — accepts the pre-production
|
||||
`success: true` response from `update_streaming_card` as affirmative write
|
||||
evidence while preserving explicit negative, conflicting, and bizId-drift
|
||||
failures, so Agents do not repeat an update that the service already applied.
|
||||
- **Text input bounds** — literal input, stdin, and `@file` inputs now all
|
||||
enforce the same byte limit; file reads validate the opened descriptor and
|
||||
cannot exceed the limit after a path replacement or file growth.
|
||||
- **Evaluation PR comments** — restores `/eval` PR conversation comments with
|
||||
the least required pull-request write permission and actionable GitHub 403
|
||||
diagnostics.
|
||||
|
||||
## [1.0.58-beta.3] - 2026-08-11
|
||||
|
||||
### Added
|
||||
|
||||
+4
-1
@@ -83,7 +83,10 @@ coverage is additionally selected for platform-sensitive code.
|
||||
change.
|
||||
6. Run `./scripts/release/verify-package-managers.sh` when packaging or
|
||||
installer surfaces change (run `make package` first).
|
||||
7. Update docs and `CHANGELOG.md` for behavior/interface changes.
|
||||
7. Update docs and add one `.changes/<unique-name>.md` release fragment for
|
||||
behavior/interface changes. Do not edit `CHANGELOG.md` in an ordinary PR;
|
||||
the release-seal workflow renders and archives fragments into the versioned
|
||||
changelog section.
|
||||
|
||||
## Submission Flow
|
||||
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
class DingtalkWorkspaceCliBeta < Formula
|
||||
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
|
||||
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
version "1.0.58-beta.3"
|
||||
version "1.0.58-beta.4"
|
||||
license "Apache-2.0"
|
||||
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
|
||||
|
||||
on_macos do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.3/dws-darwin-arm64.tar.gz"
|
||||
sha256 "29b4fb9e081f36a699933c0919fe7530544f62de3e27c785d27626a575a2efc2"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.4/dws-darwin-arm64.tar.gz"
|
||||
sha256 "5c2ac92e35b1f1dba80234af8b0c9505b2883f4a37c1e73892b8a1c3087b7702"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.3/dws-darwin-amd64.tar.gz"
|
||||
sha256 "a6b9c4ef212c533e02b414bd9c3be0b8d1874d4af983a7896072825bdfec1033"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.4/dws-darwin-amd64.tar.gz"
|
||||
sha256 "93ef787770105fe1f0d27585adcac7b740aa6c37ff490275c4113814541ae095"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.3/dws-linux-arm64.tar.gz"
|
||||
sha256 "ae3a9ebe151702fd05dee0c56d778a20789d98c390cf8c0a0b2ec695b57b5ec3"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.4/dws-linux-arm64.tar.gz"
|
||||
sha256 "011ce16a73d8fd24275e34c3122d3d0832c60cde2480f496018eb654059b5c05"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.3/dws-linux-amd64.tar.gz"
|
||||
sha256 "544b480701e9ec9ec5366467ad4c855fca06dea887e3d577ff50e4b3eeb13ac2"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.4/dws-linux-amd64.tar.gz"
|
||||
sha256 "847b17ff8a8d80dce38f0013eb35c77c102be16c9f98b955a632b983cd5ec104"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.3/dws-skills.zip"
|
||||
sha256 "322f1840442ff183ad4b6d4f2a2b38825ff9f96a3fcde06ba57b8f80647468ae"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.4/dws-skills.zip"
|
||||
sha256 "f5e0c72cc92cb7e8886409319cf68bbbfc7740e969bd39a389b74af4befdbc66"
|
||||
end
|
||||
|
||||
def install
|
||||
|
||||
@@ -70,15 +70,17 @@ The installer ships skills in one of two layouts. CLI commands (`dws aitable ...
|
||||
|
||||
| Mode | What gets installed | Best for |
|
||||
|------|----------------------|----------|
|
||||
| **mono** (stable, default) | One `dws` skill covering all products | Cross-product workflows; single entry point |
|
||||
| **multi** | Per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
|
||||
| **multi** (default) | Per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
|
||||
| **mono** (legacy) | One `dws` skill covering all products | Cross-product workflows; single entry point |
|
||||
|
||||
> Installs and upgrades default to `multi`. `mono` remains available via `DWS_SKILL_MODE=mono` or `dws skill setup --mode mono`. File issues if you hit problems.
|
||||
|
||||
How to pick:
|
||||
|
||||
- **Quick install** (one-liner above): non-interactive, installs `mono`.
|
||||
- **TTY install** (download then run): `curl -O .../install.sh && bash install.sh` — prompts `1) mono 2) multi` (default 1).
|
||||
- **Override via env**: `DWS_SKILL_MODE=multi curl -fsSL ... | sh`.
|
||||
- **Switch later**: `dws skill setup --mode multi` (or `--mode mono`) — re-run any time.
|
||||
- **Quick install** (one-liner above): non-interactive, installs `multi`.
|
||||
- **TTY install** (download then run): `curl -O .../install.sh && bash install.sh` — prompts `1) multi 2) mono` (default 1).
|
||||
- **Override via env**: `DWS_SKILL_MODE=mono curl -fsSL ... | sh`.
|
||||
- **Switch later**: `dws skill setup --mode mono` (or `--mode multi`) — review the listed paths and confirm interactively.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -208,7 +210,7 @@ The verifier uses isolated directories and does not replace the `dws` on the cur
|
||||
The upgrade process follows a two-phase atomic flow to ensure consistency:
|
||||
|
||||
1. **Prepare** — downloads the platform-specific binary and skill packages to a temporary directory, verifies SHA256 checksums, and extracts/validates all files. If any step fails, the upgrade aborts without modifying the existing installation.
|
||||
2. **Apply** — only after all preparations succeed, the binary is replaced and skill packages are installed to all detected agent directories (`~/.agents/skills/dws`, `~/.claude/skills/dws`, `~/.cursor/skills/dws`, etc.).
|
||||
2. **Apply** — only after all preparations succeed, the binary is replaced and skills are flattened into detected agent-specific roots (for example `~/.codex/skills/dingtalk-chat`). `~/.agents/skills` is used only when no specific Agent is detected; once a specific root is active, older DWS-managed generic copies are backed up and retired so the same Skill is not discovered twice.
|
||||
|
||||
A backup of the current version is automatically created before each upgrade. Use `dws upgrade --rollback` to restore the previous version if needed.
|
||||
|
||||
@@ -391,19 +393,19 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
|
||||
|
||||
The repo ships a complete Agent Skill system under `skills/`, organized into two layouts:
|
||||
|
||||
- `skills/mono/` — single-skill layout (one `SKILL.md` + `references/products/`), recommended default.
|
||||
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ...), each with its own `SKILL.md`.
|
||||
- `skills/mono/` — single-skill layout (one `SKILL.md` + `references/products/`), legacy.
|
||||
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ...), each with its own `SKILL.md`. Default layout.
|
||||
|
||||
Leaf safety/parameters/selection prose for Schema generation come from ProductDecl / ContractFinal declarations in Go. The former `internal/cli/schema_hints/` HintFile tree is fully retired and must not reappear.
|
||||
|
||||
After installing, AI tools like Claude Code / Cursor can operate DingTalk directly through natural language:
|
||||
|
||||
```bash
|
||||
# Install skills into current project (defaults to mono)
|
||||
# Install skills into current project (defaults to multi; DWS_SKILL_MODE=mono switches back)
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
|
||||
```
|
||||
|
||||
> `install.sh` installs to `$HOME/.agents/skills/dws` (global); `install-skills.sh` installs to `./.agents/skills/dws` (current project).
|
||||
> Installers prefer detected agent-specific roots such as `$HOME/.codex/skills/`. They use `.agents/skills/` only as the generic fallback when no specific Agent is detected; multi layout is per-product siblings, while mono uses the `dws/` subdirectory.
|
||||
>
|
||||
> China users: prefix `DWS_GITEE_REPO` to use the Gitee mirror — see [China mirror](#china-mirror).
|
||||
|
||||
@@ -413,22 +415,31 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace
|
||||
# Interactive: prompts for mode + target agents
|
||||
dws skill setup
|
||||
|
||||
# Install mono skill to every detected agent home (claude / cursor / codex / opencode / qoder)
|
||||
dws skill setup --mode mono --target all --yes
|
||||
# Preview the exact directories that mono setup would back up and replace
|
||||
dws skill setup --mode mono --target all --dry-run
|
||||
|
||||
# Install multi skills to a single agent home
|
||||
dws skill setup --mode multi --target cursor --yes
|
||||
# Run interactively and confirm the listed directories
|
||||
dws skill setup --mode mono --target all
|
||||
|
||||
# Point at a local source tree (e.g. a fork or work-in-progress)
|
||||
# Preview, then install multi skills to a single agent home with interactive confirmation
|
||||
dws skill setup --mode multi --target cursor --dry-run
|
||||
dws skill setup --mode multi --target cursor
|
||||
|
||||
# Point at a local source tree (e.g. a fork or work-in-progress), preview first
|
||||
DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi --dry-run
|
||||
DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
|
||||
```
|
||||
|
||||
| Flag | Values | Description |
|
||||
|------|--------|-------------|
|
||||
| `--mode` | `mono` \| `multi` | Skill layout; defaults to interactive prompt |
|
||||
| `--target` | `all` \| `claude` \| `cursor` \| `codex` \| `opencode` \| `qoder` | Where to install; `all` covers every detected agent home |
|
||||
| `--target` | `all` \| `claude` \| `cursor` \| `codex` \| `zcode` \| `opencode` \| `qoder` | Where to install; `all` covers every detected agent home, including ZCode at `~/.zcode/skills` |
|
||||
| `--source` | path | Local source directory (overrides bundled skills) |
|
||||
| `--yes` | — | Skip confirmation prompts |
|
||||
| `--yes` | — | Scripting-only: skip the confirmation prompt. Removals are still backed up to `~/.dws/skill-backups/` first |
|
||||
|
||||
> The setup command can remove the opposite-mode layout (`dws/` for multi, DWS-managed multi Skills for mono) and stale managed Skills not in the bundle. DWS records ownership, installer version, source, and content digest centrally in `~/.dws/skills-state.json` (or `$DWS_CONFIG_DIR/skills-state.json`). Exact official names shipped before the centralized state remain a frozen migration list. A `dingtalk-*` prefix alone never authorizes cleanup, so other same-prefix market/user Skills are preserved. Every removal is previewed before confirmation and preserved under `~/.dws/skill-backups/<timestamp>/`; a directory that cannot be backed up is never removed. In a non-interactive shell, first run `--dry-run` and inspect its output; only then may the caller explicitly choose the scripting-only confirmation bypass.
|
||||
|
||||
After a multi setup or upgrade, DWS stores the official bundle snapshot and centralized ownership metadata in `~/.dws/skills-state.json` (or `$DWS_CONFIG_DIR/skills-state.json`). Every upgrade installs and overwrites the complete bundled Skill set from that release. Deleting or excluding a bundled Skill is not sticky: the next upgrade restores it. `dws upgrade --force` additionally allows reinstalling the current CLI version when no newer version is available.
|
||||
|
||||
Env vars: `DWS_SKILL_MODE=mono|multi` (also honored by `install.sh` / `install.ps1`), `DWS_SKILL_SOURCE=<path>`.
|
||||
|
||||
@@ -726,7 +737,7 @@ See [`docs/robot-quickstart.md`](./docs/robot-quickstart.md) for the full 4-step
|
||||
<summary>Coming soon</summary>
|
||||
|
||||
- `conference` (video meetings)
|
||||
- Multi-skill mode (experimental) — per-product skills under `skills/multi/`; opt in via `dws skill setup --mode multi`
|
||||
- Multi-skill mode (default) — per-product skills under `skills/multi/`; installs and upgrades default to it, `dws skill setup --mode mono` switches back after interactive confirmation
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+30
-19
@@ -70,15 +70,17 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
|
||||
|
||||
| 模式 | 安装内容 | 适合场景 |
|
||||
|------|----------|----------|
|
||||
| **mono**(稳定,默认) | 一个 `dws` skill,覆盖全部产品 | 跨产品组合操作;单一入口召唤 |
|
||||
| **multi** | 按产品拆分的独立 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
|
||||
| **multi**(默认) | 按产品拆分的独立 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
|
||||
| **mono**(legacy) | 一个 `dws` skill,覆盖全部产品 | 跨产品组合操作;单一入口召唤 |
|
||||
|
||||
> 安装与升级默认均为 multi。mono 仍可通过 `DWS_SKILL_MODE=mono` 或 `dws skill setup --mode mono` 使用。问题请提 issue 反馈。
|
||||
|
||||
怎么选:
|
||||
|
||||
- **快速安装**(上方一行 curl):非交互,默认装 `mono`。
|
||||
- **TTY 安装**(先下载再执行):`curl -O .../install.sh && bash install.sh`,会弹出 `1) mono 2) multi` 选项(默认 1)。
|
||||
- **环境变量覆盖**:`DWS_SKILL_MODE=multi curl -fsSL ... | sh`。
|
||||
- **装完之后再切换**:`dws skill setup --mode multi`(或 `--mode mono`),随时重跑都行。
|
||||
- **快速安装**(上方一行 curl):非交互,默认装 `multi`。
|
||||
- **TTY 安装**(先下载再执行):`curl -O .../install.sh && bash install.sh`,会弹出 `1) multi 2) mono` 选项(默认 1)。
|
||||
- **环境变量覆盖**:`DWS_SKILL_MODE=mono curl -fsSL ... | sh`。
|
||||
- **装完之后再切换**:`dws skill setup --mode mono`(或 `--mode multi`),核对列出的路径后交互确认。
|
||||
|
||||
</details>
|
||||
|
||||
@@ -205,7 +207,7 @@ bash verify-all-channels.sh
|
||||
升级过程采用两阶段原子流程,确保一致性:
|
||||
|
||||
1. **准备阶段** — 将平台对应的二进制文件和技能包下载到临时目录,校验 SHA256 校验和,解压并验证所有文件。任何步骤失败则立即中止,不会修改现有安装。
|
||||
2. **执行阶段** — 仅在所有准备工作成功后,替换二进制文件并将技能包安装到所有已检测到的 Agent 目录(`~/.agents/skills/dws`、`~/.claude/skills/dws`、`~/.cursor/skills/dws` 等)。
|
||||
2. **执行阶段** — 仅在所有准备工作成功后,替换二进制文件并将技能包平铺到已检测到的具体 Agent 目录(例如 `~/.codex/skills/dingtalk-chat`、`~/.claude/skills/dingtalk-chat`)。只有未检测到具体 Agent 时才使用 `~/.agents/skills`;检测到具体 Agent 后会备份迁走旧的 DWS 通用副本,避免同一 Skill 被重复发现。
|
||||
|
||||
每次升级前自动备份当前版本,可通过 `dws upgrade --rollback` 随时回滚。
|
||||
|
||||
@@ -385,19 +387,19 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
|
||||
|
||||
仓库内置完整的 Agent Skill 体系(`skills/` 目录),分为两套布局:
|
||||
|
||||
- `skills/mono/` — 单 skill 布局(一个 `SKILL.md` + `references/products/`),默认推荐。
|
||||
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ...),每个 skill 自带 `SKILL.md`。
|
||||
- `skills/mono/` — 单 skill 布局(一个 `SKILL.md` + `references/products/`),legacy。
|
||||
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ...),每个 skill 自带 `SKILL.md`。默认布局。
|
||||
|
||||
Schema 生成的叶子 safety/参数/选型文案由 Go 中的 ProductDecl / ContractFinal 声明驱动。原 `internal/cli/schema_hints/` HintFile 目录已完全退役,不得重新引入。
|
||||
|
||||
安装之后,Claude Code / Cursor 等 AI 工具就能通过自然语言直接操作钉钉:
|
||||
|
||||
```bash
|
||||
# 安装 skills 到当前项目(默认 mono)
|
||||
# 安装 skills 到当前项目(默认 multi;DWS_SKILL_MODE=mono 可切回)
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
|
||||
```
|
||||
|
||||
> `install.sh` 安装到 `$HOME/.agents/skills/dws`(全局);`install-skills.sh` 安装到 `./.agents/skills/dws`(当前项目)。
|
||||
> 安装器优先使用检测到的具体 Agent 根目录(如 `$HOME/.codex/skills/`);仅在未检测到具体 Agent 时回退到 `.agents/skills/`。multi 为按产品平铺,mono 为 `dws/` 子目录。
|
||||
>
|
||||
> 国内用户加 `DWS_GITEE_REPO` 走 Gitee 镜像,见 [国内加速安装](#国内加速安装)。
|
||||
|
||||
@@ -407,22 +409,31 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace
|
||||
# 交互式:提示选模式 + 目标 Agent
|
||||
dws skill setup
|
||||
|
||||
# 把 mono skill 铺到所有检测到的 Agent home(claude / cursor / codex / opencode / qoder)
|
||||
dws skill setup --mode mono --target all --yes
|
||||
# 先预览 mono setup 将备份和替换的精确目录
|
||||
dws skill setup --mode mono --target all --dry-run
|
||||
|
||||
# 只装到某一个 Agent home
|
||||
dws skill setup --mode multi --target cursor --yes
|
||||
# 交互执行并确认列出的目录
|
||||
dws skill setup --mode mono --target all
|
||||
|
||||
# 指定本地源目录(比如 fork 或正在改的版本)
|
||||
# 先预览,再交互确认装到某一个 Agent home
|
||||
dws skill setup --mode multi --target cursor --dry-run
|
||||
dws skill setup --mode multi --target cursor
|
||||
|
||||
# 指定本地源目录(比如 fork 或正在改的版本),先预览
|
||||
DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi --dry-run
|
||||
DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
|
||||
```
|
||||
|
||||
| 参数 | 取值 | 说明 |
|
||||
|------|------|------|
|
||||
| `--mode` | `mono` \| `multi` | skill 布局,不指定则交互式询问 |
|
||||
| `--target` | `all` \| `claude` \| `cursor` \| `codex` \| `opencode` \| `qoder` | 安装目标,`all` 表示铺到所有检测到的 Agent home |
|
||||
| `--target` | `all` \| `claude` \| `cursor` \| `codex` \| `zcode` \| `opencode` \| `qoder` | 安装目标;`all` 表示铺到检测到的具体 Agent home(ZCode 为 `~/.zcode/skills`),仅在未检测到具体 Agent 时回退到 `~/.agents/skills` |
|
||||
| `--source` | 路径 | 本地源目录(覆盖内置 skills) |
|
||||
| `--yes` | — | 跳过确认提示 |
|
||||
| `--yes` | — | 仅供脚本使用:跳过确认提示。删除操作仍会先备份到 `~/.dws/skill-backups/` |
|
||||
|
||||
> setup 命令可能移除对面模式残留(装 multi 删 `dws/`,装 mono 清理统一状态中登记或属于状态上线前精确官方名称集合的 multi Skill)以及不在 bundle 内的过期受管 Skill。DWS 在 `~/.dws/skills-state.json`(或 `$DWS_CONFIG_DIR/skills-state.json`)集中记录所有权、安装版本、来源和内容摘要。仅有 `dingtalk-*` 前缀不能触发清理,因此其他同前缀市场/用户 Skill 会保留。所有删除都会先列入确认预览,并备份到 `~/.dws/skill-backups/<时间戳>/`;备份失败的目录会保留原样、绝不删除。非交互环境应先用 `--dry-run` 核对输出,再由调用方显式决定是否使用仅供脚本的确认跳过参数。
|
||||
|
||||
multi setup 或 upgrade 后,DWS 会把官方 bundle 快照和统一所有权元数据写入 `~/.dws/skills-state.json`(或 `$DWS_CONFIG_DIR/skills-state.json`)。每次 upgrade 都会安装并覆盖该版本的全部预制 Skill;手工删除或通过 setup 排除预制 Skill 不会永久保留,下次 upgrade 会恢复。`dws upgrade --force` 还允许在没有新版本时重装当前 CLI 版本。
|
||||
|
||||
环境变量:`DWS_SKILL_MODE=mono|multi`(`install.sh` / `install.ps1` 也认)、`DWS_SKILL_SOURCE=<路径>`。
|
||||
|
||||
@@ -715,7 +726,7 @@ dws dev connect --channel auto --robot-client-id <id> --robot-client-secret <sec
|
||||
<summary>即将推出</summary>
|
||||
|
||||
- `conference`(视频会议)
|
||||
- 多 skill 模式(实验中)— 每产品一个独立 skill,位于 `skills/multi/`,通过 `dws skill setup --mode multi` 启用
|
||||
- 多 skill 模式(默认)— 每产品一个独立 skill,位于 `skills/multi/`,安装与升级默认启用;`dws skill setup --mode mono` 交互确认后可切回单 skill
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+637
-17
@@ -3,6 +3,7 @@
|
||||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
const crypto = require("crypto");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const childProcess = require("child_process");
|
||||
@@ -16,6 +17,7 @@ const AGENT_DIRS = [
|
||||
".qoderwork/skills",
|
||||
".gemini/skills",
|
||||
".codex/skills",
|
||||
".zcode/skills",
|
||||
".github/skills",
|
||||
".windsurf/skills",
|
||||
".augment/skills",
|
||||
@@ -45,6 +47,58 @@ function ensureCleanDir(dir) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
// backupStamp returns the UTC timestamp used for backup directory names,
|
||||
// matching the shell installers' `date -u +%Y%m%d-%H%M%S` layout.
|
||||
function backupStamp() {
|
||||
const d = new Date();
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return (
|
||||
`${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}` +
|
||||
`-${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}`
|
||||
);
|
||||
}
|
||||
|
||||
// backupAndRemoveSkillDir moves dir into <homeDir>/.dws/skill-backups/
|
||||
// <stamp>/<rel-or-basename> instead of destroying it (non-interactive
|
||||
// installs cannot confirm, so removals must stay reversible). Missing paths
|
||||
// are a no-op success. On any backup failure the directory is left in place
|
||||
// and false is returned so callers skip that target rather than silently
|
||||
// deleting data.
|
||||
function backupAndRemoveSkillDir(homeDir, dir, backups = null, renameFn = fs.renameSync) {
|
||||
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
|
||||
return true;
|
||||
}
|
||||
const rel = path.relative(homeDir, dir);
|
||||
const name =
|
||||
rel && rel !== "." && !rel.startsWith("..") && !path.isAbsolute(rel)
|
||||
? rel.split(path.sep).join("-")
|
||||
: path.basename(dir);
|
||||
const stamp = backupStamp();
|
||||
const backupRoot = path.join(homeDir, ".dws", "skill-backups");
|
||||
let targetRoot = path.join(backupRoot, stamp);
|
||||
let target = path.join(targetRoot, name);
|
||||
for (let i = 1; fs.existsSync(target); i++) {
|
||||
if (i > 1000) {
|
||||
console.warn(`⚠️ 备份目录冲突,保留原目录 ${dir}`);
|
||||
return false;
|
||||
}
|
||||
targetRoot = path.join(backupRoot, `${stamp}-${i}`);
|
||||
target = path.join(targetRoot, name);
|
||||
}
|
||||
try {
|
||||
fs.mkdirSync(targetRoot, { recursive: true });
|
||||
renameFn(dir, target);
|
||||
} catch (err) {
|
||||
console.warn(`⚠️ 备份失败,保留原目录 ${dir}: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
if (backups) {
|
||||
backups.push({ original: dir, backup: target });
|
||||
}
|
||||
console.log(` × 已备份并移除 ${dir} → ${target}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
function findBinary(root) {
|
||||
const entries = fs.readdirSync(root, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
@@ -117,47 +171,587 @@ function copyChildren(srcDir, destDir) {
|
||||
}
|
||||
}
|
||||
|
||||
// publishCacheAtomically prepares a complete sibling tree before replacing a
|
||||
// cache. If copying or publishing fails, the previous cache stays available.
|
||||
// copyFn is injectable so the failure contract can be tested without relying
|
||||
// on platform-specific permission behavior.
|
||||
function publishCacheAtomically(sourceDir, cacheDir, copyFn = copyChildren) {
|
||||
const cacheParent = path.dirname(cacheDir);
|
||||
const cacheName = path.basename(cacheDir);
|
||||
fs.mkdirSync(cacheParent, { recursive: true });
|
||||
|
||||
const stagedDir = fs.mkdtempSync(path.join(cacheParent, `.${cacheName}.tmp-`));
|
||||
let rollbackDir = "";
|
||||
let published = false;
|
||||
try {
|
||||
copyFn(sourceDir, stagedDir);
|
||||
|
||||
if (fs.existsSync(cacheDir)) {
|
||||
rollbackDir = fs.mkdtempSync(path.join(cacheParent, `.${cacheName}.old-`));
|
||||
fs.rmSync(rollbackDir, { recursive: true, force: true });
|
||||
fs.renameSync(cacheDir, rollbackDir);
|
||||
}
|
||||
|
||||
try {
|
||||
fs.renameSync(stagedDir, cacheDir);
|
||||
published = true;
|
||||
} catch (publishErr) {
|
||||
if (rollbackDir) {
|
||||
try {
|
||||
fs.renameSync(rollbackDir, cacheDir);
|
||||
rollbackDir = "";
|
||||
} catch (restoreErr) {
|
||||
throw new Error(
|
||||
`failed to publish cache ${cacheDir}: ${publishErr.message}; ` +
|
||||
`failed to restore previous cache from ${rollbackDir}: ${restoreErr.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
throw publishErr;
|
||||
}
|
||||
|
||||
if (rollbackDir) {
|
||||
try {
|
||||
fs.rmSync(rollbackDir, { recursive: true, force: true });
|
||||
} catch (cleanupErr) {
|
||||
console.warn(
|
||||
`⚠️ New cache is active, but old cache cleanup failed at ${rollbackDir}: ${cleanupErr.message}`,
|
||||
);
|
||||
}
|
||||
rollbackDir = "";
|
||||
}
|
||||
} finally {
|
||||
if (!published) {
|
||||
fs.rmSync(stagedDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function installSkillsToHomes(skillRoot) {
|
||||
const homeDir = os.homedir();
|
||||
const managedNames = readManagedSkillNames(homeDir);
|
||||
let installed = 0;
|
||||
let attempted = 0;
|
||||
let failed = 0;
|
||||
|
||||
const specificAgentDirs = AGENT_DIRS.slice(1).filter((agentDir) =>
|
||||
fs.existsSync(path.dirname(path.join(homeDir, agentDir))),
|
||||
);
|
||||
|
||||
const installToBase = (baseDir) => {
|
||||
const victims = [path.join(baseDir, "dws")];
|
||||
if (fs.existsSync(baseDir)) {
|
||||
for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory() && isManagedMultiSkillDir(path.join(baseDir, entry.name), managedNames)) {
|
||||
victims.push(path.join(baseDir, entry.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
publishManagedMonoSkillSetAtomically(homeDir, skillRoot, baseDir, victims);
|
||||
} catch (err) {
|
||||
console.warn(`⚠️ 跳过 ${baseDir}(mono 集合发布失败,已回滚): ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
AGENT_DIRS.forEach((agentDir, index) => {
|
||||
if (index === 0 && specificAgentDirs.length > 0) {
|
||||
return;
|
||||
}
|
||||
const baseDir = path.join(homeDir, agentDir);
|
||||
const parentGate = path.dirname(baseDir);
|
||||
if (index > 0 && !fs.existsSync(parentGate)) {
|
||||
return;
|
||||
}
|
||||
const destDir = path.join(baseDir, "dws");
|
||||
fs.rmSync(destDir, { recursive: true, force: true });
|
||||
copyChildren(skillRoot, destDir);
|
||||
installed += 1;
|
||||
attempted += 1;
|
||||
if (installToBase(baseDir)) {
|
||||
installed += 1;
|
||||
} else {
|
||||
failed += 1;
|
||||
}
|
||||
});
|
||||
|
||||
if (installed === 0) {
|
||||
copyChildren(skillRoot, path.join(homeDir, ".agents", "skills", "dws"));
|
||||
if (specificAgentDirs.length > 0 && installed > 0) {
|
||||
try {
|
||||
retireGenericSkillRoot(homeDir, managedNames);
|
||||
} catch (err) {
|
||||
console.warn(`⚠️ 通用 Skill 副本迁移失败: ${err.message}`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (attempted === 0) {
|
||||
if (installToBase(path.join(homeDir, ".agents", "skills"))) {
|
||||
installed += 1;
|
||||
} else {
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
if (installed === 0) {
|
||||
throw new Error("未安装任何 mono Skill:所有检测到的 Agent 目标均失败");
|
||||
}
|
||||
if (failed > 0) {
|
||||
throw new Error(`有 ${failed} 个 Agent 目标安装 mono Skill 失败`);
|
||||
}
|
||||
fs.rmSync(path.join(skillStateDir(homeDir), "skills-state.json"), { force: true });
|
||||
}
|
||||
|
||||
// multiTreeHasSkills mirrors multi_tree_has_skills in scripts/install.sh and
|
||||
// Test-MultiTreeHasSkills in scripts/install.ps1: true only when the multi
|
||||
// bundle carries at least one product skill (a subdir with SKILL.md). An
|
||||
// empty or corrupt multi/ tree must never select the multi branch nor refresh
|
||||
// the multi cache — installing it would wipe existing skills and lay down
|
||||
// nothing.
|
||||
function multiTreeHasSkills(dir) {
|
||||
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
|
||||
return false;
|
||||
}
|
||||
return fs
|
||||
.readdirSync(dir, { withFileTypes: true })
|
||||
.some((e) => e.isDirectory() && fs.existsSync(path.join(dir, e.name, "SKILL.md")));
|
||||
}
|
||||
|
||||
const MANAGED_SKILL_DIGEST_SCOPE = "skill-directory-v1";
|
||||
// Frozen exact names shipped before centralized ownership metadata. Retired
|
||||
// names stay here so old installs can be migrated without treating every
|
||||
// dingtalk-* directory as DWS-owned.
|
||||
const LEGACY_OFFICIAL_MULTI_SKILLS = new Set([
|
||||
"dingtalk-agoal", "dingtalk-aiapp", "dingtalk-aisearch", "dingtalk-aitable",
|
||||
"dingtalk-attendance", "dingtalk-calendar", "dingtalk-chat", "dingtalk-contact",
|
||||
"dingtalk-dev", "dingtalk-devapp", "dingtalk-devdoc", "dingtalk-ding",
|
||||
"dingtalk-doc", "dingtalk-drive", "dingtalk-event", "dingtalk-hrbrain",
|
||||
"dingtalk-live", "dingtalk-mail", "dingtalk-markdown", "dingtalk-minutes",
|
||||
"dingtalk-misc", "dingtalk-oa", "dingtalk-pat", "dingtalk-profile",
|
||||
"dingtalk-report", "dingtalk-shared", "dingtalk-sheet", "dingtalk-skill",
|
||||
"dingtalk-todo", "dingtalk-wiki", "dws-shared",
|
||||
]);
|
||||
|
||||
function skillStateDir(homeDir) {
|
||||
return (process.env.DWS_CONFIG_DIR || "").trim() || path.join(homeDir, ".dws");
|
||||
}
|
||||
|
||||
function readManagedSkillNames(homeDir) {
|
||||
try {
|
||||
const state = JSON.parse(fs.readFileSync(path.join(skillStateDir(homeDir), "skills-state.json"), "utf8"));
|
||||
return new Set((state.managed_skills || []).map((record) => record.name).filter(Boolean));
|
||||
} catch (_) {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
function isManagedMultiSkillDir(dir, managedNames) {
|
||||
const name = path.basename(dir);
|
||||
return LEGACY_OFFICIAL_MULTI_SKILLS.has(name) || managedNames.has(name);
|
||||
}
|
||||
|
||||
function retireGenericSkillRoot(homeDir, managedNames) {
|
||||
const baseDir = path.join(homeDir, ".agents", "skills");
|
||||
const victims = [path.join(baseDir, "dws")];
|
||||
if (fs.existsSync(baseDir)) {
|
||||
for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory() && isManagedMultiSkillDir(path.join(baseDir, entry.name), managedNames)) {
|
||||
victims.push(path.join(baseDir, entry.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
const backups = [];
|
||||
try {
|
||||
for (const victim of victims) {
|
||||
if (!backupAndRemoveSkillDir(homeDir, victim, backups)) {
|
||||
throw new Error(`failed to back up Skill directory ${victim}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const restoreErrors = [];
|
||||
for (let i = backups.length - 1; i >= 0; i -= 1) {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(backups[i].original), { recursive: true });
|
||||
fs.renameSync(backups[i].backup, backups[i].original);
|
||||
} catch (restoreErr) {
|
||||
restoreErrors.push(`${backups[i].original}: ${restoreErr.message}`);
|
||||
}
|
||||
}
|
||||
if (restoreErrors.length > 0) {
|
||||
throw new Error(`${err.message}; generic-root rollback failed: ${restoreErrors.join("; ")}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function skillDirectoryDigest(dir) {
|
||||
const files = [];
|
||||
const visit = (current, prefix) => {
|
||||
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
||||
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
||||
const full = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
visit(full, rel);
|
||||
} else {
|
||||
files.push({ rel, full });
|
||||
}
|
||||
}
|
||||
};
|
||||
visit(dir, "");
|
||||
files.sort((a, b) => Buffer.from(a.rel).compare(Buffer.from(b.rel)));
|
||||
const hash = crypto.createHash("sha256");
|
||||
for (const file of files) {
|
||||
hash.update(file.rel, "utf8");
|
||||
hash.update(Buffer.from([0]));
|
||||
hash.update(fs.readFileSync(file.full));
|
||||
hash.update(Buffer.from([0]));
|
||||
}
|
||||
return `sha256:${hash.digest("hex")}`;
|
||||
}
|
||||
|
||||
// Publish a complete multi-skill set as one transaction. The entire new set
|
||||
// is staged before any Agent-visible directory moves. If a later backup or
|
||||
// publish fails, every partial publication is removed and all old directories
|
||||
// are restored from their exact backup paths.
|
||||
function publishManagedMultiSkillSetAtomically(
|
||||
homeDir,
|
||||
multiRoot,
|
||||
baseDir,
|
||||
skills,
|
||||
victims,
|
||||
options = {},
|
||||
) {
|
||||
const copyFn = options.copyFn || copyChildren;
|
||||
const renameFn = options.renameFn || fs.renameSync;
|
||||
const removeFn = options.removeFn || ((dir) => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
fs.mkdirSync(baseDir, { recursive: true });
|
||||
const stageRoot = fs.mkdtempSync(path.join(baseDir, ".dws-multi-set.tmp-"));
|
||||
const staged = [];
|
||||
const backups = [];
|
||||
const published = [];
|
||||
|
||||
const restore = () => {
|
||||
const restoreErrors = [];
|
||||
for (let i = published.length - 1; i >= 0; i -= 1) {
|
||||
try {
|
||||
removeFn(published[i]);
|
||||
} catch (err) {
|
||||
restoreErrors.push(`remove ${published[i]}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
for (let i = backups.length - 1; i >= 0; i -= 1) {
|
||||
const item = backups[i];
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(item.original), { recursive: true });
|
||||
renameFn(item.backup, item.original);
|
||||
} catch (err) {
|
||||
restoreErrors.push(`restore ${item.original} from ${item.backup}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
if (restoreErrors.length > 0) {
|
||||
throw new Error(restoreErrors.join("; "));
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
for (const name of skills) {
|
||||
const stagedDir = path.join(stageRoot, name);
|
||||
copyFn(path.join(multiRoot, name), stagedDir);
|
||||
staged.push({ staged: stagedDir, dest: path.join(baseDir, name) });
|
||||
}
|
||||
|
||||
const seen = new Set();
|
||||
for (const victim of victims) {
|
||||
const normalized = path.resolve(victim);
|
||||
if (seen.has(normalized)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(normalized);
|
||||
if (!backupAndRemoveSkillDir(homeDir, victim, backups, renameFn)) {
|
||||
throw new Error(`failed to back up Skill directory ${victim}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of staged) {
|
||||
renameFn(item.staged, item.dest);
|
||||
published.push(item.dest);
|
||||
}
|
||||
} catch (err) {
|
||||
try {
|
||||
restore();
|
||||
} catch (restoreErr) {
|
||||
throw new Error(`${err.message}; rollback failed: ${restoreErr.message}`);
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
removeFn(stageRoot);
|
||||
}
|
||||
}
|
||||
|
||||
// Publish mono plus every mutually-exclusive managed multi victim as one
|
||||
// transaction. The complete dws/ tree is staged before any live directory is
|
||||
// moved; a later backup or publish failure restores the exact previous set.
|
||||
function publishManagedMonoSkillSetAtomically(
|
||||
homeDir,
|
||||
monoRoot,
|
||||
baseDir,
|
||||
victims,
|
||||
options = {},
|
||||
) {
|
||||
const copyFn = options.copyFn || copyChildren;
|
||||
const renameFn = options.renameFn || fs.renameSync;
|
||||
const removeFn = options.removeFn || ((dir) => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
fs.mkdirSync(baseDir, { recursive: true });
|
||||
const stageRoot = fs.mkdtempSync(path.join(baseDir, ".dws-mono-set.tmp-"));
|
||||
const stagedDir = path.join(stageRoot, "dws");
|
||||
const destDir = path.join(baseDir, "dws");
|
||||
const backups = [];
|
||||
const published = [];
|
||||
|
||||
const restore = () => {
|
||||
const restoreErrors = [];
|
||||
for (let i = published.length - 1; i >= 0; i -= 1) {
|
||||
try {
|
||||
removeFn(published[i]);
|
||||
} catch (err) {
|
||||
restoreErrors.push(`remove ${published[i]}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
for (let i = backups.length - 1; i >= 0; i -= 1) {
|
||||
const item = backups[i];
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(item.original), { recursive: true });
|
||||
renameFn(item.backup, item.original);
|
||||
} catch (err) {
|
||||
restoreErrors.push(`restore ${item.original} from ${item.backup}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
if (restoreErrors.length > 0) {
|
||||
throw new Error(restoreErrors.join("; "));
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
copyFn(monoRoot, stagedDir);
|
||||
|
||||
const seen = new Set();
|
||||
for (const victim of victims) {
|
||||
const normalized = path.resolve(victim);
|
||||
if (seen.has(normalized)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(normalized);
|
||||
if (!backupAndRemoveSkillDir(homeDir, victim, backups, renameFn)) {
|
||||
throw new Error(`failed to back up Skill directory ${victim}`);
|
||||
}
|
||||
}
|
||||
|
||||
published.push(destDir);
|
||||
renameFn(stagedDir, destDir);
|
||||
} catch (err) {
|
||||
try {
|
||||
restore();
|
||||
} catch (restoreErr) {
|
||||
throw new Error(`${err.message}; rollback failed: ${restoreErr.message}`);
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
removeFn(stageRoot);
|
||||
}
|
||||
}
|
||||
|
||||
function writeSkillsState(homeDir, multiRoot, skills) {
|
||||
const version = process.env.npm_package_version || process.env.DWS_PACKAGE_VERSION || "unknown";
|
||||
const managedSkills = [...skills].sort().map((name) => ({
|
||||
name,
|
||||
version,
|
||||
source: "npm-postinstall",
|
||||
digest: skillDirectoryDigest(path.join(multiRoot, name)),
|
||||
digest_scope: MANAGED_SKILL_DIGEST_SCOPE,
|
||||
}));
|
||||
const state = {
|
||||
version,
|
||||
official_skills: [...skills].sort(),
|
||||
updated_skills: [...skills].sort(),
|
||||
managed_skills: managedSkills,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
const stateDir = skillStateDir(homeDir);
|
||||
fs.mkdirSync(stateDir, { recursive: true });
|
||||
const stage = fs.mkdtempSync(path.join(stateDir, ".skills-state.tmp-"));
|
||||
const stagedFile = path.join(stage, "skills-state.json");
|
||||
const statePath = path.join(stateDir, "skills-state.json");
|
||||
const rollbackPath = path.join(stage, "skills-state.previous.json");
|
||||
let movedPrevious = false;
|
||||
let preserveRecovery = false;
|
||||
try {
|
||||
fs.writeFileSync(stagedFile, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
||||
if (fs.existsSync(statePath)) {
|
||||
fs.renameSync(statePath, rollbackPath);
|
||||
movedPrevious = true;
|
||||
}
|
||||
try {
|
||||
fs.renameSync(stagedFile, statePath);
|
||||
} catch (err) {
|
||||
if (movedPrevious && !fs.existsSync(statePath)) {
|
||||
try {
|
||||
fs.renameSync(rollbackPath, statePath);
|
||||
movedPrevious = false;
|
||||
} catch (restoreErr) {
|
||||
preserveRecovery = true;
|
||||
throw new Error(
|
||||
`publish skills state failed: ${err.message}; restore also failed: ${restoreErr.message}; previous state retained at ${rollbackPath}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
} finally {
|
||||
if (!preserveRecovery) {
|
||||
fs.rmSync(stage, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// installMultiSkillsToHomes mirrors installSkillsToHomes for the multi bundle:
|
||||
// every product skill becomes a sibling directory of the agent home. Mutual
|
||||
// exclusion: the mono leftover (dws/) and stale, proven DWS-managed skills not
|
||||
// present in the new bundle are removed first.
|
||||
function installMultiSkillsToHomes(multiRoot) {
|
||||
const homeDir = os.homedir();
|
||||
const skills = fs
|
||||
.readdirSync(multiRoot, { withFileTypes: true })
|
||||
.filter((e) => e.isDirectory() && fs.existsSync(path.join(multiRoot, e.name, "SKILL.md")))
|
||||
.map((e) => e.name);
|
||||
if (skills.length === 0) {
|
||||
throw new Error(`no product skills found under ${multiRoot}`);
|
||||
}
|
||||
const skillSet = new Set(skills);
|
||||
const managedNames = readManagedSkillNames(homeDir);
|
||||
let installed = 0;
|
||||
let attempted = 0;
|
||||
let failed = 0;
|
||||
|
||||
const specificAgentDirs = AGENT_DIRS.slice(1).filter((agentDir) =>
|
||||
fs.existsSync(path.dirname(path.join(homeDir, agentDir))),
|
||||
);
|
||||
|
||||
const installToBase = (baseDir) => {
|
||||
fs.mkdirSync(baseDir, { recursive: true });
|
||||
const victims = [path.join(baseDir, "dws")];
|
||||
// Mutual exclusion: include the mono leftover and stale managed skills in
|
||||
// the same transaction as every replaced bundled skill.
|
||||
for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) {
|
||||
if (
|
||||
entry.isDirectory() &&
|
||||
(LEGACY_OFFICIAL_MULTI_SKILLS.has(entry.name) || managedNames.has(entry.name)) &&
|
||||
!skillSet.has(entry.name)
|
||||
) {
|
||||
victims.push(path.join(baseDir, entry.name));
|
||||
}
|
||||
}
|
||||
for (const name of skills) {
|
||||
victims.push(path.join(baseDir, name));
|
||||
}
|
||||
try {
|
||||
publishManagedMultiSkillSetAtomically(homeDir, multiRoot, baseDir, skills, victims);
|
||||
} catch (err) {
|
||||
console.warn(`⚠️ 跳过 ${baseDir}(multi 集合发布失败,已回滚): ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
AGENT_DIRS.forEach((agentDir, index) => {
|
||||
if (index === 0 && specificAgentDirs.length > 0) {
|
||||
return;
|
||||
}
|
||||
const baseDir = path.join(homeDir, agentDir);
|
||||
const parentGate = path.dirname(baseDir);
|
||||
if (index > 0 && !fs.existsSync(parentGate)) {
|
||||
return;
|
||||
}
|
||||
attempted += 1;
|
||||
if (installToBase(baseDir)) {
|
||||
installed += 1;
|
||||
} else {
|
||||
failed += 1;
|
||||
}
|
||||
});
|
||||
|
||||
if (specificAgentDirs.length > 0 && installed > 0) {
|
||||
try {
|
||||
retireGenericSkillRoot(homeDir, managedNames);
|
||||
} catch (err) {
|
||||
console.warn(`⚠️ 通用 Skill 副本迁移失败: ${err.message}`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (attempted === 0) {
|
||||
if (installToBase(path.join(homeDir, ".agents", "skills"))) {
|
||||
installed += 1;
|
||||
} else {
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
if (installed === 0) {
|
||||
throw new Error("未安装任何 multi Skill:所有检测到的 Agent 目标均失败");
|
||||
}
|
||||
if (failed > 0) {
|
||||
throw new Error(`有 ${failed} 个 Agent 目标安装 multi Skill 失败`);
|
||||
}
|
||||
writeSkillsState(homeDir, multiRoot, skills);
|
||||
}
|
||||
|
||||
// resolveSkillMode mirrors scripts/install.sh: DWS_SKILL_MODE (mono|multi)
|
||||
// wins; multi is the default. The --skill-mode flag accepts both the space
|
||||
// form (`--skill-mode mono`) and the equals form (`--skill-mode=mono`).
|
||||
function resolveSkillMode() {
|
||||
const raw = (process.env.DWS_SKILL_MODE || "").trim().toLowerCase();
|
||||
if (raw === "mono" || raw === "multi") {
|
||||
return raw;
|
||||
}
|
||||
if (raw !== "") {
|
||||
throw new Error(`invalid DWS_SKILL_MODE='${process.env.DWS_SKILL_MODE}'. Use 'mono' or 'multi'.`);
|
||||
}
|
||||
let fromFlag;
|
||||
const flagIndex = process.argv.indexOf("--skill-mode");
|
||||
if (flagIndex !== -1 && process.argv[flagIndex + 1]) {
|
||||
fromFlag = process.argv[flagIndex + 1];
|
||||
} else {
|
||||
const equalsArg = process.argv.find((arg) => arg.startsWith("--skill-mode="));
|
||||
if (equalsArg) {
|
||||
fromFlag = equalsArg.slice("--skill-mode=".length);
|
||||
}
|
||||
}
|
||||
if (fromFlag !== undefined) {
|
||||
const mode = fromFlag.trim().toLowerCase();
|
||||
if (mode === "mono" || mode === "multi") {
|
||||
return mode;
|
||||
}
|
||||
throw new Error(`invalid --skill-mode '${fromFlag}'. Use 'mono' or 'multi'.`);
|
||||
}
|
||||
return "multi";
|
||||
}
|
||||
|
||||
// cacheUserSkills copies the mono and multi trees out of the freshly extracted
|
||||
// dws-skills.zip into ~/.dws/skills/{mono,multi}/ so that `dws skill setup`
|
||||
// can fall back to a user-local cache when --source is not provided. mono is
|
||||
// already installed into agent homes by installSkillsToHomes; the cache is
|
||||
// purely a source-of-truth for the setup command.
|
||||
// can fall back to a user-local cache when --source is not provided. A cache
|
||||
// is only refreshed when the new bundle actually carries that tree — an
|
||||
// empty/corrupt multi/ (or a missing mono tree) must never wipe a previously
|
||||
// good cache.
|
||||
function cacheUserSkills(extractedSkillsRoot) {
|
||||
const cacheBase = path.join(os.homedir(), ".dws", "skills");
|
||||
|
||||
const monoSource = fs.existsSync(path.join(extractedSkillsRoot, "mono", "SKILL.md"))
|
||||
? path.join(extractedSkillsRoot, "mono")
|
||||
: extractedSkillsRoot;
|
||||
const monoCache = path.join(cacheBase, "mono");
|
||||
fs.rmSync(monoCache, { recursive: true, force: true });
|
||||
copyChildren(monoSource, monoCache);
|
||||
if (fs.existsSync(path.join(monoSource, "SKILL.md"))) {
|
||||
const monoCache = path.join(cacheBase, "mono");
|
||||
publishCacheAtomically(monoSource, monoCache);
|
||||
}
|
||||
|
||||
const multiSource = path.join(extractedSkillsRoot, "multi");
|
||||
if (fs.existsSync(multiSource) && fs.statSync(multiSource).isDirectory()) {
|
||||
if (multiTreeHasSkills(multiSource)) {
|
||||
const multiCache = path.join(cacheBase, "multi");
|
||||
fs.rmSync(multiCache, { recursive: true, force: true });
|
||||
copyChildren(multiSource, multiCache);
|
||||
publishCacheAtomically(multiSource, multiCache);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,8 +785,34 @@ function main() {
|
||||
const monoRoot = fs.existsSync(path.join(skillsStaging, "mono", "SKILL.md"))
|
||||
? path.join(skillsStaging, "mono")
|
||||
: skillsStaging;
|
||||
installSkillsToHomes(monoRoot);
|
||||
// A mono install requires an actual SKILL.md at the root of monoRoot. On a
|
||||
// multi-only zip monoRoot would degrade to the staging root and copy the
|
||||
// whole bundle (multi/ included) into a dws/ directory — skip instead.
|
||||
const monoHasSkill = fs.existsSync(path.join(monoRoot, "SKILL.md"));
|
||||
const multiRoot = path.join(skillsStaging, "multi");
|
||||
const skillMode = resolveSkillMode();
|
||||
if (skillMode === "multi" && multiTreeHasSkills(multiRoot)) {
|
||||
console.log(`Skill mode: multi — installing per-product skills`);
|
||||
installMultiSkillsToHomes(multiRoot);
|
||||
} else {
|
||||
if (skillMode === "multi") {
|
||||
console.log("multi skill tree not found or empty in bundle; falling back to mono.");
|
||||
}
|
||||
if (monoHasSkill) {
|
||||
installSkillsToHomes(monoRoot);
|
||||
} else {
|
||||
console.log("mono skill tree not found in bundle; skipping skill install.");
|
||||
}
|
||||
}
|
||||
cacheUserSkills(skillsStaging);
|
||||
}
|
||||
|
||||
main();
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
publishCacheAtomically,
|
||||
publishManagedMonoSkillSetAtomically,
|
||||
publishManagedMultiSkillSetAtomically,
|
||||
};
|
||||
|
||||
+14
-8
@@ -48,8 +48,12 @@ It then runs:
|
||||
--fast-path "$PR_BASE_SHA" HEAD
|
||||
```
|
||||
|
||||
Because the verified PR diff contains only `CHANGELOG.md`, the validator and
|
||||
its policy dependencies in that merge tree are byte-for-byte the current base
|
||||
The exact fast path remains limited to historic one-file maintenance. A
|
||||
release-seal PR uses `--content-only`, which permits the generated
|
||||
`CHANGELOG.md` change together with archival moves from `.changes/` to
|
||||
`.changes/released/`; it receives the normal scoped admission instead of this
|
||||
fast path. Ordinary PRs must not modify `CHANGELOG.md`; they add a standalone
|
||||
release fragment instead. The validator and its policy dependencies in that merge tree are byte-for-byte the current base
|
||||
versions. Validation targets the synthetic merge tree, not the feature-branch
|
||||
tree, so a stale branch cannot supply an older validator or combine with newer
|
||||
base notes into an invalid final CHANGELOG.
|
||||
@@ -79,10 +83,12 @@ to the complete main admission suite. A source change can therefore never
|
||||
inherit the CHANGELOG-only result.
|
||||
|
||||
Any PR that touches `CHANGELOG.md` but also changes another file runs the same
|
||||
content contract in `Policy` with `--content-only`. That mode permits the
|
||||
second file but still rejects invalid dates or versions, missing bullets,
|
||||
placeholder `TODO`/`TBD`, unmanaged-section changes, and unsafe tree modes.
|
||||
Adding a second file therefore cannot bypass CHANGELOG validation.
|
||||
content contract in `Policy` with `--content-only`. That mode accepts only
|
||||
fragment archival moves (`.changes/<name>.md` to
|
||||
`.changes/released/<version>/<name>.md`) alongside the changelog; source and
|
||||
documentation changes are rejected. It still rejects invalid dates or
|
||||
versions, missing bullets, placeholder `TODO`/`TBD`, unmanaged-section
|
||||
changes, and unsafe tree modes.
|
||||
|
||||
## Risk tiers and downstream boundaries
|
||||
|
||||
@@ -184,11 +190,11 @@ Schema,并让 candidate 对两份历史 contract 独立执行检查;它只
|
||||
lifecycle 的 exact rename 规范化到当前历史副本,不会维护第二份 allowlist,也不会
|
||||
放宽其他 Schema 历史字段。
|
||||
|
||||
For an exact CHANGELOG-only branch:
|
||||
For a release-seal branch that archives rendered fragments:
|
||||
|
||||
```sh
|
||||
base_ref=$(git merge-base HEAD origin/main)
|
||||
./scripts/policy/check-changelog-pr.sh --fast-path "$base_ref" HEAD
|
||||
./scripts/policy/check-changelog-pr.sh --content-only "$base_ref" HEAD
|
||||
```
|
||||
|
||||
`make coverage-gate` is an enforcement step, not a profile generator. For a
|
||||
|
||||
@@ -41,7 +41,7 @@ PR merge-base 同时拥有快照生成器、比较器和已审批清单。门禁
|
||||
scripts/policy/interface-migrations/approved-flag-migrations-v1.json
|
||||
```
|
||||
|
||||
清单使用严格 JSON 解析:版本、字段名大小写、JSON 值类型、命令路径和 flag 名都必须精确;拒绝重复键、未知键、scalar `null` 与尾随 JSON 值,`reason` 不能为空;禁止 `*`、`?`、前缀规则或其他 wildcard。当前清单为空,因此本治理 PR **不授权 PR #904 或任何产品接口变化**。
|
||||
清单使用严格 JSON 解析:版本、字段名大小写、JSON 值类型、命令路径和 flag 名都必须精确;拒绝重复键、未知键、scalar `null` 与尾随 JSON 值,`reason` 不能为空;禁止 `*`、`?`、前缀规则或其他 wildcard。当前清单登记了 IM ID rename 的 `pending` 记录;`pending` 只记录已评审计划,候选与 merge-base 仍必须精确匹配 `before`,因此本治理 PR **不授权 PR #904 或任何产品接口变化**。
|
||||
|
||||
## 两阶段迁移与回执清理
|
||||
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="description" content="DWS Drive Shortcut 与 lark-cli 的业务能力、真实数据 E2E 证据和平台边界分析。">
|
||||
<title>Drive Shortcut 能力全景|业务评审版</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--paper: #f4f6f2; --surface: #fffefa; --ink: #17251f; --muted: #66746d;
|
||||
--line: #dce2dc; --forest: #154f3d; --green: #17765a; --mint: #dff4e8;
|
||||
--blue: #265f86; --blue-soft: #e7f1f7; --amber: #8c5a09; --amber-soft: #fff2cf;
|
||||
--red: #a43b32; --red-soft: #fde9e5; --shadow: 0 14px 40px rgba(28, 48, 38, .08);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body { margin: 0; color: var(--ink); background: var(--paper); font: 15px/1.65 -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
code { padding: .12rem .38rem; border: 1px solid #d6e0da; border-radius: 6px; color: #174f3e; background: #f1f7f3; font: 600 .88em/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: nowrap; }
|
||||
.wrap { width: min(1180px, calc(100% - 40px)); margin: auto; }
|
||||
.hero { position: relative; overflow: hidden; padding: 64px 0 52px; color: #f7fff9; background: linear-gradient(125deg, #102b22 0%, #154c3c 57%, #1c6b54 100%); }
|
||||
.hero::after { position: absolute; inset: -180px -100px auto auto; width: 540px; height: 540px; border: 1px solid rgba(255,255,255,.14); border-radius: 50%; box-shadow: 0 0 0 76px rgba(255,255,255,.035), 0 0 0 152px rgba(255,255,255,.025); content: ""; }
|
||||
.hero-grid { position: relative; z-index: 1; display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(300px, .65fr); gap: 32px; align-items: end; }
|
||||
.eyebrow, .section-kicker { margin: 0 0 9px; color: #9fd6bd; font-size: 11px; font-weight: 900; letter-spacing: .16em; text-transform: uppercase; }
|
||||
h1 { margin: 0; font-size: clamp(40px, 6vw, 68px); line-height: 1.03; letter-spacing: -.045em; }
|
||||
.subtitle { max-width: 760px; margin: 19px 0 0; color: #d3e9de; font-size: 17px; }
|
||||
.meta-row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 21px; }
|
||||
.meta-pill { padding: 6px 10px; border: 1px solid rgba(255,255,255,.18); border-radius: 999px; color: #d5e9df; background: rgba(255,255,255,.07); font-size: 11px; font-weight: 750; }
|
||||
.meta-pill.good { color: #bff2d5; border-color: rgba(139,232,179,.38); }
|
||||
.hero-stats { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
|
||||
.hero-stat { padding: 17px 16px; border: 1px solid rgba(255,255,255,.17); border-radius: 15px; background: rgba(255,255,255,.075); backdrop-filter: blur(8px); }
|
||||
.hero-stat strong { display: block; font-size: 30px; line-height: 1; }
|
||||
.hero-stat span { display: block; margin-top: 7px; color: #cce2d7; font-size: 11px; }
|
||||
.nav { position: sticky; top: 0; z-index: 20; border-bottom: 1px solid var(--line); background: rgba(255,254,250,.94); backdrop-filter: blur(12px); }
|
||||
.nav .wrap { display: flex; overflow-x: auto; }
|
||||
.nav a { flex: 0 0 auto; padding: 14px 15px; color: #5b6c64; font-size: 12px; font-weight: 800; }
|
||||
.nav a:hover { color: var(--forest); background: #eaf3ee; }
|
||||
main { padding: 38px 0 74px; }
|
||||
section { margin-top: 50px; scroll-margin-top: 72px; }
|
||||
section:first-child { margin-top: 0; }
|
||||
.section-head { display: flex; justify-content: space-between; gap: 28px; align-items: end; margin-bottom: 19px; }
|
||||
h2 { margin: 0; font-size: clamp(25px, 3.2vw, 36px); line-height: 1.16; letter-spacing: -.025em; }
|
||||
h3 { margin: 0 0 7px; font-size: 18px; }
|
||||
.section-desc { max-width: 660px; margin: 0; color: var(--muted); font-size: 13px; }
|
||||
.callout { padding: 19px 21px; border: 1px solid #bdd8cb; border-left: 4px solid var(--green); border-radius: 13px; background: #ecf7f1; box-shadow: 0 6px 20px rgba(28,48,38,.04); }
|
||||
.callout strong { color: #13513d; }
|
||||
.callout.warn { border-color: #ead29a; border-left-color: #b67508; background: #fff8e7; }
|
||||
.callout.warn strong { color: #784b00; }
|
||||
.callout.danger { border-color: #e9b7b1; border-left-color: var(--red); background: var(--red-soft); }
|
||||
.score-grid, .domain-grid, .evidence-grid, .review-grid { display: grid; gap: 13px; }
|
||||
.score-grid { grid-template-columns: repeat(4, 1fr); margin-top: 16px; }
|
||||
.score, .domain-card, .evidence-card, .review-card { border: 1px solid var(--line); border-radius: 15px; background: var(--surface); box-shadow: var(--shadow); }
|
||||
.score { padding: 19px; }
|
||||
.score strong { display: block; color: var(--forest); font-size: 29px; line-height: 1; }
|
||||
.score span { display: block; margin-top: 8px; color: var(--muted); font-size: 12px; }
|
||||
.domain-grid { grid-template-columns: repeat(4, 1fr); }
|
||||
.domain-card { position: relative; padding: 21px; overflow: hidden; }
|
||||
.domain-card .number { position: absolute; top: 12px; right: 17px; color: #d5e8de; font: 800 42px/1 ui-monospace, monospace; }
|
||||
.domain-card p { min-height: 64px; margin: 8px 0 12px; color: var(--muted); font-size: 13px; }
|
||||
.domain-card small { color: var(--green); font-weight: 800; }
|
||||
.compare { overflow: hidden; border: 1px solid var(--line); border-radius: 16px; background: var(--surface); box-shadow: var(--shadow); }
|
||||
.compare-top { display: grid; grid-template-columns: repeat(3, 1fr); }
|
||||
.compare-column { padding: 21px; border-right: 1px solid var(--line); }
|
||||
.compare-column:last-child { border-right: 0; }
|
||||
.compare-column p, .compare-column li { color: var(--muted); font-size: 13px; }
|
||||
.compare-column ul { margin: 9px 0 0; padding-left: 18px; }
|
||||
.compare-column.covered { border-top: 5px solid var(--green); }
|
||||
.compare-column.partial { border-top: 5px solid #c78b22; }
|
||||
.compare-column.gap { border-top: 5px solid var(--red); }
|
||||
.table-wrap { overflow-x: auto; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { padding: 12px 14px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; }
|
||||
th { color: #617069; background: #f7f8f5; font-size: 11px; font-weight: 900; letter-spacing: .03em; }
|
||||
tr:last-child td { border-bottom: 0; }
|
||||
tbody tr:hover { background: #f8fbf8; }
|
||||
.verdict, .badge { display: inline-flex; align-items: center; padding: 3px 8px; border-radius: 999px; font-size: 10px; font-weight: 900; white-space: nowrap; }
|
||||
.v-covered, .badge.read { color: #116045; background: var(--mint); }
|
||||
.v-ahead, .badge.smart { color: #20577c; background: var(--blue-soft); }
|
||||
.v-partial, .badge.write { color: #7b510a; background: var(--amber-soft); }
|
||||
.v-gap, .badge.high { color: #8f3028; background: var(--red-soft); }
|
||||
.truth-grid { display: grid; grid-template-columns: 1.1fr .9fr; gap: 14px; }
|
||||
.truth-card { padding: 22px; border: 1px solid var(--line); border-radius: 15px; background: var(--surface); box-shadow: var(--shadow); }
|
||||
.truth-step { display: grid; grid-template-columns: 30px 1fr; gap: 11px; margin-top: 13px; }
|
||||
.truth-step b { display: grid; width: 28px; height: 28px; place-items: center; border-radius: 50%; color: #fff; background: var(--forest); font-size: 12px; }
|
||||
.truth-step strong, .truth-step span { display: block; }
|
||||
.truth-step span { color: var(--muted); font-size: 12px; }
|
||||
.toolbar { display: grid; grid-template-columns: minmax(260px, 1fr) 180px 180px auto; gap: 10px; align-items: center; margin: 18px 0; padding: 13px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
|
||||
input, select { width: 100%; min-height: 42px; padding: 9px 11px; border: 1px solid #ccd7d0; border-radius: 9px; color: var(--ink); background: #fff; font: inherit; }
|
||||
input:focus, select:focus { outline: 3px solid rgba(23,118,90,.13); border-color: var(--green); }
|
||||
.result-count { color: var(--muted); font-size: 12px; text-align: right; white-space: nowrap; }
|
||||
.catalog { overflow: hidden; border: 1px solid var(--line); border-radius: 16px; background: var(--surface); box-shadow: var(--shadow); }
|
||||
.shortcut-row { display: grid; grid-template-columns: 215px minmax(0, 1fr) 200px; gap: 16px; align-items: center; padding: 14px 17px; border-bottom: 1px solid var(--line); }
|
||||
.shortcut-row:last-child { border-bottom: 0; }
|
||||
.shortcut-row:hover { background: #f8fbf8; }
|
||||
.command code { font-size: 12px; }
|
||||
.row-main p { margin: 0; font-size: 13px; }
|
||||
.row-main small { color: var(--muted); }
|
||||
.badges { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: 5px; }
|
||||
.hidden-row { display: none; }
|
||||
.evidence-grid { grid-template-columns: repeat(4, 1fr); }
|
||||
.evidence-card { padding: 19px; }
|
||||
.evidence-card strong { display: block; color: var(--forest); font-size: 23px; }
|
||||
.evidence-card p { margin: 7px 0 0; color: var(--muted); font-size: 12px; }
|
||||
.timeline { margin-top: 15px; border-left: 2px solid #bdd7ca; }
|
||||
.event { position: relative; padding: 0 0 17px 22px; }
|
||||
.event::before { position: absolute; left: -7px; top: 5px; width: 12px; height: 12px; border: 3px solid var(--paper); border-radius: 50%; background: var(--green); content: ""; }
|
||||
.event b { display: block; }
|
||||
.event span { color: var(--muted); font-size: 12px; }
|
||||
.review-grid { grid-template-columns: repeat(3, 1fr); }
|
||||
.review-card { position: relative; padding: 20px; }
|
||||
.review-card .review-num { color: #b8d1c4; font: 800 12px/1 ui-monospace, monospace; letter-spacing: .1em; }
|
||||
.review-card p { margin: 7px 0 0; color: var(--muted); font-size: 13px; }
|
||||
footer { margin-top: 52px; padding: 23px 0; border-top: 1px solid var(--line); color: var(--muted); font-size: 11px; }
|
||||
@media (max-width: 900px) { .hero-grid, .truth-grid { grid-template-columns: 1fr; } .score-grid, .domain-grid, .evidence-grid { grid-template-columns: repeat(2, 1fr); } .review-grid { grid-template-columns: 1fr 1fr; } .shortcut-row { grid-template-columns: 180px 1fr; } .badges { grid-column: 1 / -1; justify-content: flex-start; } }
|
||||
@media (max-width: 620px) { .wrap { width: min(100% - 24px, 1180px); } .hero { padding: 44px 0 38px; } .hero-stats, .score-grid, .domain-grid, .evidence-grid, .review-grid, .compare-top { grid-template-columns: 1fr; } .compare-column { border-right: 0; border-bottom: 1px solid var(--line); } .toolbar { grid-template-columns: 1fr; } .result-count { text-align: left; } .shortcut-row { grid-template-columns: 1fr; } }
|
||||
@media print { body { background: #fff; } .hero { color: var(--ink); background: #fff; border-bottom: 2px solid var(--ink); } .subtitle, .meta-pill, .hero-stat span { color: #425249; } .hero-stat { border-color: #aebbb3; } .nav, .toolbar { display: none; } .score, .domain-card, .compare, .truth-card, .catalog, .evidence-card, .review-card { box-shadow: none; break-inside: avoid; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="hero">
|
||||
<div class="wrap hero-grid">
|
||||
<div>
|
||||
<p class="eyebrow">Business Review · Drive</p>
|
||||
<h1>Drive Shortcut<br>能力全景</h1>
|
||||
<p class="subtitle">从 lark-cli 对齐出发,但不止于命令名:逐项审查输入、校验、多步编排、失败语义、真实字节和平台边界。</p>
|
||||
<div class="meta-row">
|
||||
<span class="meta-pill good">真实账号 E2E 已执行</span>
|
||||
<span class="meta-pill">28 个公开入口</span>
|
||||
<span class="meta-pill">统一 Result / Pagination</span>
|
||||
<span class="meta-pill">报告已移除 PII / 凭证 / 业务正文</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-stats" aria-label="关键统计">
|
||||
<div class="hero-stat"><strong>38</strong><span>lark-cli Drive 逐项审查</span></div>
|
||||
<div class="hero-stat"><strong>26</strong><span>已覆盖或跨产品路由</span></div>
|
||||
<div class="hero-stat"><strong>7</strong><span>部分对齐,边界已公开</span></div>
|
||||
<div class="hero-stat"><strong>5</strong><span>客观不可对齐能力</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="nav"><div class="wrap"><a href="#overview">全景</a><a href="#compare">Lark 对齐</a><a href="#ahead">超越项</a><a href="#truth">真实语义</a><a href="#catalog">完整目录</a><a href="#e2e">E2E</a><a href="#review">评审</a></div></nav>
|
||||
|
||||
<main class="wrap">
|
||||
<section id="overview">
|
||||
<div class="section-head"><div><p class="section-kicker">Executive summary</p><h2>28 个公开入口,覆盖文件完整生命周期</h2></div><p class="section-desc">另有 <code>+publish-set</code> 已实现契约和读回逻辑,但真实普通文件与在线文档均被服务端拒绝,因此保持 unavailable,不进入 Agent 公开目录。</p></div>
|
||||
<div class="callout"><strong>结论:</strong>Drive 已从 9 个偏原子入口扩展为 28 个可发现 Shortcut。它不仅补齐 Lark 的核心文件、版本和状态任务,还通过严格响应合同、真实落盘、写后读回、回收恢复和个人收藏形成更可审计的钉盘工作流。</div>
|
||||
<div class="score-grid">
|
||||
<article class="score"><strong>29</strong><span>已审查注册项(含 1 unavailable)</span></article>
|
||||
<article class="score"><strong>25</strong><span>公开主能力 / 语义适配</span></article>
|
||||
<article class="score"><strong>3</strong><span>公开兼容入口</span></article>
|
||||
<article class="score"><strong>3</strong><span>高风险写入口,均需确认</span></article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="section-head"><div><p class="section-kicker">Capability map</p><h2>四个业务域</h2></div><p class="section-desc">目录按用户任务组织;兼容命令不重复计为新增能力。</p></div>
|
||||
<div class="domain-grid">
|
||||
<article class="domain-card"><span class="number">09</span><h3>发现与检查</h3><p>严格目录分页、搜索、最近访问,以及元数据、统计和封面聚合检查。</p><small>+list · +search · +recent · +inspect</small></article>
|
||||
<article class="domain-card"><span class="number">09</span><h3>文件生命周期</h3><p>创建目录、上传下载、快捷方式、在线对象复制、移动重命名、删除与恢复。</p><small>+upload · +download · +rename · +recycle-restore</small></article>
|
||||
<article class="domain-card"><span class="number">06</span><h3>个人与公开状态</h3><p>回收站清单、收藏闭环和互联网公开状态的独立安全域。</p><small>+star-list · +star-add · +publish-get</small></article>
|
||||
<article class="domain-card"><span class="number">04</span><h3>历史版本</h3><p>版本列表、精确定位、真实字节下载与高风险回滚读回。</p><small>+version-history · +version-download · +version-revert</small></article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="compare">
|
||||
<div class="section-head"><div><p class="section-kicker">Lark alignment</p><h2>对齐业务语义,不追求同名率</h2></div><p class="section-desc">38 项逐项核对。评论、导入导出和成员权限在 DWS 由更成熟的 Doc 或原子权限入口承接,不在 Drive 再复制一套。</p></div>
|
||||
<div class="compare">
|
||||
<div class="compare-top">
|
||||
<article class="compare-column covered"><h3>26 · 已覆盖 / 路由</h3><p>上传下载、目录与快捷方式、版本、移动删除、状态、搜索、评论、导入导出和成员任务均有真实入口。</p></article>
|
||||
<article class="compare-column partial"><h3>7 · 部分对齐</h3><p>预览、resolve/reaction、push/pull、权限申请与 setting 受对象模型或接口粒度约束,明确保留有限语义。</p></article>
|
||||
<article class="compare-column gap"><h3>5 · 客观缺口</h3><p>删除评论恢复、普通文件版本删除、安全标签读写、可靠双向目录同步缺少必要下层接口。</p></article>
|
||||
</div>
|
||||
<div class="table-wrap"><table><thead><tr><th>Lark 任务组</th><th>DWS 主路径</th><th>结论</th><th>关键差异与交付决定</th></tr></thead><tbody>
|
||||
<tr><td>upload / folder / shortcut / download</td><td><code>drive +upload</code> 等</td><td><span class="verdict v-ahead">增强</span></td><td>工作目录边界、OSS PUT、严格 commit、no-clobber、原子落盘、非零字节和读回验证。</td></tr>
|
||||
<tr><td>preview / cover</td><td><code>drive +cover</code></td><td><span class="verdict v-partial">部分</span></td><td>封面/缩略图可读;没有等价的服务端多格式预览转换,不扩大宣称。</td></tr>
|
||||
<tr><td>comments / replies</td><td><code>doc +comment-*</code> / <code>doc +review</code></td><td><span class="verdict v-covered">路由</span></td><td>评论归在线文档协作域;独立 resolve、reaction identity 与删除后恢复仍受接口限制。</td></tr>
|
||||
<tr><td>export / import / task result</td><td><code>doc +export</code> / <code>doc +import</code></td><td><span class="verdict v-ahead">增强</span></td><td>提交、轮询、恢复、安全下载形成类型化闭环,不保留泛化下划线命令。</td></tr>
|
||||
<tr><td>version history / get / revert</td><td><code>drive +version-*</code></td><td><span class="verdict v-ahead">增强</span></td><td>严格分页、精确版本、历史字节落盘、回滚前预检与终态读回;历史版本删除无接口。</td></tr>
|
||||
<tr><td>status / inspect</td><td><code>drive +inspect</code></td><td><span class="verdict v-ahead">超越</span></td><td>元数据为必达结果,统计、公开状态和封面按需 fan-out;可选失败为 partial_success。</td></tr>
|
||||
<tr><td>push / pull / sync</td><td><code>+upload</code> / <code>+download</code> 单文件</td><td><span class="verdict v-partial">部分</span></td><td>不在缺少稳定 hash、rename/delete journal 和冲突向量时制造危险目录同步。</td></tr>
|
||||
<tr><td>member / permission</td><td><code>doc +access-*</code> / <code>drive permission</code></td><td><span class="verdict v-covered">路由</span></td><td>协作者权限与互联网公开是两个安全域;申请权限需真实上下文,未伪装为通用 Shortcut。</td></tr>
|
||||
<tr><td>secure labels</td><td>无等价</td><td><span class="verdict v-gap">缺口</span></td><td>当前 DWS/钉钉下层没有 Drive 安全标签目录和写入接口,不能用普通权限代替。</td></tr>
|
||||
<tr><td>search</td><td><code>drive +search</code> / <code>doc +search</code></td><td><span class="verdict v-ahead">增强</span></td><td>文件与在线文档按域路由;文件搜索严格验证数组、过滤和分页。</td></tr>
|
||||
</tbody></table></div>
|
||||
</div>
|
||||
<div class="callout warn" style="margin-top:14px"><strong>普通文件 copy 边界:</strong>钉钉现有复制接口对普通文件产生 <code>.dlink</code>,不是字节独立副本。因此 <code>+copy</code> 只接受在线对象;普通文件快捷入口用 <code>+create-shortcut</code>,独立副本使用 <code>+download</code> 后 <code>+upload</code>。</div>
|
||||
</section>
|
||||
|
||||
<section id="ahead">
|
||||
<div class="section-head"><div><p class="section-kicker">Beyond parity</p><h2>DWS 可主推的八个差异化点</h2></div><p class="section-desc">价值来自正确性与完整闭环,而不是额外注册同义命令。</p></div>
|
||||
<div class="domain-grid">
|
||||
<article class="domain-card"><h3>严格目录语义</h3><p><code>+list</code> / <code>+recent</code> 只有服务端明确返回数组时才接受空集合。</p><small>缺字段 ≠ 空目录</small></article>
|
||||
<article class="domain-card"><h3>聚合检查</h3><p><code>+inspect</code> 一次汇总身份、统计、公开状态和封面,并保留局部失败。</p><small>partial_success 可审计</small></article>
|
||||
<article class="domain-card"><h3>真实文件传输</h3><p>上传执行完整事务;下载验证受控路径、覆盖策略、原子发布和字节。</p><small>不是只返回临时 URL</small></article>
|
||||
<article class="domain-card"><h3>回收恢复闭环</h3><p>从 <code>recycleItemId</code> 恢复后读取真实节点,证明资源确实回到可访问状态。</p><small>恢复后读回</small></article>
|
||||
<article class="domain-card"><h3>个人收藏闭环</h3><p>收藏、列表、取消收藏覆盖完整用户偏好过程,并保留分页。</p><small>add → list → remove</small></article>
|
||||
<article class="domain-card"><h3>版本真实字节</h3><p>除元数据外可下载任意已知历史版本,并用本地字节核验回滚结果。</p><small>version-download</small></article>
|
||||
<article class="domain-card"><h3>重命名终态</h3><p>处理服务端扩展名规则,再读取节点确认最终名称,避免重复扩展名。</p><small>write → read-back</small></article>
|
||||
<article class="domain-card"><h3>公开域诚实降级</h3><p>查询和关闭可验证;开启在 eligible 节点闭环完成前保持 unavailable。</p><small>不把 notSupported 当成功</small></article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="truth">
|
||||
<div class="section-head"><div><p class="section-kicker">Truthful execution</p><h2>空数组不再是“看起来成功”</h2></div><p class="section-desc">合法业务空集合可以成功,但必须先证明响应结构、元素类型和分页语义成立。内部错误、缺字段与坏投影必须失败。</p></div>
|
||||
<div class="truth-grid">
|
||||
<article class="truth-card"><h3>四层成功证据</h3>
|
||||
<div class="truth-step"><b>1</b><div><strong>传输成功</strong><span>进程成功,MCP / HTTP 没有显式错误。</span></div></div>
|
||||
<div class="truth-step"><b>2</b><div><strong>响应合同</strong><span>对象、数组、success 标志和元素类型与命令声明一致。</span></div></div>
|
||||
<div class="truth-step"><b>3</b><div><strong>业务终态</strong><span>写命令必须获得新 ID、终态证据或后续元数据读回。</span></div></div>
|
||||
<div class="truth-step"><b>4</b><div><strong>产物校验</strong><span>下载必须落盘、非零字节;关键链路比较大小和 SHA-256。</span></div></div>
|
||||
</article>
|
||||
<article class="truth-card"><h3>明确失败的情况</h3>
|
||||
<ul><li>空响应、缺少预期集合字段或集合类型错误。</li><li>集合存在坏元素,不能投影时静默丢弃。</li><li><code>success=false</code>、写响应没有 ID 或读回不一致。</li><li>inspect 的可选分支失败却返回整体 success。</li><li>下载得到空文件、越界路径或覆盖既有文件。</li><li>普通文件 copy 返回快捷链接却声称独立副本。</li></ul>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="catalog">
|
||||
<div class="section-head"><div><p class="section-kicker">Full catalog</p><h2>28 个公开 Shortcut 完整目录</h2></div><p class="section-desc">16 个只读、9 个普通写、3 个高风险写;3 个历史入口保留兼容但不作为新 Agent 主路径。</p></div>
|
||||
<div class="toolbar"><input id="q" type="search" placeholder="搜索命令或用途,例如 版本、回收、+inspect…" aria-label="搜索 Shortcut"><select id="domain"><option value="all">全部业务域</option><option value="discover">发现与检查</option><option value="lifecycle">文件生命周期</option><option value="personal">个人与公开</option><option value="version">历史版本</option></select><select id="risk"><option value="all">全部风险</option><option value="read">只读</option><option value="write">普通写</option><option value="high">高风险写</option></select><span id="result-count" class="result-count">显示 28 / 28</span></div>
|
||||
<div class="catalog">
|
||||
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +list</code></div><div class="row-main"><p>严格分页列出目录,保留游标,区分显式空目录和畸形响应。</p><small>发现与检查</small></div><div class="badges"><span class="badge read">READ</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +inspect</code></div><div class="row-main"><p>聚合元数据与可选统计、公开状态、封面;局部失败如实报告。</p><small>发现与检查</small></div><div class="badges"><span class="badge read">READ</span><span class="badge smart">SMART</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +info</code></div><div class="row-main"><p>历史元数据兼容入口;新场景优先使用 +inspect。</p><small>兼容入口</small></div><div class="badges"><span class="badge read">READ</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +search</code></div><div class="row-main"><p>按关键词、类型、扩展名、创建人、时间和分页搜索钉盘文件。</p><small>发现与检查</small></div><div class="badges"><span class="badge read">READ</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +find-file</code></div><div class="row-main"><p>历史文件搜索兼容入口;新场景优先使用 +search。</p><small>兼容入口</small></div><div class="badges"><span class="badge read">READ</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +search-docs</code></div><div class="row-main"><p>历史跨域搜索入口;新的在线文档搜索路由 doc +search。</p><small>兼容入口</small></div><div class="badges"><span class="badge read">READ</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +recent</code></div><div class="row-main"><p>读取最近访问或编辑列表,支持创建人筛选并保留分页。</p><small>发现与检查</small></div><div class="badges"><span class="badge read">READ</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +stats</code></div><div class="row-main"><p>读取访问、编辑、评论、点赞、预览和下载统计。</p><small>发现与检查</small></div><div class="badges"><span class="badge read">READ</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +cover</code></div><div class="row-main"><p>读取封面或缩略图;不宣称服务端多格式预览。</p><small>发现与检查</small></div><div class="badges"><span class="badge read">READ</span></div></article>
|
||||
|
||||
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="write"><div class="command"><code>dws drive +upload</code></div><div class="row-main"><p>上传凭证、OSS PUT、严格提交和远端元数据读回的一体化事务。</p><small>文件生命周期</small></div><div class="badges"><span class="badge write">WRITE · CONFIRM</span><span class="badge smart">SMART</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="read"><div class="command"><code>dws drive +download</code></div><div class="row-main"><p>安全落盘、no-clobber、原子发布并验证非零字节。</p><small>文件生命周期</small></div><div class="badges"><span class="badge read">READ</span><span class="badge smart">SMART</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="write"><div class="command"><code>dws drive +create-folder</code></div><div class="row-main"><p>创建文件夹后要求新 ID,并读回名称验证。</p><small>文件生命周期</small></div><div class="badges"><span class="badge write">WRITE · CONFIRM</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="write"><div class="command"><code>dws drive +create-shortcut</code></div><div class="row-main"><p>创建快捷方式并读回,明确区别于独立副本。</p><small>文件生命周期</small></div><div class="badges"><span class="badge write">WRITE · CONFIRM</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="write"><div class="command"><code>dws drive +copy</code></div><div class="row-main"><p>复制在线对象;普通文件预检拒绝,避免把 .dlink 当副本。</p><small>文件生命周期</small></div><div class="badges"><span class="badge write">WRITE · CONFIRM</span><span class="badge smart">SMART</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="write"><div class="command"><code>dws drive +move</code></div><div class="row-main"><p>移动到指定文件夹或知识库位置,语义与 copy/shortcut 消歧。</p><small>文件生命周期</small></div><div class="badges"><span class="badge write">WRITE · CONFIRM</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="write"><div class="command"><code>dws drive +rename</code></div><div class="row-main"><p>重命名后读取真实节点,验证最终名称和扩展名。</p><small>文件生命周期</small></div><div class="badges"><span class="badge write">WRITE · CONFIRM</span><span class="badge smart">SMART</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="high"><div class="command"><code>dws drive +delete</code></div><div class="row-main"><p>将确认过的节点移入回收站,要求 success=true 终态证据。</p><small>文件生命周期</small></div><div class="badges"><span class="badge high">HIGH · CONFIRM</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="write"><div class="command"><code>dws drive +recycle-restore</code></div><div class="row-main"><p>按回收项 ID 恢复,并读回恢复后的节点。</p><small>文件生命周期</small></div><div class="badges"><span class="badge write">WRITE · CONFIRM</span><span class="badge smart">SMART</span></div></article>
|
||||
|
||||
<article class="shortcut-row" data-tool data-domain="personal" data-risk="read"><div class="command"><code>dws drive +recycle-list</code></div><div class="row-main"><p>严格分页列出回收项并稳定投影 recycleItemId。</p><small>个人与公开</small></div><div class="badges"><span class="badge read">READ</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="personal" data-risk="read"><div class="command"><code>dws drive +star-list</code></div><div class="row-main"><p>严格分页列出当前用户收藏并保留游标。</p><small>个人与公开</small></div><div class="badges"><span class="badge read">READ</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="personal" data-risk="write"><div class="command"><code>dws drive +star-add</code></div><div class="row-main"><p>以幂等用户偏好语义收藏指定节点。</p><small>个人与公开</small></div><div class="badges"><span class="badge write">WRITE</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="personal" data-risk="write"><div class="command"><code>dws drive +star-remove</code></div><div class="row-main"><p>以幂等用户偏好语义取消收藏指定节点。</p><small>个人与公开</small></div><div class="badges"><span class="badge write">WRITE</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="personal" data-risk="read"><div class="command"><code>dws drive +publish-get</code></div><div class="row-main"><p>只读查询互联网公开状态,不沿用错误的写风险标签。</p><small>个人与公开</small></div><div class="badges"><span class="badge read">READ</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="personal" data-risk="high"><div class="command"><code>dws drive +publish-unset</code></div><div class="row-main"><p>关闭互联网公开并读回验证外链状态。</p><small>个人与公开</small></div><div class="badges"><span class="badge high">HIGH · CONFIRM</span><span class="badge smart">SMART</span></div></article>
|
||||
|
||||
<article class="shortcut-row" data-tool data-domain="version" data-risk="read"><div class="command"><code>dws drive +version-history</code></div><div class="row-main"><p>严格分页列出普通文件历史版本。</p><small>历史版本</small></div><div class="badges"><span class="badge read">READ</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="version" data-risk="read"><div class="command"><code>dws drive +version-get</code></div><div class="row-main"><p>按正整数版本号精确匹配,零命中显式失败。</p><small>历史版本</small></div><div class="badges"><span class="badge read">READ</span><span class="badge smart">SMART</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="version" data-risk="read"><div class="command"><code>dws drive +version-download</code></div><div class="row-main"><p>预检版本后安全下载历史字节,要求非零产物。</p><small>历史版本</small></div><div class="badges"><span class="badge read">READ</span><span class="badge smart">SMART</span></div></article>
|
||||
<article class="shortcut-row" data-tool data-domain="version" data-risk="high"><div class="command"><code>dws drive +version-revert</code></div><div class="row-main"><p>验证版本存在后回滚,并读取当前节点终态。</p><small>历史版本</small></div><div class="badges"><span class="badge high">HIGH · CONFIRM</span><span class="badge smart">SMART</span></div></article>
|
||||
</div>
|
||||
<div class="callout warn" style="margin-top:14px"><strong>未公开入口:</strong><code>+publish-set</code> 的安全契约和 set→get 读回代码已存在,但真实后端返回 <code>operation.notSupported</code>。在找到 eligible 节点并完成 set→get→unset 闭环前,不进入公开 Agent catalog。</div>
|
||||
</section>
|
||||
|
||||
<section id="e2e">
|
||||
<div class="section-head"><div><p class="section-kicker">Real-data E2E</p><h2>真实数据验证,不用空结果证明成功</h2></div><p class="section-desc">测试在隔离目录创建临时资源,覆盖读取、写入、下载、版本、收藏、回收和清理。资源 ID、账号、URL、上传凭证、绝对路径和业务正文均未进入报告。</p></div>
|
||||
<div class="evidence-grid">
|
||||
<article class="evidence-card"><strong>39,838 B</strong><p>真实文件上传后下载字节数;与源文件 SHA-256 完全一致。</p></article>
|
||||
<article class="evidence-card"><strong>2 versions</strong><p>覆盖写入生成两个版本;精确查询、历史下载和回滚全部读回。</p></article>
|
||||
<article class="evidence-card"><strong>4 / 5</strong><p>隔离夹具中搜索命中 4 项、最近列表命中 5 项,证明非空投影链路。</p></article>
|
||||
<article class="evidence-card"><strong>0 remain</strong><p>测试结束后隔离根目录无残留;临时资源进入回收站并完成本地清理。</p></article>
|
||||
</div>
|
||||
<div class="timeline">
|
||||
<div class="event"><b>创建与发现</b><span>创建两个隔离目录并读回;+list 命中真实节点,由此发现并修复 dentryId 与 32 字符 fileId 混用。</span></div>
|
||||
<div class="event"><b>上传与下载</b><span>真实 OSS 上传、远端元数据读回、下载、no-clobber 二次路径、大小与 SHA-256 一致性全部通过。</span></div>
|
||||
<div class="event"><b>检查与个人状态</b><span>+inspect(含 stats / publish / cover)、+stats、+cover、收藏 add→list→remove 通过。</span></div>
|
||||
<div class="event"><b>版本闭环</b><span>覆盖文件产生两个版本;history/get/download/revert 通过,回滚后最新字节与原始内容一致。</span></div>
|
||||
<div class="event"><b>复制、移动与命名</b><span>在线文档 copy 通过;普通文件 .dlink 被修正为预检拒绝;move 往返、rename 扩展名规范化通过。</span></div>
|
||||
<div class="event"><b>删除与恢复</b><span>delete→recycle-list→recycle-restore 通过,真实回收响应字段已按后端形态修正。</span></div>
|
||||
<div class="event"><b>平台负向证据</b><span>publish-set 对普通文件和在线文档均明确返回不支持,因此保持 unavailable;没有把失败改写成空对象成功。</span></div>
|
||||
<div class="event"><b>清理</b><span>隔离目录进入回收站,根目录残留计数为零;本地下载产物删除。</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="review">
|
||||
<div class="section-head"><div><p class="section-kicker">Review prompts</p><h2>建议业务评审重点确认</h2></div><p class="section-desc">这些是需要接受的产品边界,不是被空结果遮蔽的实现问题。</p></div>
|
||||
<div class="review-grid">
|
||||
<article class="review-card"><span class="review-num">01</span><h3>是否接受 26 / 7 / 5 结论?</h3><p>按用户任务计覆盖、部分与缺口,不用同名命令数量代替语义保真。</p></article>
|
||||
<article class="review-card"><span class="review-num">02</span><h3>普通文件 copy 是否足够清晰?</h3><p>服务端无法提供原子独立副本;快捷方式与下载后上传两条替代路径已明确。</p></article>
|
||||
<article class="review-card"><span class="review-num">03</span><h3>是否拒绝不可靠目录 sync?</h3><p>缺稳定 hash、删除/重命名日志和冲突向量时,不发布可能覆盖数据的双向同步。</p></article>
|
||||
<article class="review-card"><span class="review-num">04</span><h3>跨产品路由是否合理?</h3><p>评论、导入导出和协作者权限优先复用 Doc 成熟入口,不在 Drive 制造同义表面。</p></article>
|
||||
<article class="review-card"><span class="review-num">05</span><h3>publish-set 是否继续 unavailable?</h3><p>建议维持,直到真实 eligible 节点完成开启、查询、关闭的可恢复闭环。</p></article>
|
||||
<article class="review-card"><span class="review-num">06</span><h3>下一批后端解锁优先级?</h3><p>建议依次评估普通文件原子 copy、同步所需版本信号、安全标签和评论恢复接口。</p></article>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer><div class="wrap">依据:DWS 最终 Shortcut catalog / Schema、Drive 实现与测试、真实账号 E2E、lark-cli Drive registrations 与实现。范围仅含 Drive Shortcut 及必要跨产品路由;不包含原子命令总表。所有业务标识、凭证、签名 URL、用户信息和正文均已脱敏。</div></footer>
|
||||
<script>
|
||||
const q = document.querySelector('#q');
|
||||
const domain = document.querySelector('#domain');
|
||||
const risk = document.querySelector('#risk');
|
||||
const rows = [...document.querySelectorAll('[data-tool]')];
|
||||
const count = document.querySelector('#result-count');
|
||||
function filterTools() {
|
||||
const needle = q.value.trim().toLocaleLowerCase('zh-CN');
|
||||
let visible = 0;
|
||||
rows.forEach((row) => {
|
||||
const show = (!needle || row.textContent.toLocaleLowerCase('zh-CN').includes(needle)) && (domain.value === 'all' || row.dataset.domain === domain.value) && (risk.value === 'all' || row.dataset.risk === risk.value);
|
||||
row.classList.toggle('hidden-row', !show);
|
||||
if (show) visible += 1;
|
||||
});
|
||||
count.textContent = `显示 ${visible} / ${rows.length}`;
|
||||
}
|
||||
[q, domain, risk].forEach((control) => control.addEventListener('input', filterTools));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,82 @@
|
||||
# Drive Shortcut 对齐与超越 Lark CLI
|
||||
|
||||
## 目标与判定口径
|
||||
|
||||
本轮以 Lark CLI `drive` 的 38 个 shortcut 为对照,但不把“同名命令数量”当完成标准。对齐按用户任务判定:
|
||||
|
||||
1. `drive +...` 有更稳定的 Agent 主入口时,提供 Shortcut,并发布 Selection、Safety、Result 与 Pagination。
|
||||
2. 钉钉已经在其他产品提供更成熟入口时,Skill 明确跨产品路由,不在 Drive 重复实现。
|
||||
3. 只有原子能力且 Shortcut 不增加校验、编排或投影价值时,保留 Runtime Schema leaf,不制造同义别名。
|
||||
4. 下层接口不存在或无法满足相同语义时,明确记录 gap;不得用空数组、空对象或只返回任务提交结果伪装完成。
|
||||
|
||||
成功判定统一为:进程成功 + 统一结果 `ok=true/outcome=success` + 必要业务字段 + 真实数据读回或本地字节校验。服务端显式返回空数组可以是合法业务空结果;空响应、缺少数组、数组类型错误、坏元素、`success=false`、写入缺少终态证据都必须失败。
|
||||
|
||||
## Lark 38 项映射
|
||||
|
||||
| Lark Drive shortcut | DWS 路由 | 结论与原因 |
|
||||
|---|---|---|
|
||||
| `+upload` | `drive +upload` | 对齐并增强:工作目录边界、OSS PUT、严格 commit、元数据读回。 |
|
||||
| `+create-folder` | `drive +create-folder` | 对齐并增强:要求新 fileId 和名称读回。 |
|
||||
| `+create-shortcut` | `drive +create-shortcut` | 对齐并增强:明确 shortcut≠copy,创建后读回。 |
|
||||
| `+download` | `drive +download` | 对齐并增强:真实落盘、no-clobber、原子发布、非零字节。 |
|
||||
| `+preview` | `drive +cover`(有限) | 不完全对齐:钉钉当前只提供封面/缩略图读取,没有等价的服务端多格式预览转换接口。 |
|
||||
| `+cover` | `drive +cover` | 对齐:严格读取封面/缩略图对象。 |
|
||||
| `+add-comment` | `doc +comment-create` | 用户任务对齐;评论归在线文档协作域,Drive 不复制一套。 |
|
||||
| `+list-comments` | `doc +comment-list` | 用户任务对齐;Doc 已有类型、状态与分页。 |
|
||||
| `+batch-query-comments` | `doc +review` / `doc +comment-list` | 超越:可聚合未解决评论与确定性正文上下文;跨文档批量仍由调用方按节点编排。 |
|
||||
| `+resolve-comment` | `doc +comment-update`(有限) | 部分对齐:DWS 可更新评论,但当前下层未声明独立 resolve 状态接口。 |
|
||||
| `+restore-comment` | 无等价 | gap:钉钉当前下层未暴露恢复已删除评论的等价能力。 |
|
||||
| `+add-reply` | `doc +comment-reply` | 对齐。 |
|
||||
| `+list-replies` | `doc +comment-list` | 用户任务对齐:评论列表返回回复上下文;无独立 Drive reply 目录。 |
|
||||
| `+update-reply` | `doc +comment-update` | 对齐到评论/回复统一更新语义。 |
|
||||
| `+delete-reply` | `doc +comment-delete` | 对齐到评论/回复统一删除语义,高风险确认。 |
|
||||
| `+react-reply` | `doc +comment-reply`(有限) | 部分对齐:支持表情回复;不声称拥有 Lark 的独立 reaction identity。 |
|
||||
| `+export` | `doc +export` | 用户任务对齐并增强:提交、轮询、安全下载一体化。 |
|
||||
| `+export-download` | `doc +export` / `doc +export-get` | 超越:常规一体化,`+export-get` 仅作中断恢复。 |
|
||||
| `+import` | `doc +import` | 用户任务对齐并增强:转换白名单、上传 fallback、轮询终态。 |
|
||||
| `+version-history` | `drive +version-history` | 对齐并增强:严格空结果与分页。 |
|
||||
| `+version-get` | `drive +version-get` | 对齐并增强:精确版本号,零命中失败。 |
|
||||
| `+version-revert` | `drive +version-revert` | 对齐并增强:版本预检、高风险确认、节点读回。 |
|
||||
| `+version-delete` | 无等价 | gap:钉钉当前普通文件版本接口没有删除历史版本能力。 |
|
||||
| `+move` | `drive +move` | 对齐;与 copy/shortcut 明确消歧并发布确认。 |
|
||||
| `+delete` | `drive +delete` | 对齐;移入回收站、高风险确认、终态证据。 |
|
||||
| `+status` | `drive +inspect` | 超越:远端身份、统计、公开状态和封面按需聚合;不伪装成本地同步状态。 |
|
||||
| `+push` | `drive +upload`(单文件) | 部分对齐:单文件上传可靠;没有可靠的目录 diff、冲突和远端删除传播语义,因此不提供同名批量 push。 |
|
||||
| `+pull` | `drive +download`(单文件) | 部分对齐:单文件下载可靠;目录级增量拉取需稳定路径、hash 与冲突策略,当前接口不完整。 |
|
||||
| `+sync` | 无等价 | gap:在缺少稳定远端内容 hash、rename/delete journal 和冲突版本向量时,双向同步会有数据覆盖风险。 |
|
||||
| `+task_result` | `doc +export-get` / 导入任务恢复入口 | 用户任务对齐;DWS 按任务所属产品提供类型化恢复入口,不保留 Lark 的下划线泛化命令。 |
|
||||
| `+apply-permission` | `drive permission apply` raw leaf | 下层能力存在但未提升为 Shortcut:需要真实申请上下文和权限夹具,无法在通用 E2E 中安全创建。 |
|
||||
| `+member-add` | `doc +access-grant` | 用户任务对齐并增强:解析接收人、批量 ledger、首次写入前停止。 |
|
||||
| `+member-list` | `drive permission list` / `doc +inspect --include-permissions` | 对齐;常规 Agent 场景优先 Doc 聚合检查。 |
|
||||
| `+permission-get-setting` | `drive permission list` + `drive +publish-get` | 部分对齐:协作者与互联网公开是两个独立安全域,没有一个与 Lark setting 完全同构的钉钉接口。 |
|
||||
| `+secure-label-list` | 无等价 | gap:当前 DWS/钉钉下层没有可声明的 Drive 安全标签目录接口。 |
|
||||
| `+secure-label-update` | 无等价 | gap:没有安全标签写接口,不能用普通权限或公开状态替代。 |
|
||||
| `+search` | `drive +search` | 对齐并增强:过滤、严格数组和分页;在线文档搜索路由 `doc +search`。 |
|
||||
| `+inspect` | `drive +inspect` | 对齐并增强:必达元数据 + 可选聚合,部分失败不伪装成功。 |
|
||||
|
||||
## DWS 超出 Lark Drive 的可挖掘能力
|
||||
|
||||
- `+list`:严格目录分页,而不是把缺字段当空目录。
|
||||
- `+recent`:最近访问/编辑与创建人筛选。
|
||||
- `+stats`:阅读、编辑、评论、点赞、预览和下载统计。
|
||||
- `+recycle-list` / `+recycle-restore`:显式回收项身份与恢复后读回。
|
||||
- `+star-list` / `+star-add` / `+star-remove`:个人收藏完整闭环。
|
||||
- `+publish-get` / `+publish-unset`:互联网公开独立安全域与关闭后读回;`+publish-set` 保留为 unavailable 诊断入口。
|
||||
- `+version-download`:历史版本真实字节下载与本地 artifact 校验。
|
||||
- `+rename`:写后读回验证。
|
||||
|
||||
普通钉盘文件的独立 `copy` 是额外确认出的部分 gap:钉钉当前 `doc/copy_document` 对该对象会生成 `.dlink`,不是字节独立副本。`drive +copy` 因此只接受在线对象;普通文件需要快捷入口时用 `+create-shortcut`,需要独立副本时用 `+download` 后 `+upload`。这不是完整的服务端原子 copy,对大文件也不能宣称完全等价。
|
||||
|
||||
互联网公开开启也是账号/对象能力 gap:真实普通文件与在线文档夹具都由服务端返回 `operation.notSupported`。`+publish-get` 与关闭语义可验证,但 `+publish-set` 在找到 eligible 节点完成 set→get→unset 闭环前保持 `unavailable` 且不进入公开 Agent catalog。
|
||||
|
||||
## 端到端门禁
|
||||
|
||||
每个公开 Drive shortcut 必须至少覆盖:
|
||||
|
||||
- Cobra 参数、静态确认、Shortcut Execute、MCP 调度和最终输出;
|
||||
- 明确业务空集合、空响应、缺字段、错误类型、坏元素、`success=false`;
|
||||
- 写入的 ID/终态证据与读回不一致;
|
||||
- 下载的本地路径边界、no-clobber、真实字节数;
|
||||
- 真实账号数据:读命令必须命中已知非空夹具或明确验证合法空集合;写命令必须创建隔离资源、读回、必要时下载比对字节并清理。
|
||||
|
||||
发布前运行 `make build`、完整 Go 测试、Schema 生成/漂移/策略检查,并保存不含账号业务内容的结构化 E2E 汇总。
|
||||
+13
-2
@@ -17,7 +17,8 @@
|
||||
|
||||
1. 在上述 `Release` 页面选择 `Run workflow`,分支必须是默认分支 `main`。
|
||||
2. `release_operation=plan`,选择 `release_channel=beta|stable`;仅在开始新 beta 线时选择 `release_bump=patch|minor|major`。
|
||||
3. workflow summary 会给出唯一的下一版本。把对应的精确 `CHANGELOG.md` 章节通过 PR 合入 `main`。
|
||||
3. workflow summary 会给出唯一的下一版本。运行 `prepare-changelog.sh` 将已合入的
|
||||
release fragments 汇总成对应的精确 `CHANGELOG.md` 章节,并通过唯一的 release-seal PR 合入 `main`。
|
||||
4. 再次运行,改为 `release_operation=publish`。beta 会直接进入自动化发布;stable 会在封 tag 前等待管理员签收。
|
||||
|
||||
`plan` 是纯只读操作,不创建 tag、预留版本号或生成包。CHANGELOG 合入期间若另一个发布先占用了该版本,`publish` 会重新分配并因 CHANGELOG 章节不匹配而拒绝,需要重新 plan。`publish` 会先再次确认 dispatch SHA 仍是当前 `main`、Code Admission 和平台治理均通过,再由唯一的 write job 使用 GitHub API 原子创建 annotated tag;同一次 run 随即进入既有的跨平台构建、GitHub/npm、可选 OSS/Gitee 发布和 Homebrew 直交付 DAG。内置 `GITHUB_TOKEN` 创建的 tag 不依赖第二条 workflow 被再次触发。
|
||||
@@ -94,7 +95,8 @@ main 上的候选代码 + beta CHANGELOG
|
||||
dws-release v1.2.3-beta.1
|
||||
```
|
||||
|
||||
如果 CHANGELOG 尚不存在,该命令只生成模板并停止。补全内容、删除所有 `TODO`,提交后通过 PR 合入 `main`;然后重新运行完全相同的命令,它会执行完整预检:
|
||||
如果 CHANGELOG 尚不存在,该命令会从 `.changes/*.md` 生成 beta 章节并归档已消费的
|
||||
fragments,然后停止。审阅生成内容并通过唯一的 release-seal PR 合入 `main`;然后重新运行完全相同的命令,它会执行完整预检:
|
||||
|
||||
```bash
|
||||
dws-release v1.2.3-beta.1
|
||||
@@ -132,6 +134,15 @@ dws-release v1.2.3 --from-beta v1.2.3-beta.1
|
||||
|
||||
正式版使用 `## [1.2.3] - YYYY-MM-DD`。该章节会直接成为 GitHub Release Notes。
|
||||
|
||||
### Release fragments
|
||||
|
||||
普通 PR 不修改 `CHANGELOG.md` 的 `Unreleased` 区域。需要面向用户发布说明的改动在
|
||||
`.changes/<unique-name>.md` 中增加一个独立 fragment;格式和允许的分类见
|
||||
[`.changes/README.md`](../.changes/README.md)。预发封板时
|
||||
`scripts/release/prepare-changelog.sh prerelease <version>` 会稳定排序并汇总所有未归档
|
||||
fragment,写入唯一版本章节后移动到 `.changes/released/<version>/`。因此并发 PR 不会争用
|
||||
`CHANGELOG.md`;唯一的 release-seal PR 同时提交生成的章节与归档移动,供审计复核。
|
||||
|
||||
## CI/CD 保证
|
||||
|
||||
- 只接受 `vX.Y.Z-beta.N` 和 `vX.Y.Z`,且新版本必须高于上一正式版。这里的“上一正式版”必须同时具备公开非草稿 GitHub Release 和同 tag/commit 的成功 Release workflow;只有 tag、没有交付成功的孤儿版本会阻断后续发布,要求走机器核验恢复补齐。云端 tag 会固定 `Release-Run`、requester、commit 和版本分配指纹,交付验证按该精确 run/attempt 及完整 job graph 取证,不接受任意 `workflow_dispatch`。历史版本若曾通过专用 recovery workflow 完成交付,只能使用仓库内 `delivered-stable-recoveries.json` 中精确到 tag、commit、run、workflow SHA 与 attempt 的 reviewed 证据。
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
# RFC:DWS 预制 Skill 安装、升级与模式迁移
|
||||
|
||||
| 字段 | 内容 |
|
||||
|---|---|
|
||||
| 状态 | Accepted / as implemented |
|
||||
| 生效范围 | DWS CLI、升级器、npm 与平台安装脚本 |
|
||||
| 事实源 | 本 RFC 与当前代码;两者冲突时以代码和测试为准 |
|
||||
| 关联合同 | [Skill 内容框架](skill-content-framework.md)、[Mono↔Multi 内容质检](skill-mono-multi-qa.md) |
|
||||
|
||||
## 1. 背景
|
||||
|
||||
DWS 同时通过 CLI、升级器、npm、Shell 和 PowerShell 分发预制 Skill。multi
|
||||
成为默认布局后,所有入口必须对安装集合、模式互斥、失败退出、缓存发布和目录
|
||||
所有权保持一致。此前分散的调研、迁移计划、阶段性 roadmap 和 rollout 文档容易
|
||||
相互冲突;本 RFC 将最终行为收敛为一个长期合同。
|
||||
|
||||
## 2. 目标与非目标
|
||||
|
||||
### 2.1 目标
|
||||
|
||||
- 新装与升级默认使用 multi 布局,mono 在兼容期内保留显式 opt-in。
|
||||
- 每次升级使用当前版本的官方清单全量覆盖预制 Skill。
|
||||
- 删除或替换任何目录前先创建可恢复备份,备份失败不修改该 Agent 目标。
|
||||
- 只清理能够证明由 DWS 管理的目录,不通过名称前缀推断所有权。
|
||||
- 所有安装入口对部分失败返回非零状态,不误报整体成功。
|
||||
- 安装预览、确认和实际执行使用同一份计划。
|
||||
|
||||
### 2.2 非目标
|
||||
|
||||
- 不建设独立的 `dws skill mode status|set|rollback` 产品面。
|
||||
- 不持久化用户对预制 Skill 的本地删除或排除意图。
|
||||
- 不提供跨所有 Agent 目标的事务式回滚。
|
||||
- 不把市场 Skill 纳入预制 Skill 的升级和清理范围。
|
||||
|
||||
## 3. 业内调研
|
||||
|
||||
对主流 CLI 与 Agent Skill 分发方式的公开实现进行归纳后,可以得到以下共性:
|
||||
|
||||
| 观察 | 对 DWS 的启示 |
|
||||
|---|---|
|
||||
| 多个产品能力通常以同级 Skill 目录安装,由 Agent 按目录发现 | multi 使用平铺的产品 Skill,并保留一个共享 Skill 承载公共协议 |
|
||||
| CLI 本体安装和 Agent Skill 安装是两个生命周期 | DWS 可以在 CLI 安装、setup 和 upgrade 中触发 Skill 同步,但二者的失败与状态必须分别报告 |
|
||||
| 生态安装器通常天然采用 multi,不提供 mono/multi 状态机 | DWS 的模式切换保持为重新执行 setup,不新增长期驻留的 mode lifecycle |
|
||||
| 市场 Skill 与 CLI 预制 Skill 可能落在同一 Agent 根目录 | 必须使用统一所有权元数据识别受管目录,名称前缀不能作为删除依据 |
|
||||
| 多 Skill 更新常以新清单刷新官方集合 | DWS 使用当前 bundle 官方清单全量覆盖,新增 Skill 自动加入,本地删除不视为持久化排除 |
|
||||
| 制品可能需要同时服务无运行时依赖、离线和多镜像环境 | DWS 保留 embed、zip 和平台安装脚本,不把单一生态包管理器设为唯一入口 |
|
||||
| 中断的复制和原地覆盖容易破坏最后一个可用版本 | 缓存与 Go upgrade 的 Agent 目标采用 staging publish;发布失败自动恢复该目标的完整旧集合 |
|
||||
| Agent 通常以 `SKILL.md` 为入口,其他文件按引用或工具规则按需读取 | 安装元数据使用不被内容引用的隐藏文件,并保证其内容不包含 Agent 指令 |
|
||||
|
||||
本节只保留可复用的工程结论,不记录具体产品、仓库、版本或逐项能力对照,也不构成
|
||||
DWS 对任何外部实现的持续兼容义务。后续设计以 DWS 自身约束和本 RFC 的行为合同为准。
|
||||
|
||||
## 4. 布局合同
|
||||
|
||||
| 模式 | Agent 目录布局 | 选择方式 |
|
||||
|---|---|---|
|
||||
| multi(默认) | `<agent-home>/dingtalk-*/` 与必选 `dingtalk-shared/` | 默认;`dws skill setup --mode multi` |
|
||||
| mono(兼容) | `<agent-home>/dws/` | `dws skill setup --mode mono` 或安装器的 mono opt-in |
|
||||
|
||||
模式切换通过重新执行 setup 完成。安装 multi 前备份并移除 mono 的 `dws/`;安装
|
||||
mono 前只备份并移除能够证明由 DWS 管理的 multi 目录。两个方向都不提供隐式、
|
||||
不可恢复的删除。
|
||||
|
||||
## 5. 官方集合与升级策略
|
||||
|
||||
当前版本 bundle 中的 multi 目录清单是升级集合的唯一权威来源。普通 upgrade 和
|
||||
`--force` 都安装并覆盖该版本的全部官方预制 Skill:
|
||||
|
||||
- 本地删除的预制 Skill 会在下一次升级恢复;
|
||||
- setup 时通过 `--exclude` 暂时排除的 Skill 会在下一次升级恢复;
|
||||
- 新版本新增的官方 Skill 会自动安装;
|
||||
- 用户对预制 Skill 的本地修改会被官方版本覆盖;
|
||||
- `dingtalk-shared` 始终随官方集合安装。
|
||||
|
||||
`~/.dws/skills-state.json`(设置 `DWS_CONFIG_DIR` 时位于该目录)不参与安装集合
|
||||
求解,也不保存排除策略。它既记录结果快照,也集中记录 multi Skill 的所有权和
|
||||
provenance,供安全清理、诊断与后续迁移使用。
|
||||
|
||||
## 6. 目录所有权
|
||||
|
||||
每次 multi setup 或 upgrade 全部成功后,DWS 在统一的
|
||||
`~/.dws/skills-state.json` 中写入:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "v0.2.14",
|
||||
"official_skills": ["dingtalk-aitable"],
|
||||
"updated_skills": ["dingtalk-aitable"],
|
||||
"managed_skills": [
|
||||
{
|
||||
"name": "dingtalk-aitable",
|
||||
"version": "v0.2.14",
|
||||
"source": "dws-upgrade",
|
||||
"digest": "sha256:<64 个十六进制字符>",
|
||||
"digest_scope": "skill-directory-v1"
|
||||
}
|
||||
],
|
||||
"updated_at": "2026-08-11T12:34:56Z"
|
||||
}
|
||||
```
|
||||
|
||||
每条 `managed_skills` 记录代表一个由 DWS 管理的官方 Skill。`version` 记录安装该
|
||||
副本的 DWS/发布包版本,`source` 记录安装入口,`digest` 是对 bundle 中 Skill 目录
|
||||
全部普通文件按相对路径排序后计算的内容摘要。摘要用于诊断和来源追踪,不作为后续
|
||||
升级的完整性门禁;用户修改 Skill 内容后,DWS 仍保有明确管理权并能在下一次升级时
|
||||
覆盖恢复。
|
||||
|
||||
清理 stale Skill 或切换到 mono 时,只接受以下所有权证据:
|
||||
|
||||
1. Skill 名称存在于统一状态的 `managed_skills` 中;
|
||||
2. 统一状态上线前曾发布过的官方 Skill 精确名称集合。
|
||||
|
||||
历史集合是冻结的迁移清单,包含 `dws-shared` 以及已退役、折叠或仍在发布的旧官方
|
||||
目录名。仅有 `dingtalk-*` 前缀不构成所有权证据。因此,市场或用户创建的
|
||||
`dingtalk-custom` 等非官方精确名称目录不会被迁走。
|
||||
|
||||
### 6.1 对 Agent 的影响
|
||||
|
||||
Skill 目录内不再放置 DWS 所有权文件,也不增加非通用 frontmatter 字段。支持的
|
||||
Agent 仍只需以 `SKILL.md` 发现和加载 Skill;统一元数据位于 Agent Skill 目录之外,
|
||||
不会成为提示词上下文或影响 Agent 行为。
|
||||
|
||||
## 7. Setup:Plan → Confirm → Execute
|
||||
|
||||
`dws skill setup` 分为三个阶段:
|
||||
|
||||
1. **Plan**:只读计算目标、安装集合以及所有待备份路径;
|
||||
2. **Confirm**:`--dry-run` 和交互确认渲染同一份计划;
|
||||
3. **Execute**:确认后严格执行计划中的备份和安装。
|
||||
|
||||
安全要求:
|
||||
|
||||
- 非交互环境未传 `--yes` 时拒绝执行;
|
||||
- 用户拒绝确认时必须零文件写入;
|
||||
- 备份失败时跳过整个 Agent 目标,不开始铺设相反布局;
|
||||
- 同一目标先完成所有必要备份,再复制新集合;
|
||||
- multi Skill 必须在同级 staging 中完成复制,再原子发布到正式目录;
|
||||
- 任意 `skipped > 0` 都返回非零退出码,并且不写入完整成功快照;
|
||||
- 一个 Agent 目标失败不阻止其他目标尝试,但最终结果仍为失败。
|
||||
|
||||
## 8. Upgrade 与恢复语义
|
||||
|
||||
升级器对每个 Agent 目标执行:
|
||||
|
||||
- 先探测具体 Agent home;只在没有任何具体 Agent 时使用 `~/.agents/skills` 通用 fallback;
|
||||
- 具体 Agent 安装成功后,将 `~/.agents/skills` 中旧的 DWS 受管副本可恢复地迁入备份,避免 Codex 等同时扫描两个根目录时重复发现同名 Skill;
|
||||
|
||||
1. 只读计算对面布局、过期受管 Skill 和同名官方 Skill;
|
||||
2. 在目标文件系统的 staging 中复制完整新集合;
|
||||
3. staging 全部成功后,才将旧集合移入备份目录;
|
||||
4. 逐项发布 staging;任一发布失败时删除已发布的新目录,并逆序恢复该目标的全部旧目录;
|
||||
5. 仅在没有目标失败且至少一个目标成功时更新状态快照。
|
||||
|
||||
Go upgrade 当前提供 **单 Agent 目标级事务恢复**:复制失败发生在旧目录移动前;
|
||||
备份中途失败会恢复此前已移动的目录;发布中途失败会恢复该目标的完整旧集合。不同
|
||||
Agent 目标仍彼此独立,一个目标失败不会回滚此前已经成功升级的其他目标,这与
|
||||
“不提供跨所有 Agent 目标的事务式回滚”非目标保持一致。
|
||||
|
||||
## 9. 备份合同
|
||||
|
||||
- 路径:`~/.dws/skill-backups/<UTC 时间戳>/...`;
|
||||
- 主要操作:同一文件系统内使用 rename 移动;
|
||||
- 失败语义:备份失败时原目录保持不变,目标安装失败;
|
||||
- 可见性:计划和执行日志显示原路径与备份路径;
|
||||
- 保留策略:自动修剪,仅保留最近 5 批。
|
||||
|
||||
备份是安装安全机制,不等于独立 rollback 产品。需要切回 mono 时重新运行
|
||||
`dws skill setup --mode mono`。
|
||||
|
||||
## 10. 缓存与制品
|
||||
|
||||
发布制品和二进制内嵌内容同时携带 mono 与 multi 源树。`~/.dws/skills/` 只是
|
||||
setup 在未显式指定 `--source` 时的本地回退缓存。
|
||||
|
||||
缓存刷新必须采用同级 staging + publish:
|
||||
|
||||
1. 在 staging 中完整复制并验证新树;
|
||||
2. 发布前保留旧缓存;
|
||||
3. 通过 rename 发布新缓存;
|
||||
4. 复制或发布失败时保留或恢复旧缓存;
|
||||
5. 空、缺失或损坏的 bundle 不能擦除有效缓存。
|
||||
|
||||
## 11. 安装入口一致性
|
||||
|
||||
以下入口都遵守本 RFC:
|
||||
|
||||
| 入口 | 默认模式 | 失败合同 |
|
||||
|---|---|---|
|
||||
| `dws skill setup` | multi | 部分失败返回非零;不写完整成功状态 |
|
||||
| `dws upgrade` | bundle 含 multi 时安装 multi | 目标失败返回失败;下次全量重试 |
|
||||
| `scripts/install.sh` | multi | 任一检测到的目标失败则脚本非零 |
|
||||
| `scripts/install.ps1` | multi | 任一检测到的目标失败则脚本非零 |
|
||||
| `scripts/install-skills.sh` | multi | 任一检测到的目标失败则脚本非零 |
|
||||
| npm `install.js` | multi | 任一检测到的目标失败则 postinstall 失败 |
|
||||
|
||||
Homebrew 不直接向 Agent home 铺设 Skill;安装 CLI 后由 setup 执行相同流程。
|
||||
|
||||
## 12. 验收与回归门禁
|
||||
|
||||
合入和后续修改至少覆盖:
|
||||
|
||||
- mono → multi、multi → mono 互斥切换;
|
||||
- 状态上线前的官方 multi 目录切换 mono 时能够被精确迁移;
|
||||
- 未登记的同前缀市场/用户 Skill 在刷新和切换后仍存在;
|
||||
- 统一状态中登记的过期官方 Skill 被备份并移除;
|
||||
- 备份、复制、统一状态写入、缓存 publish 故障注入;
|
||||
- 非交互确认拒绝与显式 `--yes`;
|
||||
- 部分失败返回非零且不写错误状态快照;
|
||||
- 复制失败不留下 Agent 可见的残缺官方目录;
|
||||
- 普通 upgrade 恢复被删除的预制 Skill,并安装新增官方 Skill;
|
||||
- Windows、macOS、Linux 的路径和覆盖率门禁;
|
||||
- npm、Shell、PowerShell 与包管理器安装冒烟。
|
||||
|
||||
## 13. 后续演进
|
||||
|
||||
- 收敛各安装入口中的 Agent home 清单,减少跨语言复制;
|
||||
- 如确有运维需求,可单独设计备份查看和显式恢复命令;
|
||||
- mono 的物理删除必须作为独立变更,在 multi 内容、安装入口和迁移回归稳定后推进;
|
||||
- `managed_skills` 字段若演进,必须同步更新所有安装入口和跨平台回归。
|
||||
@@ -1,188 +0,0 @@
|
||||
# lark-cli Shortcut 深度对齐矩阵
|
||||
|
||||
> 12 个 agent 逐条深读 lark 每个 shortcut 的智能实现(Validate/DryRun/ID解析/投影/多步/分页),映射钉钉、标注保真度差距。
|
||||
|
||||
## 2026-07-13 最新源码复核
|
||||
|
||||
对比基线:
|
||||
|
||||
- DWS:`feature/shortcut@b7c14c1`(已合并 `origin/main@390b611`)
|
||||
- lark-cli:`main@e96c4fa5`
|
||||
- lark-cli 本轮更新范围:`f495cbb1..e96c4fa5`
|
||||
|
||||
本轮 lark-cli **没有增加或删除生产 shortcut 命令**,变化集中在已有命令的实现保真度:统一 `--json` shorthand、文档分享锚点读取、whiteboard 本地文件安全内联、VC meeting events 的 identity/timeline/NDJSON 投影、Apps DB 环境自动选择、Drive push 错误分类,以及 Wiki token 解析兼容性。因此下方历史 gap 清单的命令面没有因本轮 pull 新增条目,但若要追平体验,以下实现差距需要上调优先级。
|
||||
|
||||
### 当前命令面快照
|
||||
|
||||
| 指标 | 数量 | 说明 |
|
||||
|---|---:|---|
|
||||
| DWS built-in shortcut | 366 | 16 个服务;运行时 registry 实测 |
|
||||
| lark-cli primary shortcut | 363 | 19 个服务;排除 `_test.go` 与 42 个 `sheets/backward` 隐藏兼容别名 |
|
||||
| 双方可映射服务内命令 | DWS 313 / lark 324 | 12 组产品映射,不含平台特有服务 |
|
||||
| 同服务同名命令 | 50 | 仅是名称交集,不等于语义等价或保真度一致 |
|
||||
| DWS 平台特有 shortcut | 53 | attendance / ding / oa / report 等 |
|
||||
| lark 平台特有 shortcut | 39 | okr / vc / slides / markdown / whiteboard / note / event |
|
||||
|
||||
双方重叠服务的命令面如下;“同名”只用于定位,能力判断仍需看参数、验证、多步编排、输出投影和 dry-run:
|
||||
|
||||
| 产品映射 | DWS | lark | 同名 |
|
||||
|---|---:|---:|---:|
|
||||
| aitable ↔ base | 82 | 87 | 31 |
|
||||
| calendar ↔ calendar | 23 | 10 | 3 |
|
||||
| chat ↔ im | 89 | 21 | 2 |
|
||||
| contact ↔ contact | 16 | 2 | 1 |
|
||||
| devapp ↔ apps | 30 | 63 | 3 |
|
||||
| doc ↔ doc | 19 | 14 | 1 |
|
||||
| drive ↔ drive | 9 | 26 | 3 |
|
||||
| mail ↔ mail | 10 | 21 | 0 |
|
||||
| minutes ↔ minutes | 13 | 9 | 1 |
|
||||
| sheet ↔ sheets | 2 | 42 | 0 |
|
||||
| todo ↔ task | 13 | 17 | 2 |
|
||||
| wiki ↔ wiki | 7 | 12 | 3 |
|
||||
|
||||
### 最新优先差距
|
||||
|
||||
1. **文档与白板资源保真度**:lark `doc +fetch/+update` 已支持分享链接 selection anchor、HTML5 block 资源引用,以及相对路径内的 SVG/Mermaid/PlantUML whiteboard 安全内联。DWS 具备文档读写和媒体原子能力,但缺少统一引用解析、路径门禁和资源回写编排。
|
||||
2. **Sheets typed workflow**:lark 的 typed table、批量样式、维度移动/冻结、range copy/fill/sort、workbook import/export 仍是最大可建设缺口。DWS 原生 helper 已有部分底层能力,但 shortcut 层只有 2 个精选命令,缺少跨 sheet 分块写、类型推断和 partial rollback。
|
||||
3. **Drive 本地同步体验**:lark `+push/+pull/+sync/+import/+export` 带批量计划、错误分类、路径保护和版本操作;DWS 目前偏原子上传/搜索,缺完整目录同步和可恢复批处理。
|
||||
4. **Mail 高保真写链路**:lark 对 send/reply/reply-all/forward 提供模板、签名、HTML lint、线程头、定时和附件编排;DWS 有底层发信/草稿工具,但 smart shortcut 尚未覆盖这些组合体验。
|
||||
5. **消息资源与统一搜索**:DWS 已有 `+search-msg/+chat-messages/+thread-replies/+at-me` 等拆分场景,lark `+messages-search` 仍在统一多维过滤、会话上下文富化、reaction/资源下载方面更完整。
|
||||
6. **会议事件输出**:lark `vc +meeting-events` 本轮新增当前身份、actor、会议状态推断、timeline 与 NDJSON 元数据。DWS 最新 main 已有更强的实时 event bus 和个人事件订阅,但尚未沉淀成同等级 shortcut 投影;这是“底层能力领先、shortcut UX 未收口”。
|
||||
|
||||
### 不建议机械追平
|
||||
|
||||
- lark Apps DB、Spark 发布、Lark Drive/Wiki 特有对象模型属于平台差异,不应只为同名率复制。
|
||||
- DWS 的 attendance、DING、OA、report、agoal 和最新 event bus 是钉钉侧差异化能力,应优先做场景化组合,而不是追求 363 vs 366 的数字对齐。
|
||||
- DWS 已具备按姓名解析、跨产品智能编排、失败回滚和 usage→自定义 shortcut 沉淀闭环,这些能力无法由同名命令统计体现。
|
||||
|
||||
> 注:下方“361 条”汇总是上一轮逐条人工分类的历史基线;当前 lark-cli primary shortcut 是 363 条,另有 42 个不应重复计为能力的 Sheets 隐藏兼容别名。历史条目的判断仍可复用,但总量数字不能直接代表本轮最新覆盖率,后续应把新增条目按 covered-1to1 / covered-smart / gap-buildable / no-dingtalk-tool 四类补录。
|
||||
|
||||
## 汇总(361 条 lark shortcut)
|
||||
|
||||
| dws_status | 数量 | 含义 |
|
||||
|---|:---:|---|
|
||||
| covered-1to1 | 144 | lark 组合在钉钉塌缩成 1:1,封装层已覆盖 |
|
||||
| no-dingtalk-tool | 127 | 钉钉无对应工具,客观不可对齐 |
|
||||
| **gap-buildable** | **41** | 钉钉有工具、值得补成智能 shortcut(**建设目标**);已建 minutes `+detail`/`+replace-batch`、base `+record-share-links`/`+resolve-base`、im `+thread-replies`/`+chat-messages`/`+chat-list`、task `+related-tasks` |
|
||||
| covered-smart | 49 | 已建智能 shortcut / 部分覆盖 |
|
||||
|
||||
## 🎯 gap-buildable 目标清单(原 49 条,已建 8 → 剩 41,按服务)
|
||||
|
||||
> 已落地:minutes `+detail`(✅ smart `+detail`)、minutes `+word-replace`(✅ smart `+replace-batch`,批量+去重)、base `+record-share-link-create`(✅ smart `+record-share-links`,>20 去重+分片+合并)、im `+threads-messages-list`(✅ smart `chat +thread-replies`,list_topic_replies + 投影)、im `+chat-list`(✅ smart `chat +chat-list`)、task `+get-related-tasks`(✅ smart `todo +related-tasks`,三角色并集+去重+投影)。
|
||||
|
||||
### im → chat(7)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+chat-list` ✅ | read | **已建 smart `chat +chat-list`**:`list_all_conversations` + 默认仅群聊 + `--types group/p2p` + `--exclude-muted` + page-size/page-token 别名 + `--page-all/--page-limit` 数字 cursor 自动翻页、跨页去重、合并后类型过滤和完整性 ledger。剩余未做:sort/sort-type、bot 身份 p2p 剥离(DWS 无对应身份模型) |
|
||||
| `+chat-messages-list` ✅ | read | **已建 smart `chat +chat-messages`**:群/单聊互斥解析、时间范围、asc/desc、时间边界全量翻页、reaction、资源下载与完整性 ledger |
|
||||
| `+chat-search` ✅ | read | **已建 smart `chat +chat-search`**:真实 `search_groups` 关键词搜索 + page-size/page-token 别名 + `--page-all/--page-limit` 不透明 cursor 自动翻页、跨页去重和完整性 ledger。Lark v2 的 member/type/mode/manager/sort 过滤没有可验证的钉钉对应参数,未伪造 |
|
||||
| `+flag-list` ✅ | read | **已建 smart `chat +flag-list`**:真实 `list_message_favorites` 的 `items + hasMore + 数字 nextCursor`,支持 page-size/page-token、`--page-all/--page-limit`、跨页去重和完整性 ledger;仅对齐 message favorite,不模拟 Lark Feed thread flag |
|
||||
| `+messages-resources-download` | write | dws download-media 走 get_resource_download_url 拿URL,缺分片Range下载/重试/扩展名推断/安全落盘路径校验 |
|
||||
| `+messages-search` ✅ | read | **已建 smart `chat +search-msg`**:统一多维过滤、精确时间范围、asc/desc、cursor 全量翻页、mget 富化、reaction、资源下载与完整性 ledger。剩余差异是 Lark chat 上下文和部分 sender/attachment 类型过滤 |
|
||||
| `+threads-messages-list` ✅ | read | **已建 smart `chat +thread-replies`**:支持主消息 ID 自动只读解析 conversation/thread,也支持显式 group + thread/topic ID;list_topic_replies + sender/text/time/reaction/resource 投影 + 下层毫秒级 nextCursor 有界自动翻页、跨页去重、完整性 ledger,以及全量结果 asc/desc。与 Lark 的剩余差异是钉钉底层没有服务端 asc 单页,因此 DWS 的 asc 明确要求 `--page-all`,避免伪全局排序 |
|
||||
|
||||
### task → todo(3)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+reminder` | write | dws 有 add_todo_reminder/reset_todo_reminder 但无 lark 的先查现有再替换编排、相对时间(15m/1h)解析与互斥校验,值得补智能 shortcut |
|
||||
| `+get-related-tasks` ✅ | read | **已建 smart `todo +related-tasks`**:creator+executor+participant 三角色并集 + taskId 去重 + 投影。剩余未做:followed-by-me 成员比对、subtask_count/tasklists 富投影 |
|
||||
| `+upload-attachment` | write | dws add-attachment 走 init→PUT→commit 三步 MCP 上传(能力更重),但无 50MB/regular 校验、applink 提取与 dry-run 计划展示;可对齐成更智能 shortcut |
|
||||
|
||||
### calendar → calendar(1)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+room-find` | read | dws 有 room search(query_available_meeting_room 按单一时间段+过滤)和 busy search,但无多slot并发room_find聚合、无city/building/floor/capacity维度过滤、无按attendee推荐可用室,值得补成智能 shortcut 但未建 |
|
||||
|
||||
### doc (docs) → doc(2)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+media-insert` | write | dws doc media insert 为3步(取凭证→PUT→insert_document_block)无回滚、无selection定位、无剪贴板、无宽高比补算、无wiki解析;可补成带回滚的智能shortcut |
|
||||
| `+media-download` | read | dws doc media download 走resourceId→downloadUrl两段,缺whiteboard导图分支、自动扩展名、路径安全、overwrite防护;media分支可对齐,whiteboard无工具 |
|
||||
|
||||
### drive → drive(1)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+import` | write | dws drive upload 有 --workspace --convert 可转在线文档,但缺按目标类型(docx/sheet/bitable/slides)导入、缺 target-token 挂载与异步轮询 |
|
||||
|
||||
### mail → mail(4)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+reply` | write | dws reply 走 create_reply_draft+send_draft 两步、附件仅上传会话,缺 EML 线程头构造、签名自动注入、模板合并、HTML lint、读回执、send-time 定时、跨字段校验 |
|
||||
| `+reply-all` | write | dws reply-all 两步且收件人由服务端决定,缺原文收件人抽取去重排己、线程头、签名/模板/lint/定时等编排保真 |
|
||||
| `+send` | write | dws send_email 单步(附件时先 create_draft 再传再 send),缺签名/模板/lint/日历内嵌/定时发送/发件人profile解析/跨字段校验 |
|
||||
| `+forward` | write | dws forward 走 create_forward_draft+send_draft,缺 Fw:主题/引用块/原附件转载 EML 构建、签名/模板/lint/定时保真 |
|
||||
|
||||
### wiki → wiki(1)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+node-get` | read | dws 无 get_node 对应 tool(proxy wiki doc read 读的是文档正文而非节点元数据/space解析);缺 token/obj_token/URL→node 解析、obj_type推断、space交叉校验——是值得补的智能 shortcut 缺口 |
|
||||
|
||||
### minutes → minutes(4)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+search` | read | dws list_by_keyword_and_time_range 只按 keyword+时间+归属(created/shared)过滤,缺 owner/participant 的 me 解析与筛选、缺 query 长度与跨字段互斥校验、缺输出投影与去头像 |
|
||||
| `+download` | read | dws 只有 query_minutes_audio_url 返回 OSS 地址(相当于 --url-only 单条),缺真正落盘下载、批量 fanout+限速+去重、文件名推断、SSRF 防护与覆盖保护 |
|
||||
| `+word-replace` ✅ | write | **已建 smart `+replace-batch`**:多组 `原文=>替换` 批量替换 + 去重校验 + 逐组结果聚合(补齐 1:1 `+word-replace` 的单组限制)。剩余未做:@file/stdin 输入 |
|
||||
| `+detail` ✅ | read | **已建 smart `+detail`**:单命令按 `--artifacts` fanout basic/summary/keywords/transcript/todos + partial-failure 容错 + rt.Output 投影。剩余未做:wait-ready 轮询、transcript 落盘 |
|
||||
|
||||
### base → aitable(10)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+title-resolve` ✅ | read | **已建 smart `aitable +resolve-base`**:search_bases 按名解析 baseId + 0/1/多候选消歧投影。剩余未做:Drive doc_wiki 全文搜索 |
|
||||
| `+field-create` | write | dws create_fields 支持批量,但缺 formula/lookup guide-ack 门禁与逐字段节流,可补智能 shortcut |
|
||||
| `+field-update` | write | dws update_field 缺 formula/lookup guide-ack 保护 |
|
||||
| `+record-share-link-create` ✅ | read | **已建 smart `+record-share-links`**:>20 条记录去重 + 分片(≤20/批) + 跨 aitable-helper server fanout + 合并 {recordId,shareUrl},补齐单批 20 条上限 |
|
||||
| `+record-upload-attachment` | write | dws 只有 prepare_attachment_upload(拿上传凭证),缺 分片上传编排+append_attachments 回填单元格的完整链路 |
|
||||
| `+dashboard-block-list` | read | dws 仪表盘块是 chart(create/get/update/delete_chart),缺通用 block list,可对齐补 |
|
||||
| `+dashboard-block-get` | read | dws get_chart 覆盖 chart 类块,缺通用 block get |
|
||||
| `+dashboard-block-create` | write | dws create_chart 覆盖图表块,缺其他 block 类型的通用创建 |
|
||||
| `+dashboard-block-update` | write | dws update_chart 覆盖图表块更新 |
|
||||
| `+dashboard-block-delete` | high-risk-write | dws delete_chart 覆盖图表块删除 |
|
||||
|
||||
### sheets → sheet(14)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+sheet-hide` | write | dws update_sheet可能含hidden属性但未见独立hide命令,需确认 |
|
||||
| `+sheet-unhide` | write | 同上,dws无独立unhide命令 |
|
||||
| `+sheet-set-tab-color` | write | dws update_sheet或可设tab色但无独立命令 |
|
||||
| `+sheet-show-gridline` | write | dws无网格线显隐命令 |
|
||||
| `+sheet-hide-gridline` | write | dws无网格线显隐命令 |
|
||||
| `+workbook-create` | write | dws有create_workspace_sheet但仅建空表,缺typed一步建表+填充+样式+partial回滚编排 |
|
||||
| `+dim-hide` | write | dws update-dimension或含hidden但无独立hide命令 |
|
||||
| `+dim-unhide` | write | 同上,dws无独立unhide命令 |
|
||||
| `+dim-freeze` | write | dws update-dimension可能含frozen但无独立freeze命令 |
|
||||
| `+cells-get` | read | dws range read存在但缺include样式/公式投影统一封装 |
|
||||
| `+table-get` | read | dws缺typed table读回+列类型推断+多sheet编排,只有裸csv/range读 |
|
||||
| `+table-put` | write | dws有append/set_cell_range但缺typed多sheet分块写+建缺失sheet+样式+partial回滚编排 |
|
||||
| `+rows-resize` | write | dws update-dimension可调尺寸但无独立rows-resize+size/type互斥校验 |
|
||||
| `+cols-resize` | write | dws update-dimension可调尺寸但无独立cols-resize+互斥校验 |
|
||||
|
||||
### apps → devapp(3)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+release-create` | write | dws 有 create_dev_app_version(开放平台版本)可类比,但妙搭 release 是低代码应用发布、语义与产物不同 |
|
||||
| `+release-get` | read | dws 有 get_dev_app_version_detail 可类比但产品域(开放平台vs妙搭)不同 |
|
||||
| `+release-list` | read | dws 有 list_dev_app_versions 可类比但无 status 枚举过滤且产品域不同 |
|
||||
|
||||
## 已建智能 shortcut(covered-smart,48)— 可继续升级保真度
|
||||
|
||||
- **im**: +chat-members-list +chat-list +messages-send +threads-messages-list
|
||||
- **task**: +complete +assign +get-my-tasks +get-related-tasks
|
||||
- **contact**: +search-user
|
||||
- **calendar**: +agenda +create +update +freebusy +suggestion
|
||||
- **doc (docs)**: +history-revert
|
||||
- **drive**: +upload +search +inspect
|
||||
- **mail**: +triage
|
||||
- **minutes**: +upload +latest-minutes +action-items +transcript +minutes-search +detail +replace-batch
|
||||
- **base**: +table-get +table-create +view-create +view-get-filter +view-set-filter +view-get-visible-fields +view-set-visible-fields +view-get-group +view-set-group +view-get-sort +view-set-sort +view-get-timebar +view-set-timebar +view-get-card +view-set-card +record-list +record-search +record-get +record-upsert +base-create +workflow-list +form-create +form-list +form-get +record-share-link-create
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"generated_at": "2026-08-06T21:08:26.697858",
|
||||
"count": 357,
|
||||
"generated_at": "2026-08-12T00:10:44.511794",
|
||||
"count": 399,
|
||||
"results": [
|
||||
{
|
||||
"suite": "semantic",
|
||||
@@ -2162,7 +2162,7 @@
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "接受消息列表直接返回的 threadId(兼容 topicId),拉取回复并输出稳定身份、引用、reaction、resourceRefs、可读正文和时间边界分页;可选对回复资源去重后安全落盘并返回逐项失败 ledger。",
|
||||
"semantic_delta": "接受话题主消息 ID 并通过只读消息详情自动解析 conversation/thread,也接受显式 group + threadId(兼容 topicId);拉取回复并输出稳定身份、引用、reaction、resourceRefs、可读正文,使用下层毫秒级 nextCursor 安全分页以避免同秒回复漏读,并支持全量结果 asc/desc;可选对回复资源去重后安全落盘并返回逐项失败 ledger。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
@@ -2650,8 +2650,8 @@
|
||||
"command": "+history-list",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一历史版本分页参数并返回可用于回滚的版本列表。",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "保留既有历史列表路径及稳定 Schema identity;新的 Agent 场景统一使用 +version-list。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
@@ -2660,8 +2660,8 @@
|
||||
"command": "+history-revert",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "先验证目标版本存在,再执行回滚并读回当前文档状态。",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "保留既有历史回滚路径及稳定 Schema identity;新的 Agent 场景统一使用 +version-revert。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
@@ -2670,8 +2670,8 @@
|
||||
"command": "+history-save",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "以文档历史语义命名手动版本快照,避免暴露底层 RPC 命名。",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "保留既有历史快照路径及稳定 Schema identity;新的 Agent 场景统一使用 +version-save。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
@@ -2841,7 +2841,7 @@
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按名称检索模板并返回可继续创建的 templateId。",
|
||||
"semantic_delta": "按名称或关键词检索模板并返回可消歧候选和 templateId。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
@@ -2860,8 +2860,8 @@
|
||||
"command": "+version-list",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "保留历史版本列表命令及其稳定 Schema identity;新场景优先使用 +history-list。",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "版本浏览的 Agent 主入口;统一分页参数并返回可用于回滚的版本号。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
@@ -2870,8 +2870,8 @@
|
||||
"command": "+version-revert",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "保留历史版本回滚命令及其稳定 Schema identity;新场景优先使用 +history-revert。",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "版本回滚的 Agent 主入口;先验证目标版本存在,再回滚并读回当前状态。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
@@ -2880,58 +2880,289 @@
|
||||
"command": "+version-save",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "保留历史版本快照命令及其稳定 Schema identity;新场景优先使用 +history-save。",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "版本快照的 Agent 主入口;只保存当前快照,不隐式修改正文。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "drive",
|
||||
"command": "+copy",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "复制前预检在线对象类型;普通钉盘文件因下层只会生成 .dlink 而显式拒绝,避免把快捷方式伪装成独立副本。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "drive",
|
||||
"command": "+cover",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "读取节点封面或缩略图地址;明确不声称服务端多格式预览转换。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "drive",
|
||||
"command": "+create-folder",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "创建普通钉盘文件夹后要求 fileId,并读回名称验证。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "drive",
|
||||
"command": "+create-shortcut",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "明确 shortcut 与 copy 语义差异,创建后读取新节点验证。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "drive",
|
||||
"command": "+delete",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "将已确认节点移入回收站,要求高风险确认和 success=true 终态证据。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "drive",
|
||||
"command": "+download",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "不再只返回临时链接;使用受控相对路径、no-clobber、原子发布并验证非零本地字节。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "drive",
|
||||
"command": "+find-file",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "保留历史文件定位入口;新的 Agent 文件搜索统一使用 +search。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "drive",
|
||||
"command": "+info",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "保留历史元数据入口;新的 Agent 场景统一使用可扩展的 +inspect。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "drive",
|
||||
"command": "+inspect",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "以文件元数据为必达结果,按需聚合统计、公开状态和封面;可选读取失败显式报告 partial_success。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "drive",
|
||||
"command": "+list",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格区分显式空目录与缺失/畸形响应,稳定投影节点并完整保留分页游标。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "drive",
|
||||
"command": "+move",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "移动后原位置不保留,统一 folder/workspace 目标语义并发布静态确认。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "drive",
|
||||
"command": "+publish-get",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "只读查询互联网公开状态和权限,不沿用原子命令错误的写风险标签。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "drive",
|
||||
"command": "+publish-unset",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "高风险确认后关闭互联网公开,并读回状态验证外链已失效。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "drive",
|
||||
"command": "+recent",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格读取最近访问/编辑列表并保留 nextCursor/hasMore,防止嵌套响应被投影为空。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "drive",
|
||||
"command": "+recycle-list",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格分页列出回收项并稳定投影 recycleItemId,显式空数组才是空回收站。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "drive",
|
||||
"command": "+recycle-restore",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "只要求列表可获得的 recycleItemId;恢复响应必须给出节点 ID,随后读回验证。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "drive",
|
||||
"command": "+rename",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "重命名后读取真实节点元数据验证最终名称。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "drive",
|
||||
"command": "+search",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一关键词、文件类型、扩展名、创建者、时间和分页过滤,并拒绝缺失结果数组。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "drive",
|
||||
"command": "+search-docs",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "保留历史跨域文档搜索入口;新的在线文档搜索统一使用 doc +search。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "drive",
|
||||
"command": "+star-add",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "以幂等用户偏好语义收藏指定节点。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "drive",
|
||||
"command": "+star-list",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格分页列出当前用户收藏并保留游标。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "drive",
|
||||
"command": "+star-remove",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "以幂等用户偏好语义取消收藏指定节点。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "drive",
|
||||
"command": "+stats",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "读取节点访问、编辑、评论、点赞、预览和下载统计的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "drive",
|
||||
"command": "+upload",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "组合工作目录边界校验、上传凭证、OSS PUT、严格提交响应和远端元数据读回。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "drive",
|
||||
"command": "+version-download",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "预检版本存在后安全下载历史字节,受控相对路径原子发布且要求非零产物。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "drive",
|
||||
"command": "+version-get",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按正整数版本号精确匹配元数据;零命中显式失败。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "drive",
|
||||
"command": "+version-history",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格分页列出普通文件版本,区分合法空历史与响应契约错误。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "drive",
|
||||
"command": "+version-revert",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "先验证目标版本存在,再经高风险确认回滚并读取当前节点状态。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
@@ -3004,46 +3235,274 @@
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+action-items",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "Resolves the latest task through strict itemList parsing and retrieves extracted Minutes actions without pretending to write Todo objects.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+apply-permission",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "Maps view/download/edit intent to policy 4/3/2 and validates the permission-request response.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+detail",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "Fans out selected artifacts, fully paginates transcript data, validates artifact-specific shapes and returns non-zero on partial reads.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+download",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "Resolves real audio/video URLs, validates response shape and performs batch safe atomic local downloads with an explicit failure ledger.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+export-pack",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "Validates selected artifacts, safely publishes a no-clobber local directory and emits a manifest without signed URLs or credentials.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+latest",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "Validates the real itemList response and chooses latest only from an explicit comparable timestamp before reading details; +latest-minutes remains a compatibility alias.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+list-all",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "Stable projection of all accessible Minutes with strict itemList response validation.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+list-mine",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "Stable projection of current-user Minutes with strict itemList response validation.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+list-shared",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "Stable projection of shared Minutes with strict itemList response validation.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+mindmap",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "Creates the asynchronous mind-graph task exactly once and polls the explicit 0/1/2 taskStatus to success, failure or timeout.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+prepare-asr",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "Reads the real personal hot-word set, computes deterministic add/delete differences, defaults to additive changes and verifies final state.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+record-pause",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "Friendly recording-pause facade with explicit task UUID validation and confirmation.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+record-resume",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "Friendly recording-resume facade with explicit task UUID validation and confirmation.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+record-start",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "Friendly recording-create facade with explicit confirmation and stable session parameter.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+record-stop",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "Friendly recording-stop facade with explicit task UUID validation and confirmation.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+record-wrap-up",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "Stops recording once, then boundedly waits for selected validated artifacts and preserves taskUuid recovery on partial completion.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+replace-batch",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "Validates multi-rule input, supports JSON/file/stdin and reports partial writes with stop/continue policy and non-zero failure.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+search",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "Validated itemList parsing, deterministic title filtering, bounded cursor pagination, de-duplication and completeness ledger; replaces the deprecated +minutes-search discovery route.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+share",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "Grants semantic view/download/edit permissions per stable member UID with stop/continue partial-write ledgers and explicit acknowledgements.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+speaker-insights",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "Requires a real async taskId, boundedly polls speaker summaries and returns task recovery handles when content is not ready.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+speaker-replace",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "Fully paginates the transcript to preflight source speaker presence and verifies the nickname replacement after writing.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+summary",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "Reads current summary, supports literal/file/stdin, preserves Markdown images, previews the change and verifies full read-back.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+transcript",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "Resolves an explicit or latest task, follows every transcript cursor, de-duplicates paragraphs and publishes completeness.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+unshare",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "Removes permission per stable member UID with stop/continue partial-write ledgers and explicit acknowledgements.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "Reads the current title, previews the diff, avoids no-op writes and verifies the final title by read-back.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+upload",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "Owns local file validation, create-PUT-complete polling, pre-complete transfer cancellation compensation, unknown-completion recovery and final task read-back verification.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "minutes",
|
||||
"command": "+upload-and-analyze",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "Completes local upload with compensation, waits for validated artifacts, and optionally orchestrates mind map and speaker insights without re-upload recovery hazards.",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
> 本分支权威合同:`skills/mono` / `skills/multi` 的**内容组织**与 zip 内容树形状。
|
||||
> 不做安装/升级行为约定。质检见 [skill-mono-multi-qa.md](skill-mono-multi-qa.md)。
|
||||
> 对齐调研:[skill-wukong-align-plan.md](skill-wukong-align-plan.md)。
|
||||
> 安装、升级与模式迁移见
|
||||
> [DWS 预制 Skill 安装、升级与模式迁移 RFC](rfc-skill-installation-and-upgrade.md)。
|
||||
|
||||
## 1. 两棵内容树
|
||||
|
||||
@@ -87,18 +88,7 @@ skills/mono/
|
||||
|
||||
质检可断言源树形状;**不**断言安装器默认解压哪棵。
|
||||
|
||||
## 6. 与悟空 `dingtalk-skills/` 对照(组织概念 only)
|
||||
|
||||
| 维度 | DWS `skills/multi` | 悟空 `dingtalk-skills/`(develop) |
|
||||
|---|---|---|
|
||||
| 布局 | flat `dingtalk-*` + `dingtalk-shared` | 同构 flat |
|
||||
| 集合 | 产品 skill + shared(含 event/profile/…;dev/skill 等长尾落在 misc) | 更小产品集(如 attendance/report 独立目录) |
|
||||
| 质检权威 | **mono 单 skill 树** | 不作为 DWS 覆盖基准 |
|
||||
| 不移植 | `_install.sh` / bundle / dual / Qwen overlay | — |
|
||||
|
||||
悟空独有命名(如 `dingtalk-attendance`)在 DWS 中由 `dingtalk-misc` 承接对应 mono `attendance*` / `report` / `oa` / `sheet` / `dev` 等面——见覆盖表。
|
||||
|
||||
## 7. 变更流程
|
||||
## 6. 变更流程
|
||||
|
||||
1. 改 / 增内容 → 更新 `skills/content-qa/mono-multi-coverage.yaml`(coverage 或 omit)
|
||||
2. 跑 `make skill-mono-multi-content`(该独立门禁不包含在默认 `make policy` 中)
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
> 对照基准:`skills/mono`(单 skill)。被测主体:`skills/multi`。
|
||||
> 机读合同:`skills/content-qa/mono-multi-coverage.yaml`。
|
||||
> 执行:`make skill-mono-multi-content`(独立门禁;默认 `make policy` 按设计不包含该检查)。
|
||||
> 安装、升级与模式迁移见
|
||||
> [DWS 预制 Skill 安装、升级与模式迁移 RFC](rfc-skill-installation-and-upgrade.md)。
|
||||
|
||||
## 1. 质检矩阵
|
||||
|
||||
@@ -70,7 +72,3 @@ paired_files:
|
||||
| X6 | SAFETY_PREAMBLE_INJECT 无注入器 | **done** | 标记已移除 |
|
||||
|
||||
产品面覆盖:见 YAML `coverage`——mono products 均有 multi 承接(misc 聚合 attendance/oa/sheet/…)。
|
||||
|
||||
## 4. 与悟空
|
||||
|
||||
借鉴 frontmatter / 断链 / requires 等**检查维度**;不运行悟空 bundle zip 校验脚本。覆盖权威始终是 DWS mono。
|
||||
|
||||
@@ -1,272 +0,0 @@
|
||||
# DWS multi-skill **内容框架**对齐方案(相对 dws-wukong develop)
|
||||
|
||||
> 状态:**执行中** — Phase 1–3 已落地;M2/M3 已补;**M1 recovery 闭环已从 skill 删除(不做移植)**。
|
||||
> 合同短文:[skill-content-framework.md](skill-content-framework.md)
|
||||
> 质检规格:[skill-mono-multi-qa.md](skill-mono-multi-qa.md)
|
||||
> 机读合同:`skills/content-qa/mono-multi-coverage.yaml`
|
||||
> 门禁:`make skill-mono-multi-content`(独立门禁;默认 `make policy` 按设计不包含该检查)
|
||||
>
|
||||
> 撰写 / 收窄 / 质检增补 / 执行:2026-08-05
|
||||
> 工作树:`/Users/john/GolandProjects/open-source/dws-multi-skill-align`
|
||||
> 分支:`feat/multi-skill-framework-align`(自 `origin/main` @ `a37e6e68`)
|
||||
> **本分支范围:只做 skill 内容的这个框架**(目录布局、文档契约、共享内容约定、zip 内容树合同、**相对 mono 的内容质检**)。
|
||||
> **不做**安装/升级引擎、agent-home、脚本 skill-install 行为翻转。
|
||||
>
|
||||
> 对照仓:
|
||||
>
|
||||
> | 仓 | 路径 | 基线 |
|
||||
> |---|---|---|
|
||||
> | DWS OSS CLI(本工作树) | `dws-multi-skill-align` | `origin/main` |
|
||||
> | dws-wukong | `~/GolandProjects/open-source/dws-wukong` | `origin/develop` @ `ab76629a`(调研时) |
|
||||
> | 行为参考(**另一分支**) | `dws-skill-mode-migration` @ `402429ac`/`d5c8982c` | 安装默认 multi / upgrade 强制 multi —— **不在本分支排期** |
|
||||
> | 内容缺口留档(参考) | 同迁移分支 `docs/skill-capability-completion.md`(M1–M6 / X1 等) | **仅作质检目标线索**,非本分支权威 |
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR
|
||||
|
||||
1. **本分支 = skill 内容框架 + 相对 mono 的内容质检**:固化 `skills/multi` 组织合同,并用 **mono 单 skill 布局作对照基准**做覆盖/结构/漂移门禁(文档 + CI 内容护栏)。
|
||||
2. **对齐悟空**:只取内容树组织概念;质检以 **DWS-native** 设计为主(已有 policy/测试可复用)。悟空 `validate-multiskill-bundle.py` 仅借鉴「frontmatter / 断链 / requires」类检查思路,**不**移植 bundle/安装校验。
|
||||
3. **安装/升级行为**与 `402429ac`/`d5c8982c` → **单独 follow-up 分支**,本方案只登记。
|
||||
4. 质检 **不改**默认安装哪棵树;只保证 multi 内容相对 mono **可解释、可覆盖、可回归**。
|
||||
|
||||
### 0.1 IN SCOPE
|
||||
|
||||
| 类别 | 包含 |
|
||||
|---|---|
|
||||
| 内容树结构 | `skills/mono/` 与 `skills/multi/<name>/` 目录合同 |
|
||||
| 单 skill 约定 | `SKILL.md` frontmatter / 契约块 / Golden Route;`references/`;可选 `scripts/` |
|
||||
| 共享内容 | `dingtalk-shared` 职责与被引用方式;与 mono 全局文映射(文档级) |
|
||||
| 命名与集合 | `dingtalk-*` + `dingtalk-shared`;相对悟空的共有/独有清单(文档) |
|
||||
| Zip **内容布局合同** | `mono/` / `multi/` / 根 mono 副本的内容含义与树形状;不改安装默认 |
|
||||
| **Mono↔multi 内容质检** | 覆盖、结构、漂移三类门禁;复用/扩展现有 policy 与测试;缺口修复属内容编辑(另批或同分支内容 Phase) |
|
||||
| 内容架构文档 | 本文件 + 可选短文(架构合同 + 质检矩阵) |
|
||||
|
||||
### 0.2 OUT OF SCOPE
|
||||
|
||||
| 类别 | 去向 |
|
||||
|---|---|
|
||||
| 安装默认 multi、upgrade always-multi | Follow-up 分支(`402429ac`/`d5c8982c`) |
|
||||
| `LocateSkillsRoot` / `skill_setup` / `paths.go` / `skillhome` / install 脚本行为 | 同上 |
|
||||
| 安装/运行时 manifest、state.json、mode 切换、telemetry header | 拒绝或行为分支 |
|
||||
| 悟空 `_install.sh` / dual / Qwen / RewindDesktop / pod | 拒绝 |
|
||||
| 非 skill 内容的 CLI 功能(schema/shortcut 代码等) | 拒绝 |
|
||||
| 把质检做成「改安装默认值」的后门 | 拒绝 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 内容现状盘点
|
||||
|
||||
### 1.1 DWS `skills/mono`(质检对照基准 · 单 skill)
|
||||
|
||||
```text
|
||||
skills/mono/
|
||||
├── SKILL.md
|
||||
├── references/
|
||||
│ ├── products/<area>.md|…/ # 产品能力面(质检「覆盖」主源)
|
||||
│ ├── error-codes.md、… # 全局协议(无 recovery 闭环)
|
||||
│ └── best_practices/…
|
||||
└── scripts/
|
||||
```
|
||||
|
||||
### 1.2 DWS `skills/multi`(内容主体)
|
||||
|
||||
```text
|
||||
skills/multi/
|
||||
├── dingtalk-shared/ # 跨产品契约 / routing / 全局协议应落点
|
||||
└── dingtalk-*/ # 19 产品 + 各 references、scripts
|
||||
```
|
||||
|
||||
仅 DWS 有(悟空无):dev, event, hrbrain, markdown, pat, profile, skill。
|
||||
|
||||
### 1.3 悟空 `dingtalk-skills/`(内容组织对照,非质检权威)
|
||||
|
||||
Flat `dingtalk-*` + `dingtalk-shared`;单 skill 骨架同构。**不作为 mono 覆盖基准**(集合更小、不同源)。
|
||||
|
||||
### 1.4 Zip 内容布局合同
|
||||
|
||||
| Zip 路径 | 内容含义 |
|
||||
|---|---|
|
||||
| `<root>/` | mono 副本(兼容) |
|
||||
| `<root>/mono/` | 显式 mono 内容源 |
|
||||
| `<root>/multi/` | 与 `skills/multi/` 同构 |
|
||||
|
||||
质检可断言「源树形状」;**不**断言安装面默认选哪棵。
|
||||
|
||||
### 1.5 现有 DWS skill 内容质检资产(复用清单)
|
||||
|
||||
| 资产 | 作用 | 与 mono↔multi 质检关系 |
|
||||
|---|---|---|
|
||||
| `scripts/policy/check-skill-commands.sh` + `skill-command-check/` | Skill 文内 `dws …` 命令路径存在性 | **复用**(命令真实性);非覆盖映射 |
|
||||
| `scripts/policy/check-skill-context-budget.sh` | chat/event/mono/`dingtalk-shared` 上下文预算与冷启动约束 | **复用**(结构/预算);可扩展 shared 引用规则 |
|
||||
| `scripts/policy/check-multi-im-skill-chain.sh` + `multi-im-skill-chain/` | IM 意图单默认路由、retired scripts、handoff | **复用**(chat/event 链);面窄 |
|
||||
| `test/unit/skill_docs_policy_test.go` | 退役命令、event 扁平输出契约等 | **复用**;可加 mono↔multi 断言 |
|
||||
| `test/unit/whiteboard_skill_docs_test.go` | mono/multi whiteboard recipes **字节一致** | **样板**:产品面「同源文件」门禁范式 |
|
||||
| `test/skill_static`(`-tags skill_verify`) | 文内命令 vs Cobra;multi 查 flag | **复用**(opt-in 深度);非 CI 默认全量时可保持 tags |
|
||||
| `test/skill_e2e` / `test/run_skill_tests.py` | 执行层 / 用例驱动 | **偏行为**;本分支质检默认不依赖 e2e |
|
||||
| `Makefile` → `policy` 含 context-budget、multi-im-skill-chain;`skill-command-integrity` 独立 | 已有 CI 钩子 | 新门禁优先挂同类 policy / `test/unit` |
|
||||
|
||||
**缺口(尚无的门禁)**:系统的「mono `references/products/*` → multi 目录/文」覆盖表;frontmatter 全集完备性;orphan scripts。全局协议中 **确认门禁 / Schema 教学已补**;**recovery 闭环已从 skill 移除(不再作为缺口)**。
|
||||
|
||||
### 1.6 悟空侧类比质检
|
||||
|
||||
| 悟空 | 说明 | 本分支 |
|
||||
|---|---|---|
|
||||
| `scripts/validate-multiskill-bundle.py` | 校验 **已打好的 bundle zip**:frontmatter keys/category、`requires`、markdown 断链、scenario 编排 | **Adapt 思路** → DWS 源树(`skills/multi` + 对照 mono),不跑 zip 安装语义 |
|
||||
| `sync-monolith-to-multiskill.py` | mono→multi 派生 | **不**作默认质检手段;DWS 直接维护 multi |
|
||||
|
||||
结论:**DWS-native mono↔multi 质检**;悟空仅参考检查维度。
|
||||
|
||||
---
|
||||
|
||||
## 2. Diff(内容组织 + 质检视角)
|
||||
|
||||
### 2.1 已同构
|
||||
|
||||
Flat `dingtalk-*` + `dingtalk-shared`;`SKILL.md` + `references/`(+ 可选 `scripts/`)。
|
||||
|
||||
### 2.2 分叉与已知内容风险(质检要盯的)
|
||||
|
||||
| 风险 ID | 现象(线索) | 质检类型 |
|
||||
|---|---|---|
|
||||
| **C-cov** | mono `products/*` 能力面在 multi 无对应 skill/reference,或未登记「有意省略」 | 覆盖 |
|
||||
| **C-struct** | multi 缺 frontmatter 字段、`references/`、`DWS_RUNTIME_CONTRACT`、对 `dingtalk-shared` 引用不一致 | 结构 |
|
||||
| **C-drift-global** | 曾关注 recovery / 确认 / Schema;现确认与 Schema 已在 `dingtalk-shared`,**recovery skill 文档已删除** | 漂移(协议) |
|
||||
| **C-drift-orphan** | multi(或 mono)scripts/refs 无文档引用;或 routing 指向无索引产品(留档 X1/M6) | 漂移(孤儿) |
|
||||
| **C-pair** | 应对齐的成对文件(如 whiteboard recipes)内容不一致 | 漂移(成对) |
|
||||
|
||||
### 2.3 Reject
|
||||
|
||||
悟空安装包校验整文件照搬、内容集 19→12 砍产品、安装行为门禁冒充内容质检。
|
||||
|
||||
---
|
||||
|
||||
## 3. Goals / Non-goals
|
||||
|
||||
### 3.1 Goals
|
||||
|
||||
1. 固化 multi **内容目录合同**与 mono↔multi **映射说明**。
|
||||
2. 建立 **质检矩阵**(覆盖 / 结构 / 漂移)并以 mono 为对照基准;有意省略必须 reviewed 登记。
|
||||
3. **复用** §1.5 资产;新增门禁走 `scripts/policy` 或 `test/unit`,内容-only。
|
||||
4. (可选)纯内容元数据;**禁止**被安装引擎读取改行为。
|
||||
5. 质检失败 → 修 **内容**或更新「有意省略」表,不改 setup/upgrade。
|
||||
|
||||
### 3.2 Non-goals
|
||||
|
||||
安装/升级翻转;cherry-pick 行为提交;取消产品;悟空客户端;非 skill CLI 功能;用质检驱动默认 multi 安装。
|
||||
|
||||
---
|
||||
|
||||
## 4. 分期(内容框架 + 质检 · 均无安装引擎)
|
||||
|
||||
> 批准前 **零编码**(含不实现新 gates)。**已执行**:Phase 1–3 见文首状态。
|
||||
|
||||
### Phase 0 — 方案冻结(本文)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **范围** | 本文件;§7(含质检轨)勾选 |
|
||||
| **验收** | owner 重新批准 → ✅「现在开始执行」 |
|
||||
|
||||
### Phase 1 — Multi 内容目录合同 + 架构短文 ✅
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **范围** | `skills/multi` 目录合同;与悟空内容树对照表;zip `multi/` 同构合同 |
|
||||
| **触达** | `docs/skill-content-framework.md` |
|
||||
| **验收** | 可指导「如何新增 dingtalk-* 内容目录」 |
|
||||
|
||||
### Phase 2 — Mono↔multi **内容质检规格**(矩阵 + 缺口基线) ✅
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **范围** | 质检规格 + 覆盖/omit 机读表 + 缺口 disposition |
|
||||
| **触达** | `docs/skill-mono-multi-qa.md`、`skills/content-qa/mono-multi-coverage.yaml` |
|
||||
| **验收** | 矩阵可人工抽查;缺口均有 disposition |
|
||||
|
||||
### Phase 3 — 质检落地:CI 内容护栏(复用 + 新 gate) ✅
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **范围** | G1–G4 自动门禁 |
|
||||
| **触达** | `test/unit/mono_multi_skill_content_test.go`、`scripts/policy/check-mono-multi-skill-content.sh`、`Makefile` |
|
||||
| **验收** | `make skill-mono-multi-content` 绿;已知缺口走 reviewed omit |
|
||||
|
||||
### Phase 4 — 可选:内容包元数据 + 缺口修复波次
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **范围 A** | 纯内容 layout/skill 列表元数据(人不读安装器) |
|
||||
| **范围 B** | 按 Phase 2 disposition **修内容**:确认 / Schema 已补;**recovery skill 文档已删除(wontfix 移植)**;orphan 脚本仍走 allowlist(M4 等) |
|
||||
| **验收** | 元数据不驱动安装;修复项关闭对应质检失败或转入 omit |
|
||||
|
||||
### 延期登记(非本分支)
|
||||
|
||||
| 主题 | 载体 |
|
||||
|---|---|
|
||||
| 默认 multi + upgrade always-multi | 行为分支 ← `402429ac`/`d5c8982c` |
|
||||
| skillhome / 安装面 bootstrap | 行为分支 |
|
||||
|
||||
---
|
||||
|
||||
## 5. Port / Adapt / Reject
|
||||
|
||||
| 项 | 决策 | 说明 |
|
||||
|---|---|---|
|
||||
| flat + `dingtalk-shared` 内容模型 | **Port** | 已有;合同 + 质检加固 |
|
||||
| 悟空 bundle frontmatter/断链/requires 检查维度 | **Adapt** | 做成 DWS 源树门禁,不校验 bundle zip/安装 |
|
||||
| whiteboard 式 mono/multi 成对一致 | **Port(范式)** | 推广到 reviewed 文件对 |
|
||||
| `validate-multiskill-bundle.py` 整脚本 | **Reject** | 绑定悟空 zip/Qwen 语义 |
|
||||
| `_install.sh` / dual / overlay | **Reject** | 非内容 |
|
||||
| 行为 cherry-pick | **Defer** | 另分支 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 与 `402429ac` / `d5c8982c`
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 本分支 cherry-pick? | **否** |
|
||||
| 质检是否替代行为翻转? | **否** |
|
||||
| 行为分支 | 另开;可与内容/质检并行 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 批准清单(请重新勾选)
|
||||
|
||||
**范围**
|
||||
|
||||
- [x] 本分支 = skill **内容**框架 + **mono↔multi 内容质检**(§0.1);无安装/升级引擎
|
||||
- [x] `402429ac`/`d5c8982c` 及 setup/paths/install 脚本行为 **不在本分支**
|
||||
- [x] 取消产品与悟空客户端链路仍拒绝
|
||||
|
||||
**内容框架 Phase**
|
||||
|
||||
- [x] **Phase 1**:multi 目录合同 + 悟空内容树对照短文
|
||||
|
||||
**质检轨 Phase**
|
||||
|
||||
- [x] **Phase 2**:质检矩阵 + mono↔multi 覆盖/缺口基线规格(先文档,可执行)
|
||||
- [x] **Phase 3**:CI 内容护栏(G1–G4)—— 本迭代做 / 拆 PR / 只要规格暂不落地
|
||||
- [x] 质检失败处置原则:修内容或 reviewed omit,**不**改安装默认
|
||||
|
||||
**可选**
|
||||
|
||||
- [ ] **Phase 4A** 纯内容元数据:做 / 不做 / 以后
|
||||
- [x] **Phase 4B** recovery skill 文档 **removed/wontfix**;确认/Schema 已补;剩余 orphan(M4 等)仍 defer / allowlist
|
||||
|
||||
**Follow-up 知悉**
|
||||
|
||||
- [ ] 安装默认 multi + upgrade always-multi → **另一分支**
|
||||
|
||||
---
|
||||
|
||||
## 8. 下一步
|
||||
|
||||
**Phase 1–3 已落地**(合同短文 + 质检规格 + `skills/content-qa` + CI 门禁)。
|
||||
Phase 4B:recovery 已删除(不做移植);确认/Schema 已补。剩余 defer:orphan scripts(M4 等)、LICENSE/NOTICE(M5)、Phase 4A 元数据。
|
||||
安装默认 multi 等行为仍走 **另一分支**。
|
||||
|
||||
---
|
||||
|
||||
*锚点:`skills/mono`、`skills/multi`、§1.5 policy/测试、wukong `dingtalk-skills/`(组织对照 only)。*
|
||||
@@ -14,7 +14,7 @@ require (
|
||||
github.com/itchyny/gojq v0.12.18
|
||||
github.com/mattn/go-isatty v0.0.20
|
||||
github.com/muesli/termenv v0.16.0
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-0.20260705041131-325e7c1049ad
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-beta.1
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/zalando/go-keyring v0.2.8
|
||||
golang.org/x/crypto v0.49.0
|
||||
|
||||
@@ -88,8 +88,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-0.20260705041131-325e7c1049ad h1:Bb4I+suYd+ehQ8e22aimLLze+5XTN3+WTc/x2LafmH8=
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-0.20260705041131-325e7c1049ad/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-beta.1 h1:5WwR5TV6A12taXMH7SggT8yCMMJMF9jWE7Wj+4AuHck=
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-beta.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
|
||||
@@ -33,6 +33,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/safety"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
upgradepkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
@@ -2033,6 +2034,10 @@ func TestCrossPlatformCoverageSkillSetupRuntimeCoverage(t *testing.T) {
|
||||
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dingtalk-shared", "SKILL.md")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state, readable, err := skillstate.Read(home)
|
||||
if err != nil || !readable || len(state.OfficialSkills) != 3 || len(state.UpdatedSkills) != 2 {
|
||||
t.Fatalf("setup state = %#v, readable=%v, err=%v", state, readable, err)
|
||||
}
|
||||
if output, _, err := run("--mode", "multi", "--source", multi, "--target", "agents", "--yes", "--dry-run", "--exclude", "b"); err != nil || !strings.Contains(output, "DRY-RUN") {
|
||||
t.Fatalf("multi dry run = %q, %v", output, err)
|
||||
}
|
||||
@@ -2048,8 +2053,11 @@ func TestCrossPlatformCoverageSkillSetupRuntimeCoverage(t *testing.T) {
|
||||
t.Fatalf("invalid setup %#v succeeded", args)
|
||||
}
|
||||
}
|
||||
if _, _, err := run("--source", mono, "--target", "agents", "--yes", "--dry-run"); err != nil {
|
||||
t.Fatalf("default mono setup: %v", err)
|
||||
if _, _, err := run("--mode", "mono", "--source", mono, "--target", "agents", "--yes", "--dry-run"); err != nil {
|
||||
t.Fatalf("mono setup: %v", err)
|
||||
}
|
||||
if output, _, err := run("--source", multi, "--target", "agents", "--yes", "--dry-run"); err != nil || !strings.Contains(output, "mode=multi") {
|
||||
t.Fatalf("default mode should be multi: %q, %v", output, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2082,7 +2090,7 @@ func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
|
||||
if _, err := listMultiSkillNames(filepath.Join(t.TempDir(), "missing")); err == nil {
|
||||
t.Fatal("missing multi source succeeded")
|
||||
}
|
||||
if mode, err := resolveSkillSetupMode("", true, io.Discard); err != nil || mode != skillSetupModeMono {
|
||||
if mode, err := resolveSkillSetupMode("", true, io.Discard); err != nil || mode != skillSetupModeMulti {
|
||||
t.Fatalf("default setup mode = %q, %v", mode, err)
|
||||
}
|
||||
if _, err := resolveSkillSetupMode("bad", true, io.Discard); err == nil {
|
||||
@@ -2117,7 +2125,7 @@ func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
|
||||
for _, tc := range []struct{ path, mode string }{{"", skillSetupModeMono}, {mono, skillSetupModeMono}, {filepath.Dir(multi), skillSetupModeMulti}, {root, "bad"}} {
|
||||
_ = isSkillSourceRoot(tc.path, tc.mode)
|
||||
}
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
setTestHome(t, t.TempDir())
|
||||
for _, tc := range []struct{ target, mode string }{{"agents", skillSetupModeMono}, {"agents", skillSetupModeMulti}, {"all", skillSetupModeMono}, {"missing", skillSetupModeMono}} {
|
||||
_, _ = resolveSkillSetupTargets(tc.target, tc.mode)
|
||||
}
|
||||
@@ -2125,8 +2133,8 @@ func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
|
||||
_ = agentHomeForMode("base", skillSetupModeMulti)
|
||||
_ = detectExistingAgentHomes(t.TempDir(), skillSetupModeMono)
|
||||
for _, mode := range []string{skillSetupModeMono, skillSetupModeMulti, "bad"} {
|
||||
_, _ = confirmSkillSetup(io.Discard, mode, root, []string{root}, all)
|
||||
_ = mutualExclusionVictims(root, mode)
|
||||
_, _ = confirmSkillSetup(io.Discard, mode, root, []string{root}, all, false)
|
||||
_, _ = mutualExclusionVictims(root, mode)
|
||||
}
|
||||
if isCharDevice(nil) || isInteractiveTerminal() {
|
||||
t.Fatal("test process unexpectedly interactive")
|
||||
@@ -2134,17 +2142,17 @@ func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
|
||||
|
||||
monoDest := filepath.Join(t.TempDir(), "agent", "dws")
|
||||
_ = os.MkdirAll(filepath.Join(filepath.Dir(monoDest), "dingtalk-old"), 0o755)
|
||||
_ = mutualExclusionVictims(monoDest, skillSetupModeMono)
|
||||
_, _ = mutualExclusionVictims(monoDest, skillSetupModeMono)
|
||||
multiDest := filepath.Join(t.TempDir(), "agent")
|
||||
_ = os.MkdirAll(filepath.Join(multiDest, "dws"), 0o755)
|
||||
_ = mutualExclusionVictims(multiDest, skillSetupModeMulti)
|
||||
_, _ = mutualExclusionVictims(multiDest, skillSetupModeMulti)
|
||||
cleanupMutualExclusion(monoDest, skillSetupModeMono, io.Discard, io.Discard)
|
||||
cleanupMutualExclusion(multiDest, skillSetupModeMulti, io.Discard, io.Discard)
|
||||
|
||||
badParent := filepath.Join(t.TempDir(), "file")
|
||||
_ = os.WriteFile(badParent, []byte("x"), 0o600)
|
||||
_, _, _ = installSkillToHomes(root, []string{filepath.Join(badParent, "dest")}, io.Discard, io.Discard)
|
||||
_, _, _ = installMultiSkillToHomes(root, []string{"missing"}, []string{filepath.Join(badParent, "dest")}, io.Discard, io.Discard)
|
||||
_, _, _ = installMultiSkillToHomes(root, []string{"missing"}, []string{filepath.Join(badParent, "dest")}, io.Discard, io.Discard, true)
|
||||
if err := copyDir(filepath.Join(root, "missing"), t.TempDir()); err == nil {
|
||||
t.Fatal("copy missing directory succeeded")
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ func TestP1SharedAlwaysIncludedWithSkillFilter(t *testing.T) {
|
||||
// Actually install with the filtered+mandatory set and assert dingtalk-shared landed.
|
||||
dest := t.TempDir()
|
||||
var out, errOut bytes.Buffer
|
||||
if _, _, err := installMultiSkillToHomes(src, final, []string{dest}, &out, &errOut); err != nil {
|
||||
if _, _, err := installMultiSkillToHomes(src, final, []string{dest}, &out, &errOut, true); err != nil {
|
||||
t.Fatalf("install: %v (%s)", err, errOut.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dest, "dingtalk-shared", "SKILL.md")); err != nil {
|
||||
|
||||
@@ -79,13 +79,15 @@ func TestCrossPlatformCoveragePATRetryRemainingPureAndWaitCoverage(t *testing.T)
|
||||
if ok, err := WaitForPatAuthorization(context.Background(), "", &out); err != nil || ok {
|
||||
t.Fatalf("timed out authorization = %v, %v", ok, err)
|
||||
}
|
||||
patAuthorizationTimeout = 5 * time.Millisecond
|
||||
patAuthorizationTimeout = time.Second
|
||||
patAuthorizationPollInterval = time.Millisecond
|
||||
pollCtx, pollCancel := context.WithCancel(context.Background())
|
||||
patResolveAccessToken = func(context.Context, string, string) (string, error) {
|
||||
pollCancel()
|
||||
return "", authpkg.ErrTokenDataNotFound
|
||||
}
|
||||
out.Reset()
|
||||
if ok, err := WaitForPatAuthorization(context.Background(), "", &out); err != nil || ok || !strings.Contains(out.String(), "等待授权中") {
|
||||
if ok, err := WaitForPatAuthorization(pollCtx, "", &out); ok || !errors.Is(err, context.Canceled) || !strings.Contains(out.String(), "等待授权中") {
|
||||
t.Fatalf("invalid-token polling = %v, %v, output %q", ok, err, out.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -681,6 +681,11 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
|
||||
if _, err := parseAgentProduct(os.Getenv(agentproduct.EnvName)); err != nil {
|
||||
return err
|
||||
}
|
||||
if shouldDetectNestedSkillLayout(cmd) {
|
||||
if found, err := detectNestedMultiSkillLayout(); err == nil && found {
|
||||
fmt.Fprintln(cmd.ErrOrStderr(), "⚠️ 检测到旧升级器留下的嵌套 Skill;请运行 dws skill setup --mode multi 查看迁移计划并确认")
|
||||
}
|
||||
}
|
||||
|
||||
authpkg.SetRuntimeProfile(flags.Profile)
|
||||
// Apply OAuth credential overrides from CLI flags (highest priority).
|
||||
|
||||
@@ -16,12 +16,12 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
publicShortcutCount = 357
|
||||
publicShortcutCount = 399
|
||||
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
|
||||
// including hidden leaves such as minutes.shortcut_minutes_search.
|
||||
schemaPublishedShortcutCount = 358
|
||||
// including the hidden historical minutes.shortcut_minutes_search contract.
|
||||
schemaPublishedShortcutCount = 401
|
||||
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
|
||||
publiclyDeliveredShortcutCount = 357
|
||||
publiclyDeliveredShortcutCount = 399
|
||||
)
|
||||
|
||||
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
|
||||
|
||||
@@ -126,6 +126,7 @@ var agentSkillPaths = map[string]string{
|
||||
"claude": ".claude/skills",
|
||||
"cursor": ".cursor/skills",
|
||||
"codex": ".codex/skills",
|
||||
"zcode": ".zcode/skills",
|
||||
"opencode": filepath.Join(".config", "opencode", "skills"),
|
||||
// IDE / agent registries also probed by `dws skill setup --target all`.
|
||||
"gemini": ".gemini/skills",
|
||||
|
||||
@@ -452,7 +452,7 @@ func TestSupportedTargets(t *testing.T) {
|
||||
// Should contain all predefined targets — including the agents/* sentinel
|
||||
// and the IDE/agent registries we share with skillSetupAgentHomes.
|
||||
expectedTargets := []string{
|
||||
"agents", "claude", "cursor", "codex", "opencode", "qoder",
|
||||
"agents", "claude", "cursor", "codex", "zcode", "opencode", "qoder",
|
||||
"gemini", "github", "windsurf", "augment", "cline",
|
||||
"amp", "kiro", "trae", "openclaw", "hermes",
|
||||
".",
|
||||
|
||||
+823
-169
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,707 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillprovenance"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func useManagedSkillNames(t *testing.T, names ...string) {
|
||||
t.Helper()
|
||||
records := make([]skillprovenance.Record, 0, len(names))
|
||||
for _, name := range names {
|
||||
records = append(records, skillprovenance.Record{Name: name})
|
||||
}
|
||||
testseam.Swap(t, &skillSetupReadState, func(string) (*skillstate.State, bool, error) {
|
||||
return &skillstate.State{ManagedSkills: records}, true, nil
|
||||
})
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageSkillSetupConfirmPreviewsStaleSkills verifies the
|
||||
// confirmation prompt lists stale dingtalk-* / dws-shared directories that a
|
||||
// full (unfiltered) multi install will back up and remove, and that a
|
||||
// filtered install previews nothing extra.
|
||||
func TestCrossPlatformCoverageSkillSetupConfirmPreviewsStaleSkills(t *testing.T) {
|
||||
testseam.Swap(t, &skillSetupInteractive, func() bool { return false })
|
||||
|
||||
dest := filepath.Join(t.TempDir(), ".claude", "skills")
|
||||
stale := filepath.Join(dest, "dingtalk-old")
|
||||
if err := os.MkdirAll(stale, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(stale, "SKILL.md"), []byte("stale"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
useManagedSkillNames(t, "dingtalk-old")
|
||||
|
||||
var out bytes.Buffer
|
||||
ok, err := confirmSkillSetup(&out, skillSetupModeMulti, "src", []string{dest}, []string{"dingtalk-chat"}, false)
|
||||
if err == nil || ok || !strings.Contains(err.Error(), "--yes") {
|
||||
t.Fatalf("confirmSkillSetup = (%v, %v), want non-interactive confirmation error", ok, err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "将备份并移除过期 skill") {
|
||||
t.Fatalf("full install preview must list stale skills, got %q", out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), filepath.Join(dest, "dingtalk-old")) {
|
||||
t.Fatalf("preview must name the stale directory, got %q", out.String())
|
||||
}
|
||||
|
||||
out.Reset()
|
||||
ok, err = confirmSkillSetup(&out, skillSetupModeMulti, "src", []string{dest}, []string{"dingtalk-chat"}, true)
|
||||
if err == nil || ok {
|
||||
t.Fatalf("filtered confirmSkillSetup = (%v, %v), want non-interactive confirmation error", ok, err)
|
||||
}
|
||||
if strings.Contains(out.String(), "将备份并移除过期 skill") {
|
||||
t.Fatalf("filtered install must stay additive in the preview, got %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupUnifiedOwnership(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "dingtalk-custom")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if isManagedDWSMultiSkillDir(dir) {
|
||||
t.Fatal("an unregistered dingtalk-* directory must not be treated as DWS-owned")
|
||||
}
|
||||
managed := map[string]bool{"dingtalk-custom": true}
|
||||
if !isManagedDWSMultiSkillDir(dir, managed) {
|
||||
t.Fatal("unified metadata must prove ownership")
|
||||
}
|
||||
legacy := filepath.Join(t.TempDir(), legacySharedSkill)
|
||||
if !isManagedDWSMultiSkillDir(legacy) {
|
||||
t.Fatal("the exact legacy dws-shared name must remain managed")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePublishManagedSkillFailurePaths(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{"dingtalk-a"})
|
||||
skillSrc := filepath.Join(src, "dingtalk-a")
|
||||
failure := errors.New("publish denied")
|
||||
|
||||
t.Run("mkdir", func(t *testing.T) {
|
||||
testseam.Swap(t, &skillSetupPublishTemp, func(string, string) (string, error) { return "", failure })
|
||||
err := publishDWSManagedSkillDir(skillSrc, filepath.Join(t.TempDir(), "dingtalk-a"))
|
||||
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "staging") {
|
||||
t.Fatalf("mkdir error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("copy", func(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error { return failure })
|
||||
err := publishDWSManagedSkillDir(skillSrc, filepath.Join(parent, "dingtalk-a"))
|
||||
if !errors.Is(err, failure) {
|
||||
t.Fatalf("copy error = %v", err)
|
||||
}
|
||||
if entries, readErr := os.ReadDir(parent); readErr != nil || len(entries) != 0 {
|
||||
t.Fatalf("copy failure retained staging: %v, err=%v", entries, readErr)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rename", func(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return failure })
|
||||
err := publishDWSManagedSkillDir(skillSrc, filepath.Join(parent, "dingtalk-a"))
|
||||
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "发布 Skill") {
|
||||
t.Fatalf("rename error = %v", err)
|
||||
}
|
||||
if entries, readErr := os.ReadDir(parent); readErr != nil || len(entries) != 0 {
|
||||
t.Fatalf("rename failure retained staging: %v, err=%v", entries, readErr)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cleanup", func(t *testing.T) {
|
||||
renameErr := errors.New("rename denied")
|
||||
cleanupErr := errors.New("cleanup denied")
|
||||
testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return renameErr })
|
||||
testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return cleanupErr })
|
||||
err := publishDWSManagedSkillDir(skillSrc, filepath.Join(t.TempDir(), "dingtalk-a"))
|
||||
if !errors.Is(err, renameErr) || !errors.Is(err, cleanupErr) {
|
||||
t.Fatalf("cleanup error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageSkillSetupCleanupHomeFailure verifies that
|
||||
// cleanupMutualExclusion keeps every victim in place with a warning when
|
||||
// $HOME cannot be resolved, instead of destroying anything.
|
||||
func TestCrossPlatformCoverageSkillSetupCleanupHomeFailure(t *testing.T) {
|
||||
dest := filepath.Join(t.TempDir(), ".agents", "skills")
|
||||
victim := filepath.Join(dest, "dws")
|
||||
if err := os.MkdirAll(victim, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(victim, "SKILL.md"), []byte("mono"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
homeErr := errors.New("home boom")
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", homeErr })
|
||||
|
||||
var out, errOut bytes.Buffer
|
||||
cleanupMutualExclusion(dest, skillSetupModeMulti, &out, &errOut)
|
||||
if !strings.Contains(errOut.String(), "无法解析 HOME,跳过删除") {
|
||||
t.Fatalf("expected HOME warning on errOut, got %q", errOut.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(victim, "SKILL.md")); err != nil {
|
||||
t.Fatalf("victim must survive the HOME failure: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupBackupFailureSkipsWholeTarget(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
|
||||
copyCalls := 0
|
||||
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error {
|
||||
copyCalls++
|
||||
return nil
|
||||
})
|
||||
failure := errors.New("backup boom")
|
||||
testseam.Swap(t, &skillSetupBackupAndRemove, func(_ string, dir string) (string, error) {
|
||||
if filepath.Base(dir) == "dws" {
|
||||
return "", failure
|
||||
}
|
||||
return "", nil
|
||||
})
|
||||
|
||||
dest := filepath.Join(home, ".agents", "skills")
|
||||
if err := os.MkdirAll(filepath.Join(dest, "dws"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := writeMultiSkillSource(t, []string{"dingtalk-a", "dingtalk-shared"})
|
||||
var out, errOut bytes.Buffer
|
||||
installed, skipped, err := installMultiSkillToHomes(src, []string{"dingtalk-a", "dingtalk-shared"}, []string{dest}, &out, &errOut, false)
|
||||
if err != nil || installed != 0 || skipped != 2 {
|
||||
t.Fatalf("install = (%d, %d, %v), want (0, 2, nil)", installed, skipped, err)
|
||||
}
|
||||
if copyCalls != 2 {
|
||||
t.Fatalf("backup failure staged %d new Skills, want 2", copyCalls)
|
||||
}
|
||||
if !strings.Contains(errOut.String(), "跳过整个 Agent 目标") {
|
||||
t.Fatalf("missing whole-target warning: %q", errOut.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupCleanupMutualExclusionBackupFailure(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
dest := filepath.Join(home, ".agents", "skills")
|
||||
victim := filepath.Join(dest, "dws")
|
||||
if err := os.MkdirAll(victim, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
failure := errors.New("backup boom")
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
|
||||
testseam.Swap(t, &skillSetupBackupAndRemove, func(_ string, dir string) (string, error) {
|
||||
if dir != victim {
|
||||
t.Fatalf("backup victim = %q, want %q", dir, victim)
|
||||
}
|
||||
return "", failure
|
||||
})
|
||||
|
||||
var out, errOut bytes.Buffer
|
||||
err := cleanupMutualExclusion(dest, skillSetupModeMulti, &out, &errOut)
|
||||
if !errors.Is(err, failure) {
|
||||
t.Fatalf("cleanup error = %v, want %v", err, failure)
|
||||
}
|
||||
if out.Len() != 0 || !strings.Contains(errOut.String(), "互斥清理失败") {
|
||||
t.Fatalf("cleanup output = %q / %q", out.String(), errOut.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupMonoCleanupFailureSkipsWholeTarget(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
|
||||
copyCalls := 0
|
||||
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error {
|
||||
copyCalls++
|
||||
return nil
|
||||
})
|
||||
failure := errors.New("multi backup boom")
|
||||
testseam.Swap(t, &skillSetupBackupAndRemove, func(_ string, dir string) (string, error) {
|
||||
if filepath.Base(dir) == "dingtalk-a" {
|
||||
return "", failure
|
||||
}
|
||||
return "", nil
|
||||
})
|
||||
|
||||
base := filepath.Join(home, ".agents", "skills")
|
||||
multi := filepath.Join(base, "dingtalk-a")
|
||||
if err := os.MkdirAll(multi, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
useManagedSkillNames(t, filepath.Base(multi))
|
||||
monoSrc := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(monoSrc, "SKILL.md"), []byte("mono"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out, errOut bytes.Buffer
|
||||
installed, skipped, err := installSkillToHomes(monoSrc, []string{filepath.Join(base, "dws")}, &out, &errOut)
|
||||
if err != nil || installed != 0 || skipped != 1 {
|
||||
t.Fatalf("install = (%d, %d, %v), want (0, 1, nil)", installed, skipped, err)
|
||||
}
|
||||
if copyCalls != 1 {
|
||||
t.Fatalf("multi cleanup failure staged mono %d times, want 1", copyCalls)
|
||||
}
|
||||
if _, err := os.Stat(multi); err != nil {
|
||||
t.Fatalf("multi leftover must survive backup failure: %v", err)
|
||||
}
|
||||
if !strings.Contains(errOut.String(), "Skill 备份失败,已执行回滚,跳过整个 Agent 目标") {
|
||||
t.Fatalf("missing mono whole-target warning: %q", errOut.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupStaleBackupFailureSkipsWholeTarget(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
|
||||
copyCalls := 0
|
||||
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error {
|
||||
copyCalls++
|
||||
return nil
|
||||
})
|
||||
failure := errors.New("stale backup boom")
|
||||
testseam.Swap(t, &skillSetupBackupAndRemove, func(_ string, dir string) (string, error) {
|
||||
if filepath.Base(dir) == "dingtalk-stale" {
|
||||
return "", failure
|
||||
}
|
||||
return "", nil
|
||||
})
|
||||
|
||||
dest := filepath.Join(home, ".agents", "skills")
|
||||
stale := filepath.Join(dest, "dingtalk-stale")
|
||||
if err := os.MkdirAll(stale, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
useManagedSkillNames(t, filepath.Base(stale))
|
||||
src := writeMultiSkillSource(t, []string{"dingtalk-a", "dingtalk-shared"})
|
||||
var out, errOut bytes.Buffer
|
||||
installed, skipped, err := installMultiSkillToHomes(src, []string{"dingtalk-a", "dingtalk-shared"}, []string{dest}, &out, &errOut, false)
|
||||
if err != nil || installed != 0 || skipped != 2 {
|
||||
t.Fatalf("install = (%d, %d, %v), want (0, 2, nil)", installed, skipped, err)
|
||||
}
|
||||
if copyCalls != 2 {
|
||||
t.Fatalf("stale backup failure staged %d new Skills, want 2", copyCalls)
|
||||
}
|
||||
if !strings.Contains(errOut.String(), "Skill 备份失败,已执行回滚,跳过整个 Agent 目标") {
|
||||
t.Fatalf("missing stale whole-target warning: %q", errOut.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupTransactionFailuresRestoreOldSet(t *testing.T) {
|
||||
for _, failureKind := range []string{"later_backup", "later_publish"} {
|
||||
failureKind := failureKind
|
||||
t.Run(failureKind, func(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
dest := filepath.Join(home, ".agents", "skills")
|
||||
first := filepath.Join(dest, "dingtalk-first")
|
||||
second := filepath.Join(dest, "dingtalk-second")
|
||||
for path, body := range map[string]string{
|
||||
filepath.Join(first, "SKILL.md"): "old first\n",
|
||||
filepath.Join(second, "SKILL.md"): "old second\n",
|
||||
} {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
src := writeMultiSkillSource(t, []string{"dingtalk-first", "dingtalk-second"})
|
||||
if err := os.WriteFile(filepath.Join(src, "dingtalk-first", "SKILL.md"), []byte("new first\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(src, "dingtalk-second", "SKILL.md"), []byte("new second\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
|
||||
failure := errors.New("injected " + failureKind + " failure")
|
||||
if failureKind == "later_backup" {
|
||||
originalBackup := skillSetupBackupAndRemove
|
||||
testseam.Swap(t, &skillSetupBackupAndRemove, func(homeDir, dir string) (string, error) {
|
||||
if dir == second {
|
||||
return "", failure
|
||||
}
|
||||
return originalBackup(homeDir, dir)
|
||||
})
|
||||
} else {
|
||||
originalRename := skillSetupPublishRename
|
||||
testseam.Swap(t, &skillSetupPublishRename, func(oldPath, newPath string) error {
|
||||
if newPath == second && strings.HasPrefix(filepath.Base(filepath.Dir(oldPath)), ".dws-setup-set-") {
|
||||
return failure
|
||||
}
|
||||
return originalRename(oldPath, newPath)
|
||||
})
|
||||
}
|
||||
|
||||
var out, errOut bytes.Buffer
|
||||
installed, skipped, err := installMultiSkillToHomes(
|
||||
src,
|
||||
[]string{"dingtalk-first", "dingtalk-second"},
|
||||
[]string{dest},
|
||||
&out,
|
||||
&errOut,
|
||||
true,
|
||||
)
|
||||
if err != nil || installed != 0 || skipped != 2 {
|
||||
t.Fatalf("transaction failure = (%d, %d, %v), stderr=%s", installed, skipped, err, errOut.String())
|
||||
}
|
||||
for path, want := range map[string]string{
|
||||
filepath.Join(first, "SKILL.md"): "old first\n",
|
||||
filepath.Join(second, "SKILL.md"): "old second\n",
|
||||
} {
|
||||
got, readErr := os.ReadFile(path)
|
||||
if readErr != nil || string(got) != want {
|
||||
t.Fatalf("restored %s = %q, err=%v, want %q", path, got, readErr, want)
|
||||
}
|
||||
}
|
||||
entries, readErr := os.ReadDir(dest)
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if strings.HasPrefix(entry.Name(), ".dws-setup-set-") {
|
||||
t.Fatalf("transaction left staging directory %s", entry.Name())
|
||||
}
|
||||
}
|
||||
if !strings.Contains(errOut.String(), "已执行回滚") || !strings.Contains(errOut.String(), failure.Error()) {
|
||||
t.Fatalf("transaction failure output = %q", errOut.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) {
|
||||
failure := errors.New("injected transaction failure")
|
||||
|
||||
t.Run("managed publish success", func(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{"dingtalk-a"})
|
||||
dest := filepath.Join(t.TempDir(), "dingtalk-a")
|
||||
if err := publishDWSManagedSkillDir(filepath.Join(src, "dingtalk-a"), dest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dest, "SKILL.md")); err != nil {
|
||||
t.Fatalf("published Skill missing: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("staging cleanup failure", func(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{"dingtalk-a"})
|
||||
dest := t.TempDir()
|
||||
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error { return failure })
|
||||
cleanupErr := errors.New("staging cleanup failure")
|
||||
testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return cleanupErr })
|
||||
_, _, err := stageSkillSetupTarget(
|
||||
&skillSetupPlan{Mode: skillSetupModeMulti, Source: src, MultiSkillNames: []string{"dingtalk-a"}},
|
||||
skillSetupTargetPlan{Destination: dest},
|
||||
)
|
||||
if !errors.Is(err, failure) || !errors.Is(err, cleanupErr) {
|
||||
t.Fatalf("staging cleanup error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("staging directory failure", func(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{"dingtalk-a"})
|
||||
dest := t.TempDir()
|
||||
originalMkdirAll := skillSetupMkdirAll
|
||||
testseam.Swap(t, &skillSetupMkdirAll, func(path string, mode os.FileMode) error {
|
||||
if filepath.Base(path) == "dingtalk-a" && strings.HasPrefix(filepath.Base(filepath.Dir(path)), ".dws-setup-set-") {
|
||||
return failure
|
||||
}
|
||||
return originalMkdirAll(path, mode)
|
||||
})
|
||||
_, _, err := stageSkillSetupTarget(
|
||||
&skillSetupPlan{Mode: skillSetupModeMulti, Source: src, MultiSkillNames: []string{"dingtalk-a"}},
|
||||
skillSetupTargetPlan{Destination: dest},
|
||||
)
|
||||
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "创建 Skill staging 目录失败") {
|
||||
t.Fatalf("staging directory error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("restore failure aggregation", func(t *testing.T) {
|
||||
t.Run("remove published", func(t *testing.T) {
|
||||
testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return failure })
|
||||
if err := restoreSkillSetupTarget([]string{"published"}, nil); !errors.Is(err, failure) {
|
||||
t.Fatalf("remove published error = %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("original still exists", func(t *testing.T) {
|
||||
original := t.TempDir()
|
||||
err := restoreSkillSetupTarget(nil, []skillSetupBackedUpDir{{original: original, backup: "backup"}})
|
||||
if err == nil || !strings.Contains(err.Error(), "恢复目标仍存在") {
|
||||
t.Fatalf("existing restore target error = %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("stat", func(t *testing.T) {
|
||||
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, failure })
|
||||
err := restoreSkillSetupTarget(nil, []skillSetupBackedUpDir{{original: "original", backup: "backup"}})
|
||||
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "检查 Skill 恢复目标失败") {
|
||||
t.Fatalf("restore stat error = %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("mkdir", func(t *testing.T) {
|
||||
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
|
||||
testseam.Swap(t, &skillSetupMkdirAll, func(string, os.FileMode) error { return failure })
|
||||
err := restoreSkillSetupTarget(nil, []skillSetupBackedUpDir{{original: "original", backup: "backup"}})
|
||||
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "创建 Skill 恢复目录失败") {
|
||||
t.Fatalf("restore mkdir error = %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("rename", func(t *testing.T) {
|
||||
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
|
||||
testseam.Swap(t, &skillSetupMkdirAll, func(string, os.FileMode) error { return nil })
|
||||
testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return failure })
|
||||
err := restoreSkillSetupTarget(nil, []skillSetupBackedUpDir{{original: "original", backup: "backup"}})
|
||||
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "恢复原 Skill 失败") {
|
||||
t.Fatalf("restore rename error = %v", err)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("backup rollback failure", func(t *testing.T) {
|
||||
calls := 0
|
||||
testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
return "backup", nil
|
||||
}
|
||||
return "", failure
|
||||
})
|
||||
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
|
||||
testseam.Swap(t, &skillSetupMkdirAll, func(string, os.FileMode) error { return nil })
|
||||
restoreErr := errors.New("restore failure")
|
||||
testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return restoreErr })
|
||||
_, err := backupSkillSetupTarget("home", []skillSetupBackup{{Path: "first"}, {Path: "second"}}, io.Discard)
|
||||
if !errors.Is(err, failure) || !errors.Is(err, restoreErr) {
|
||||
t.Fatalf("backup rollback error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("publish rollback failure", func(t *testing.T) {
|
||||
testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return failure })
|
||||
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
|
||||
testseam.Swap(t, &skillSetupMkdirAll, func(string, os.FileMode) error { return nil })
|
||||
err := publishSkillSetupTarget(
|
||||
[]skillSetupStagedDir{{staged: "staged", dest: "dest"}},
|
||||
[]skillSetupBackedUpDir{{original: "dest", backup: "backup"}},
|
||||
)
|
||||
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "回滚不完整") {
|
||||
t.Fatalf("publish rollback error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("execute cleanup errors", func(t *testing.T) {
|
||||
newPlan := func(t *testing.T) *skillSetupPlan {
|
||||
t.Helper()
|
||||
src := writeMultiSkillSource(t, []string{"dingtalk-a"})
|
||||
return &skillSetupPlan{
|
||||
Mode: skillSetupModeMulti,
|
||||
Source: src,
|
||||
MultiSkillNames: []string{"dingtalk-a"},
|
||||
Targets: []skillSetupTargetPlan{{Destination: t.TempDir()}},
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("after backup failure", func(t *testing.T) {
|
||||
plan := newPlan(t)
|
||||
plan.Targets[0].Backups = []skillSetupBackup{{Path: filepath.Join(plan.Targets[0].Destination, "old")}}
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return t.TempDir(), nil })
|
||||
testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) { return "", failure })
|
||||
cleanupErr := errors.New("cleanup after backup failure")
|
||||
testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return cleanupErr })
|
||||
var stderr bytes.Buffer
|
||||
_, skipped, err := executeSkillSetupPlan(plan, io.Discard, &stderr)
|
||||
if err != nil || skipped != 1 || !strings.Contains(stderr.String(), cleanupErr.Error()) {
|
||||
t.Fatalf("backup cleanup = skipped %d, err %v, stderr %q", skipped, err, stderr.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("after publish failure", func(t *testing.T) {
|
||||
plan := newPlan(t)
|
||||
originalRename := skillSetupPublishRename
|
||||
testseam.Swap(t, &skillSetupPublishRename, func(oldPath, newPath string) error {
|
||||
if strings.HasPrefix(filepath.Base(filepath.Dir(oldPath)), ".dws-setup-set-") {
|
||||
return failure
|
||||
}
|
||||
return originalRename(oldPath, newPath)
|
||||
})
|
||||
originalRemoveAll := skillSetupRemoveAll
|
||||
cleanupErr := errors.New("cleanup after publish failure")
|
||||
testseam.Swap(t, &skillSetupRemoveAll, func(path string) error {
|
||||
if strings.HasPrefix(filepath.Base(path), ".dws-setup-set-") {
|
||||
return cleanupErr
|
||||
}
|
||||
return originalRemoveAll(path)
|
||||
})
|
||||
var stderr bytes.Buffer
|
||||
_, skipped, err := executeSkillSetupPlan(plan, io.Discard, &stderr)
|
||||
if err != nil || skipped != 1 || !strings.Contains(stderr.String(), cleanupErr.Error()) {
|
||||
t.Fatalf("publish cleanup = skipped %d, err %v, stderr %q", skipped, err, stderr.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("after success", func(t *testing.T) {
|
||||
plan := newPlan(t)
|
||||
originalRemoveAll := skillSetupRemoveAll
|
||||
cleanupErr := errors.New("cleanup after success")
|
||||
testseam.Swap(t, &skillSetupRemoveAll, func(path string) error {
|
||||
if strings.HasPrefix(filepath.Base(path), ".dws-setup-set-") {
|
||||
return cleanupErr
|
||||
}
|
||||
return originalRemoveAll(path)
|
||||
})
|
||||
var stderr bytes.Buffer
|
||||
installed, skipped, err := executeSkillSetupPlan(plan, io.Discard, &stderr)
|
||||
if err != nil || installed != 1 || skipped != 0 || !strings.Contains(stderr.String(), cleanupErr.Error()) {
|
||||
t.Fatalf("success cleanup = installed %d, skipped %d, err %v, stderr %q", installed, skipped, err, stderr.String())
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageSkillSetupInstallHomeFailureSkips verifies both
|
||||
// install paths skip (never destroy) every target when $HOME cannot be
|
||||
// resolved for the pre-refresh backup.
|
||||
func TestCrossPlatformCoverageSkillSetupInstallHomeFailureSkips(t *testing.T) {
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", errors.New("home boom") })
|
||||
|
||||
monoSrc := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(monoSrc, "SKILL.md"), []byte("# mono"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
monoDest := filepath.Join(t.TempDir(), "agent", "dws")
|
||||
if err := os.MkdirAll(monoDest, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(monoDest, "SKILL.md"), []byte("# old"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out, errOut bytes.Buffer
|
||||
installed, skipped, err := installSkillToHomes(monoSrc, []string{monoDest}, &out, &errOut)
|
||||
if err != nil || installed != 0 || skipped != 1 {
|
||||
t.Fatalf("mono install = (%d, %d, %v), want (0, 1, nil)", installed, skipped, err)
|
||||
}
|
||||
if !strings.Contains(errOut.String(), "无法解析 HOME,跳过刷新") {
|
||||
t.Fatalf("expected HOME skip warning, got %q", errOut.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(monoDest, "SKILL.md")); err != nil {
|
||||
t.Fatalf("existing mono dir must be preserved: %v", err)
|
||||
}
|
||||
|
||||
multiSrc := writeMultiSkillSource(t, []string{"dingtalk-a"})
|
||||
multiDest := filepath.Join(t.TempDir(), ".claude", "skills")
|
||||
if err := os.MkdirAll(filepath.Join(multiDest, "dingtalk-a"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out.Reset()
|
||||
errOut.Reset()
|
||||
installed, skipped, err = installMultiSkillToHomes(multiSrc, []string{"dingtalk-a"}, []string{multiDest}, &out, &errOut, true)
|
||||
if err != nil || installed != 0 || skipped != 1 {
|
||||
t.Fatalf("multi install = (%d, %d, %v), want (0, 1, nil)", installed, skipped, err)
|
||||
}
|
||||
if !strings.Contains(errOut.String(), "无法解析 HOME,跳过整个 Agent 目标") {
|
||||
t.Fatalf("expected multi HOME skip warning, got %q", errOut.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(multiDest, "dingtalk-a")); err != nil {
|
||||
t.Fatalf("existing sub skill must be preserved: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageSkillSetupRemoveStaleMultiSkillsEdges covers
|
||||
// removeStaleMultiSkills and its preview companion staleMultiSkillVictims:
|
||||
// scan failures, the HOME failure, backup failures, and the success path.
|
||||
func TestCrossPlatformCoverageSkillSetupRemoveStaleMultiSkillsEdges(t *testing.T) {
|
||||
dest := filepath.Join(t.TempDir(), ".cursor", "skills")
|
||||
keep := []string{"dingtalk-chat"}
|
||||
entries := map[string]bool{ // dir entries; README below is a plain file
|
||||
"dingtalk-chat": true, // kept (in bundle)
|
||||
"dingtalk-stale": true, // stale product skill
|
||||
"dws-shared": true, // legacy shared name is stale too
|
||||
"other-skill": true, // non-DWS, must survive
|
||||
}
|
||||
for name := range entries {
|
||||
if err := os.MkdirAll(filepath.Join(dest, name), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
useManagedSkillNames(t, "dingtalk-stale")
|
||||
if err := os.WriteFile(filepath.Join(dest, "README"), []byte("file"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var out, errOut bytes.Buffer
|
||||
|
||||
// Non-ENOENT scan failure warns; ENOENT is silent.
|
||||
testseam.Swap(t, &skillSetupReadDir, func(string) ([]os.DirEntry, error) { return nil, errors.New("scan boom") })
|
||||
removeStaleMultiSkills(dest, keep, &out, &errOut)
|
||||
if !strings.Contains(errOut.String(), "过期 skill 扫描失败") {
|
||||
t.Fatalf("expected scan warning, got %q", errOut.String())
|
||||
}
|
||||
errOut.Reset()
|
||||
testseam.Swap(t, &skillSetupReadDir, func(string) ([]os.DirEntry, error) { return nil, os.ErrNotExist })
|
||||
removeStaleMultiSkills(dest, keep, &out, &errOut)
|
||||
if errOut.Len() != 0 {
|
||||
t.Fatalf("ENOENT scan must be silent, got %q", errOut.String())
|
||||
}
|
||||
testseam.Swap(t, &skillSetupReadDir, os.ReadDir)
|
||||
|
||||
// The preview companion sees the same victims and skips files/kept/non-DWS.
|
||||
victims := staleMultiSkillVictims(dest, keep)
|
||||
wantVictims := []string{filepath.Join(dest, "dingtalk-stale"), filepath.Join(dest, "dws-shared")}
|
||||
if len(victims) != len(wantVictims) {
|
||||
t.Fatalf("staleMultiSkillVictims = %v, want %v", victims, wantVictims)
|
||||
}
|
||||
|
||||
// HOME failure keeps every stale directory with a warning.
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", errors.New("home boom") })
|
||||
errOut.Reset()
|
||||
removeStaleMultiSkills(dest, keep, &out, &errOut)
|
||||
if !strings.Contains(errOut.String(), "无法解析 HOME,跳过删除") {
|
||||
t.Fatalf("expected HOME warning, got %q", errOut.String())
|
||||
}
|
||||
for name := range entries {
|
||||
if _, err := os.Stat(filepath.Join(dest, name)); err != nil {
|
||||
t.Fatalf("entry %s must survive the HOME failure: %v", name, err)
|
||||
}
|
||||
}
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return t.TempDir(), nil })
|
||||
|
||||
// Backup failure keeps the stale directory with a warning.
|
||||
testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) { return "", errors.New("backup boom") })
|
||||
errOut.Reset()
|
||||
removeStaleMultiSkills(dest, keep, &out, &errOut)
|
||||
if !strings.Contains(errOut.String(), "过期 skill 清理失败(保留原目录") {
|
||||
t.Fatalf("expected backup failure warning, got %q", errOut.String())
|
||||
}
|
||||
for _, stale := range wantVictims {
|
||||
if _, err := os.Stat(stale); err != nil {
|
||||
t.Fatalf("stale dir must survive the backup failure: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Success: both stale dirs are backed up and reported; the rest survives.
|
||||
testseam.Swap(t, &skillSetupBackupAndRemove, func(_, dir string) (string, error) { return filepath.Join(t.TempDir(), "backup"), nil })
|
||||
out.Reset()
|
||||
removeStaleMultiSkills(dest, keep, &out, &errOut)
|
||||
if count := strings.Count(out.String(), "已备份并清理过期 skill"); count != len(wantVictims) {
|
||||
t.Fatalf("expected %d stale cleanup lines, got %d (out=%q)", len(wantVictims), count, out.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dest, "other-skill")); err != nil {
|
||||
t.Fatalf("non-DWS dir must survive: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dest, "dingtalk-chat")); err != nil {
|
||||
t.Fatalf("bundle skill must survive: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -47,18 +47,16 @@ func TestCrossPlatformCoverageSkillSetupHighLevelRemainingCoverage(t *testing.T)
|
||||
oldTargets := skillSetupResolveTargets
|
||||
oldList := skillSetupListMulti
|
||||
oldFilter := skillSetupFilterMulti
|
||||
oldConfirm := skillSetupConfirm
|
||||
oldMono := skillSetupInstallMono
|
||||
oldMulti := skillSetupInstallMulti
|
||||
oldConfirm := skillSetupConfirmPlan
|
||||
oldExecute := skillSetupExecutePlan
|
||||
t.Cleanup(func() {
|
||||
skillSetupResolveMode = oldMode
|
||||
skillSetupResolveSource = oldSource
|
||||
skillSetupResolveTargets = oldTargets
|
||||
skillSetupListMulti = oldList
|
||||
skillSetupFilterMulti = oldFilter
|
||||
skillSetupConfirm = oldConfirm
|
||||
skillSetupInstallMono = oldMono
|
||||
skillSetupInstallMulti = oldMulti
|
||||
skillSetupConfirmPlan = oldConfirm
|
||||
skillSetupExecutePlan = oldExecute
|
||||
})
|
||||
fail := errors.New("failure")
|
||||
skillSetupResolveMode = func(mode string, _ bool, _ io.Writer) (string, error) { return mode, nil }
|
||||
@@ -83,17 +81,17 @@ func TestCrossPlatformCoverageSkillSetupHighLevelRemainingCoverage(t *testing.T)
|
||||
}
|
||||
skillSetupFilterMulti = func(all, _, _ []string) ([]string, error) { return all, nil }
|
||||
cmd = skillSetupCoverageCommand(t, skillSetupModeMulti, true)
|
||||
_ = cmd.Root().PersistentFlags().Set("dry-run", "true")
|
||||
cmd.Flags().Bool("dry-run", true, "")
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
skillSetupConfirm = func(io.Writer, string, string, []string, []string) (bool, error) { return false, fail }
|
||||
skillSetupConfirmPlan = func(io.Writer, *skillSetupPlan) (bool, error) { return false, fail }
|
||||
cmd = skillSetupCoverageCommand(t, skillSetupModeMono, false)
|
||||
if err := cmd.RunE(cmd, nil); err == nil {
|
||||
t.Fatal("confirmation failure should propagate")
|
||||
}
|
||||
skillSetupConfirm = func(io.Writer, string, string, []string, []string) (bool, error) { return false, nil }
|
||||
skillSetupConfirmPlan = func(io.Writer, *skillSetupPlan) (bool, error) { return false, nil }
|
||||
cmd = skillSetupCoverageCommand(t, skillSetupModeMono, false)
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -105,17 +103,17 @@ func TestCrossPlatformCoverageSkillSetupHighLevelRemainingCoverage(t *testing.T)
|
||||
t.Fatal("unknown resolved mode should fail")
|
||||
}
|
||||
skillSetupResolveMode = func(mode string, _ bool, _ io.Writer) (string, error) { return mode, nil }
|
||||
skillSetupInstallMono = func(string, []string, io.Writer, io.Writer) (int, int, error) { return 0, 0, fail }
|
||||
skillSetupExecutePlan = func(*skillSetupPlan, io.Writer, io.Writer) (int, int, error) { return 0, 0, fail }
|
||||
cmd = skillSetupCoverageCommand(t, skillSetupModeMono, true)
|
||||
if err := cmd.RunE(cmd, nil); err == nil {
|
||||
t.Fatal("mono install failure should propagate")
|
||||
}
|
||||
skillSetupInstallMono = func(string, []string, io.Writer, io.Writer) (int, int, error) { return 1, 0, nil }
|
||||
skillSetupExecutePlan = func(*skillSetupPlan, io.Writer, io.Writer) (int, int, error) { return 1, 0, nil }
|
||||
cmd = skillSetupCoverageCommand(t, skillSetupModeMono, true)
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
skillSetupInstallMulti = func(string, []string, []string, io.Writer, io.Writer) (int, int, error) { return 0, 0, fail }
|
||||
skillSetupExecutePlan = func(*skillSetupPlan, io.Writer, io.Writer) (int, int, error) { return 0, 0, fail }
|
||||
cmd = skillSetupCoverageCommand(t, skillSetupModeMulti, true)
|
||||
if err := cmd.RunE(cmd, nil); err == nil {
|
||||
t.Fatal("multi install failure should propagate")
|
||||
@@ -123,6 +121,7 @@ func TestCrossPlatformCoverageSkillSetupHighLevelRemainingCoverage(t *testing.T)
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupMigratesLegacySharedAfterReplacement(t *testing.T) {
|
||||
setTestHome(t, t.TempDir())
|
||||
src := writeMultiSkillSource(t, []string{multiSharedSkill, "dingtalk-chat"})
|
||||
home := filepath.Join(t.TempDir(), "skills")
|
||||
legacyPath := filepath.Join(home, legacyMultiSharedSkill)
|
||||
@@ -143,6 +142,7 @@ func TestCrossPlatformCoverageSkillSetupMigratesLegacySharedAfterReplacement(t *
|
||||
[]string{home},
|
||||
&out,
|
||||
&errOut,
|
||||
true,
|
||||
)
|
||||
if err != nil || installed != 2 || skipped != 0 {
|
||||
t.Fatalf("install = %d/%d, err=%v, stderr=%s", installed, skipped, err, errOut.String())
|
||||
@@ -156,7 +156,7 @@ func TestCrossPlatformCoverageSkillSetupMigratesLegacySharedAfterReplacement(t *
|
||||
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 残留") {
|
||||
if !strings.Contains(out.String(), "已备份并清理过期 skill") {
|
||||
t.Fatalf("legacy cleanup was not reported: %s", out.String())
|
||||
}
|
||||
|
||||
@@ -178,12 +178,17 @@ func TestCrossPlatformCoverageSkillSetupMigratesLegacySharedAfterReplacement(t *
|
||||
[]string{failureHome},
|
||||
&failureOut,
|
||||
&failureErr,
|
||||
true,
|
||||
)
|
||||
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)
|
||||
got, readErr := os.ReadFile(filepath.Join(failureLegacy, "SKILL.md"))
|
||||
if readErr != nil || string(got) != "legacy\n" {
|
||||
t.Fatalf("failed replacement changed the live legacy copy: %q, err=%v", got, readErr)
|
||||
}
|
||||
if !strings.Contains(failureErr.String(), "Skill staging 失败,保留原集合") {
|
||||
t.Fatalf("failed replacement did not report preserved live set: %s", failureErr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -226,6 +231,7 @@ func TestCrossPlatformCoverageSkillSetupLowLevelRemainingCoverage(t *testing.T)
|
||||
oldReadDir, oldStat := skillSetupReadDir, skillSetupStat
|
||||
oldExecutable, oldGetwd, oldHome := skillSetupExecutable, skillSetupGetwd, skillSetupUserHomeDir
|
||||
oldRemove, oldMkdir := skillSetupRemoveAll, skillSetupMkdirAll
|
||||
oldBackup := skillSetupBackupAndRemove
|
||||
oldCopyDir, oldWalk, oldRel := skillSetupCopyDir, skillSetupWalk, skillSetupRel
|
||||
oldMkdirTemp, oldRename := skillSetupMkdirTemp, skillSetupRename
|
||||
oldReadlink, oldOpen, oldOpenFile, oldCopy := skillSetupReadlink, skillSetupOpen, skillSetupOpenFile, skillSetupCopy
|
||||
@@ -234,6 +240,7 @@ func TestCrossPlatformCoverageSkillSetupLowLevelRemainingCoverage(t *testing.T)
|
||||
skillSetupReadDir, skillSetupStat = oldReadDir, oldStat
|
||||
skillSetupExecutable, skillSetupGetwd, skillSetupUserHomeDir = oldExecutable, oldGetwd, oldHome
|
||||
skillSetupRemoveAll, skillSetupMkdirAll = oldRemove, oldMkdir
|
||||
skillSetupBackupAndRemove = oldBackup
|
||||
skillSetupCopyDir, skillSetupWalk, skillSetupRel = oldCopyDir, oldWalk, oldRel
|
||||
skillSetupMkdirTemp, skillSetupRename = oldMkdirTemp, oldRename
|
||||
skillSetupReadlink, skillSetupOpen, skillSetupOpenFile, skillSetupCopy = oldReadlink, oldOpen, oldOpenFile, oldCopy
|
||||
@@ -246,7 +253,7 @@ func TestCrossPlatformCoverageSkillSetupLowLevelRemainingCoverage(t *testing.T)
|
||||
t.Fatal("interactive mode failure should propagate")
|
||||
}
|
||||
skillSetupRunForm = func(*huh.Form) error { return nil }
|
||||
if got, err := resolveSkillSetupMode("", false, io.Discard); err != nil || got != skillSetupModeMono {
|
||||
if got, err := resolveSkillSetupMode("", false, io.Discard); err != nil || got != skillSetupModeMulti {
|
||||
t.Fatalf("interactive default choice = %q, %v", got, err)
|
||||
}
|
||||
|
||||
@@ -305,23 +312,24 @@ func TestCrossPlatformCoverageSkillSetupLowLevelRemainingCoverage(t *testing.T)
|
||||
skillSetupReadDir, skillSetupStat = oldReadDir, oldStat
|
||||
var out, errOut bytes.Buffer
|
||||
skillSetupRunForm = func(*huh.Form) error { return fail }
|
||||
if _, err := confirmSkillSetup(&out, skillSetupModeMulti, "src", []string{monoDest}, []string{"dingtalk-doc"}); err == nil {
|
||||
if _, err := confirmSkillSetup(&out, skillSetupModeMulti, "src", []string{monoDest}, []string{"dingtalk-doc"}, false); err == nil {
|
||||
t.Fatal("confirmation form failure should propagate")
|
||||
}
|
||||
skillSetupRunForm = func(*huh.Form) error { return nil }
|
||||
if ok, err := confirmSkillSetup(&out, skillSetupModeMono, "src", []string{monoDest}, nil); err != nil || ok {
|
||||
if ok, err := confirmSkillSetup(&out, skillSetupModeMono, "src", []string{monoDest}, nil, false); err != nil || ok {
|
||||
t.Fatalf("EOF confirmation = %v, %v", ok, err)
|
||||
}
|
||||
skillSetupRemoveAll = func(string) error { return fail }
|
||||
skillSetupUserHomeDir = func() (string, error) { return t.TempDir(), nil }
|
||||
skillSetupBackupAndRemove = func(string, string) (string, error) { return "", fail }
|
||||
cleanupMutualExclusion(monoDest, skillSetupModeMono, &out, &errOut)
|
||||
|
||||
skillSetupCopyDir = func(string, string) error { return fail }
|
||||
skillSetupRemoveAll = func(string) error { return fail }
|
||||
skillSetupBackupAndRemove = func(string, string) (string, error) { return "", fail }
|
||||
_, skipped, _ := installSkillToHomes("src", []string{"a"}, &out, &errOut)
|
||||
if skipped != 1 {
|
||||
t.Fatal("mono remove failure not skipped")
|
||||
t.Fatal("mono backup failure not skipped")
|
||||
}
|
||||
skillSetupRemoveAll = func(string) error { return nil }
|
||||
skillSetupBackupAndRemove = func(string, string) (string, error) { return "", nil }
|
||||
skillSetupMkdirAll = func(string, os.FileMode) error { return fail }
|
||||
_, skipped, _ = installSkillToHomes("src", []string{"b"}, &out, &errOut)
|
||||
if skipped != 1 {
|
||||
@@ -334,18 +342,18 @@ func TestCrossPlatformCoverageSkillSetupLowLevelRemainingCoverage(t *testing.T)
|
||||
}
|
||||
|
||||
skillSetupMkdirAll = func(string, os.FileMode) error { return fail }
|
||||
_, skipped, _ = installMultiSkillToHomes("src", []string{"one", "two"}, []string{filepath.Join(t.TempDir(), "dest")}, &out, &errOut)
|
||||
_, skipped, _ = installMultiSkillToHomes("src", []string{"one", "two"}, []string{filepath.Join(t.TempDir(), "dest")}, &out, &errOut, true)
|
||||
if skipped != 2 {
|
||||
t.Fatal("multi mkdir failure count mismatch")
|
||||
}
|
||||
skillSetupMkdirAll = func(string, os.FileMode) error { return nil }
|
||||
skillSetupRemoveAll = func(string) error { return fail }
|
||||
_, skipped, _ = installMultiSkillToHomes("src", []string{"one"}, []string{filepath.Join(t.TempDir(), "dest")}, &out, &errOut)
|
||||
skillSetupBackupAndRemove = func(string, string) (string, error) { return "", fail }
|
||||
_, skipped, _ = installMultiSkillToHomes("src", []string{"one"}, []string{filepath.Join(t.TempDir(), "dest")}, &out, &errOut, true)
|
||||
if skipped != 1 {
|
||||
t.Fatal("multi remove failure count mismatch")
|
||||
t.Fatal("multi backup failure count mismatch")
|
||||
}
|
||||
skillSetupRemoveAll = func(string) error { return nil }
|
||||
_, skipped, _ = installMultiSkillToHomes("src", []string{"one"}, []string{filepath.Join(t.TempDir(), "dest")}, &out, &errOut)
|
||||
skillSetupBackupAndRemove = func(string, string) (string, error) { return "", nil }
|
||||
_, skipped, _ = installMultiSkillToHomes("src", []string{"one"}, []string{filepath.Join(t.TempDir(), "dest")}, &out, &errOut, true)
|
||||
if skipped != 1 {
|
||||
t.Fatal("multi copy failure count mismatch")
|
||||
}
|
||||
@@ -516,15 +524,15 @@ func TestCrossPlatformCoverageSkillSetupEventMigrationFailureBranches(t *testing
|
||||
})
|
||||
|
||||
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) {
|
||||
testseam.Swap(t, &skillSetupInstallMulti, func(string, []string, []string, io.Writer, io.Writer, bool) (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) {
|
||||
if _, _, err := installMultiSkillsWithEventMigration("src", []string{multiEventSkill}, []string{migration, ordinary}, []string{migration}, true, 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) {
|
||||
if _, _, err := installMultiSkillsWithEventMigration("src", []string{multiEventSkill, multiMiscSkill, multiSharedSkill}, []string{migration}, []string{migration}, true, io.Discard, io.Discard); !errors.Is(err, fail) {
|
||||
t.Fatalf("prerequisite install failure = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/charmbracelet/huh"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupPlanPreviewDeclineAndExecutionMatch(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
dest := filepath.Join(home, ".claude", "skills")
|
||||
source := writeMultiSkillSource(t, []string{"dingtalk-a", "dingtalk-shared"})
|
||||
for _, name := range []string{"dws", "dingtalk-a", "dingtalk-stale"} {
|
||||
if err := os.MkdirAll(filepath.Join(dest, name), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
useManagedSkillNames(t, "dingtalk-stale")
|
||||
|
||||
testseam.Swap(t, &skillSetupResolveMode, func(mode string, _ bool, _ io.Writer) (string, error) { return mode, nil })
|
||||
testseam.Swap(t, &skillSetupResolveSource, func(string, string) (string, func(), error) { return source, func() {}, nil })
|
||||
testseam.Swap(t, &skillSetupResolveTargets, func(string, string) ([]string, error) { return []string{dest}, nil })
|
||||
testseam.Swap(t, &skillSetupListMulti, func(string) ([]string, error) {
|
||||
return []string{"dingtalk-a", "dingtalk-shared"}, nil
|
||||
})
|
||||
testseam.Swap(t, &skillSetupFilterMulti, filterMultiSkillNames)
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
|
||||
testseam.Swap(t, &skillSetupInteractive, func() bool { return true })
|
||||
testseam.Swap(t, &skillSetupRunForm, func(*huh.Form) error { return nil })
|
||||
testseam.Swap(t, &skillSetupWriteState, func(string, skillstate.State) error { return nil })
|
||||
|
||||
wantBackups := []string{
|
||||
filepath.Join(dest, "dingtalk-a"),
|
||||
filepath.Join(dest, "dingtalk-stale"),
|
||||
filepath.Join(dest, "dws"),
|
||||
}
|
||||
|
||||
// Dry-run must disclose every exact path and perform no backup or copy.
|
||||
backupCalls, copyCalls := []string{}, 0
|
||||
testseam.Swap(t, &skillSetupBackupAndRemove, func(_ string, path string) (string, error) {
|
||||
backupCalls = append(backupCalls, path)
|
||||
return "backup", nil
|
||||
})
|
||||
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error { copyCalls++; return nil })
|
||||
testseam.Swap(t, &skillSetupWriteFile, func(string, []byte, os.FileMode) error { return nil })
|
||||
testseam.Swap(t, &skillSetupPublishRename, func(src, dest string) error {
|
||||
if err := os.RemoveAll(dest); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(src, dest)
|
||||
})
|
||||
dryRunCmd := skillSetupCoverageCommand(t, skillSetupModeMulti, false)
|
||||
var dryRunOut bytes.Buffer
|
||||
dryRunCmd.SetOut(&dryRunOut)
|
||||
if err := dryRunCmd.Root().PersistentFlags().Set("dry-run", "true"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := dryRunCmd.RunE(dryRunCmd, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, path := range wantBackups {
|
||||
if strings.Count(dryRunOut.String(), path) != 1 {
|
||||
t.Fatalf("dry-run path %s count != 1:\n%s", path, dryRunOut.String())
|
||||
}
|
||||
}
|
||||
if len(backupCalls) != 0 || copyCalls != 0 {
|
||||
t.Fatalf("dry-run mutated backup=%v copy=%d", backupCalls, copyCalls)
|
||||
}
|
||||
|
||||
// The real confirmation renderer discloses the same paths. Its default
|
||||
// negative answer must leave backup and copy at zero calls.
|
||||
declineCmd := skillSetupCoverageCommand(t, skillSetupModeMulti, false)
|
||||
var declineOut bytes.Buffer
|
||||
declineCmd.SetOut(&declineOut)
|
||||
if err := declineCmd.RunE(declineCmd, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, path := range wantBackups {
|
||||
if strings.Count(declineOut.String(), path) != 1 {
|
||||
t.Fatalf("confirmation path %s count != 1:\n%s", path, declineOut.String())
|
||||
}
|
||||
}
|
||||
if len(backupCalls) != 0 || copyCalls != 0 {
|
||||
t.Fatalf("declined confirmation mutated backup=%v copy=%d", backupCalls, copyCalls)
|
||||
}
|
||||
|
||||
// Explicit confirmation executes exactly the paths rendered from the plan.
|
||||
var confirmedPlan *skillSetupPlan
|
||||
testseam.Swap(t, &skillSetupConfirmPlan, func(out io.Writer, plan *skillSetupPlan) (bool, error) {
|
||||
confirmedPlan = plan
|
||||
renderSkillSetupPlan(out, plan)
|
||||
return true, nil
|
||||
})
|
||||
confirmCmd := skillSetupCoverageCommand(t, skillSetupModeMulti, false)
|
||||
if err := confirmCmd.RunE(confirmCmd, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var planned []string
|
||||
for _, target := range confirmedPlan.Targets {
|
||||
for _, backup := range target.Backups {
|
||||
planned = append(planned, backup.Path)
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(planned, wantBackups) || !reflect.DeepEqual(backupCalls, wantBackups) {
|
||||
t.Fatalf("planned=%v executed=%v want=%v", planned, backupCalls, wantBackups)
|
||||
}
|
||||
if copyCalls != 2 {
|
||||
t.Fatalf("copy calls = %d, want 2", copyCalls)
|
||||
}
|
||||
|
||||
// A filtered multi plan replaces only selected same-name skills and leaves
|
||||
// unselected siblings out of the backup set.
|
||||
filtered, err := buildSkillSetupPlan(skillSetupModeMulti, source, []string{dest}, []string{"dingtalk-a"}, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var filteredPaths []string
|
||||
for _, backup := range filtered.Targets[0].Backups {
|
||||
filteredPaths = append(filteredPaths, backup.Path)
|
||||
}
|
||||
if !reflect.DeepEqual(filteredPaths, []string{filepath.Join(dest, "dingtalk-a"), filepath.Join(dest, "dws")}) {
|
||||
t.Fatalf("filtered backups = %v", filteredPaths)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupMonoPlanIncludesSameNameTarget(t *testing.T) {
|
||||
dest := filepath.Join(t.TempDir(), ".agents", "skills", "dws")
|
||||
if err := os.MkdirAll(dest, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plan, err := buildSkillSetupPlan(skillSetupModeMono, "source", []string{dest}, nil, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(plan.Targets) != 1 || len(plan.Targets[0].Backups) != 1 || plan.Targets[0].Backups[0].Path != dest || plan.Targets[0].Backups[0].Reason != skillSetupBackupReplace {
|
||||
t.Fatalf("mono plan = %#v", plan)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupGenericCleanupDerivesHomeFromConcreteTarget(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
dest := filepath.Join(home, ".codex", "skills")
|
||||
genericMono := filepath.Join(home, ".agents", "skills", "dws")
|
||||
if err := os.MkdirAll(genericMono, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) {
|
||||
return "", errors.New("transient HOME failure")
|
||||
})
|
||||
|
||||
plan, err := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{dest}, []string{"dingtalk-chat"}, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(plan.Targets) != 2 || !plan.Targets[1].CleanupOnly || plan.Targets[1].Destination != filepath.Dir(genericMono) {
|
||||
t.Fatalf("generic cleanup target = %#v", plan.Targets)
|
||||
}
|
||||
if len(plan.Targets[1].Backups) != 1 || plan.Targets[1].Backups[0].Path != genericMono {
|
||||
t.Fatalf("generic cleanup backups = %#v", plan.Targets[1].Backups)
|
||||
}
|
||||
var preview bytes.Buffer
|
||||
renderSkillSetupPlan(&preview, plan)
|
||||
if !strings.Contains(preview.String(), "仅迁移旧的通用 DWS 副本") {
|
||||
t.Fatalf("generic cleanup preview missing: %s", preview.String())
|
||||
}
|
||||
|
||||
t.Run("managed multi and scan failure", func(t *testing.T) {
|
||||
managedDir := filepath.Join(home, ".agents", "skills", "dingtalk-chat")
|
||||
if err := os.MkdirAll(managedDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target, targetErr := genericSkillCleanupTarget([]string{dest}, map[string]bool{"dingtalk-chat": true})
|
||||
if targetErr != nil || target == nil || len(target.Backups) != 2 {
|
||||
t.Fatalf("managed generic cleanup = %#v, %v", target, targetErr)
|
||||
}
|
||||
failure := errors.New("generic scan failure")
|
||||
testseam.Swap(t, &skillSetupReadDir, func(string) ([]os.DirEntry, error) { return nil, failure })
|
||||
if _, targetErr := genericSkillCleanupTarget([]string{dest}, nil); !errors.Is(targetErr, failure) {
|
||||
t.Fatalf("generic scan error = %v", targetErr)
|
||||
}
|
||||
if _, planErr := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{dest}, []string{"dingtalk-chat"}, true); !errors.Is(planErr, failure) {
|
||||
t.Fatalf("generic cleanup plan error = %v", planErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupCleanupOnlyExecutionBranches(t *testing.T) {
|
||||
failure := errors.New("cleanup failure")
|
||||
cleanup := skillSetupTargetPlan{Destination: "generic", CleanupOnly: true, Backups: []skillSetupBackup{{Path: "old"}}}
|
||||
|
||||
t.Run("prior skip suppresses cleanup", func(t *testing.T) {
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return t.TempDir(), nil })
|
||||
plan := &skillSetupPlan{Mode: skillSetupModeMono, Source: "missing", Targets: []skillSetupTargetPlan{{Destination: "install"}, cleanup}}
|
||||
installed, skipped, err := executeSkillSetupPlan(plan, io.Discard, io.Discard)
|
||||
if err != nil || installed != 0 || skipped != 1 {
|
||||
t.Fatalf("cleanup after skip = (%d, %d, %v)", installed, skipped, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("home failure keeps generic copy", func(t *testing.T) {
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", failure })
|
||||
var stderr bytes.Buffer
|
||||
_, skipped, err := executeSkillSetupPlan(&skillSetupPlan{Mode: skillSetupModeMono, Targets: []skillSetupTargetPlan{cleanup}}, io.Discard, &stderr)
|
||||
if err != nil || skipped != 1 || !strings.Contains(stderr.String(), "保留通用 Skill 副本") {
|
||||
t.Fatalf("cleanup HOME failure = (%d, %v, %q)", skipped, err, stderr.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("backup failure is reported", func(t *testing.T) {
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return t.TempDir(), nil })
|
||||
testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) { return "", failure })
|
||||
var stderr bytes.Buffer
|
||||
_, skipped, err := executeSkillSetupPlan(&skillSetupPlan{Mode: skillSetupModeMono, Targets: []skillSetupTargetPlan{cleanup}}, io.Discard, &stderr)
|
||||
if err != nil || skipped != 1 || !strings.Contains(stderr.String(), "迁移失败") {
|
||||
t.Fatalf("cleanup backup failure = (%d, %v, %q)", skipped, err, stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupPlanDeduplicatesAndFailsClosed(t *testing.T) {
|
||||
dest := filepath.Join(t.TempDir(), "skills")
|
||||
if err := os.MkdirAll(filepath.Join(dest, "dws"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// "dws" is synthetic but makes the mutual-exclusion target and selected
|
||||
// same-name target overlap, pinning path deduplication in the plan itself.
|
||||
plan, err := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{dest}, []string{"dws"}, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(plan.Targets[0].Backups) != 1 || plan.Targets[0].Backups[0].Path != filepath.Join(dest, "dws") {
|
||||
t.Fatalf("deduplicated plan = %#v", plan)
|
||||
}
|
||||
|
||||
failure := errors.New("scan denied")
|
||||
monoDest := filepath.Join(t.TempDir(), "agent", "dws")
|
||||
if err := os.MkdirAll(filepath.Dir(monoDest), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, failure })
|
||||
if _, err := buildSkillSetupPlan(skillSetupModeMono, "source", []string{monoDest}, nil, false); err == nil || !strings.Contains(err.Error(), "\u68c0\u67e5\u5c06\u88ab\u66ff\u6362") {
|
||||
t.Fatalf("replacement stat error = %v", err)
|
||||
}
|
||||
|
||||
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
|
||||
testseam.Swap(t, &skillSetupReadDir, func(string) ([]os.DirEntry, error) { return nil, failure })
|
||||
if _, err := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{dest}, []string{"dingtalk-a"}, false); err == nil || !strings.Contains(err.Error(), "\u626b\u63cf\u8fc7\u671f") {
|
||||
t.Fatalf("stale scan error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupInstallWrappersFailOnPlanErrors(t *testing.T) {
|
||||
t.Run("multi mono-leftover stat failure", func(t *testing.T) {
|
||||
failure := errors.New("stat denied")
|
||||
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) {
|
||||
return nil, failure
|
||||
})
|
||||
|
||||
installed, skipped, err := installMultiSkillToHomes(
|
||||
"source",
|
||||
[]string{"dingtalk-a"},
|
||||
[]string{filepath.Join(t.TempDir(), "skills")},
|
||||
io.Discard,
|
||||
io.Discard,
|
||||
true,
|
||||
)
|
||||
if installed != 0 || skipped != 1 || !errors.Is(err, failure) {
|
||||
t.Fatalf("installMultiSkillToHomes = (%d, %d, %v), want (0, 1, %v)", installed, skipped, err, failure)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("mono multi-leftover scan failure", func(t *testing.T) {
|
||||
failure := errors.New("scan denied")
|
||||
testseam.Swap(t, &skillSetupReadDir, func(string) ([]os.DirEntry, error) {
|
||||
return nil, failure
|
||||
})
|
||||
|
||||
installed, skipped, err := installSkillToHomes(
|
||||
"source",
|
||||
[]string{filepath.Join(t.TempDir(), "skills", "dws")},
|
||||
io.Discard,
|
||||
io.Discard,
|
||||
)
|
||||
if installed != 0 || skipped != 1 || !errors.Is(err, failure) {
|
||||
t.Fatalf("installSkillToHomes = (%d, %d, %v), want (0, 1, %v)", installed, skipped, err, failure)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupMergedEventPlanEdges(t *testing.T) {
|
||||
t.Run("legacy shared stat failure", func(t *testing.T) {
|
||||
dest := filepath.Join(t.TempDir(), "skills")
|
||||
failure := errors.New("legacy stat denied")
|
||||
testseam.Swap(t, &skillSetupStat, func(path string) (os.FileInfo, error) {
|
||||
if path == filepath.Join(dest, legacySharedSkill) {
|
||||
return nil, failure
|
||||
}
|
||||
return nil, os.ErrNotExist
|
||||
})
|
||||
|
||||
_, err := buildSkillSetupPlan(
|
||||
skillSetupModeMulti,
|
||||
"source",
|
||||
[]string{dest},
|
||||
[]string{multiSharedSkill},
|
||||
true,
|
||||
)
|
||||
if !errors.Is(err, failure) {
|
||||
t.Fatalf("legacy shared stat error = %v, want %v", err, failure)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("migration plan retains unrelated backups", func(t *testing.T) {
|
||||
dest := filepath.Join(t.TempDir(), "skills")
|
||||
unrelated := filepath.Join(dest, "dws")
|
||||
plan := &skillSetupPlan{Targets: []skillSetupTargetPlan{
|
||||
{
|
||||
Destination: dest,
|
||||
Backups: []skillSetupBackup{
|
||||
{Path: filepath.Join(dest, multiEventSkill)},
|
||||
{Path: filepath.Join(dest, multiMiscSkill)},
|
||||
{Path: unrelated},
|
||||
},
|
||||
},
|
||||
}}
|
||||
|
||||
configureEventMiscMigrationPlan(plan, []string{dest}, true)
|
||||
if len(plan.Targets[0].Backups) != 1 || plan.Targets[0].Backups[0].Path != unrelated {
|
||||
t.Fatalf("migration backups = %#v, want only %s", plan.Targets[0].Backups, unrelated)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no migration targets delegates filtered install", func(t *testing.T) {
|
||||
called := false
|
||||
testseam.Swap(t, &skillSetupInstallMulti, func(_ string, _ []string, _ []string, _ io.Writer, _ io.Writer, filtered bool) (int, int, error) {
|
||||
called = true
|
||||
if !filtered {
|
||||
t.Fatal("filtered flag was not forwarded")
|
||||
}
|
||||
return 1, 2, nil
|
||||
})
|
||||
|
||||
installed, skipped, err := installMultiSkillsWithEventMigration(
|
||||
"source", []string{"dingtalk-a"}, []string{"dest"}, nil, true, io.Discard, io.Discard,
|
||||
)
|
||||
if err != nil || installed != 1 || skipped != 2 || !called {
|
||||
t.Fatalf("delegated install = (%d, %d, %v), called=%v", installed, skipped, err, called)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("migration cleanup scan failure", func(t *testing.T) {
|
||||
failure := errors.New("cleanup stat denied")
|
||||
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) {
|
||||
return nil, failure
|
||||
})
|
||||
dest := filepath.Join(t.TempDir(), "skills")
|
||||
|
||||
installed, skipped, err := installMultiSkillsWithEventMigration(
|
||||
"source",
|
||||
[]string{multiEventSkill, multiSharedSkill},
|
||||
[]string{dest},
|
||||
[]string{dest},
|
||||
true,
|
||||
io.Discard,
|
||||
io.Discard,
|
||||
)
|
||||
if installed != 0 || skipped != 2 || !errors.Is(err, failure) {
|
||||
t.Fatalf("cleanup scan failure = (%d, %d, %v), want (0, 2, %v)", installed, skipped, err, failure)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupEmptyCleanupPlansAreNoOps(t *testing.T) {
|
||||
dest := t.TempDir()
|
||||
var out, errOut bytes.Buffer
|
||||
if err := cleanupMutualExclusion(dest, skillSetupModeMulti, &out, &errOut); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := removeStaleMultiSkills(dest, []string{"dingtalk-a"}, &out, &errOut); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Len() != 0 || errOut.Len() != 0 {
|
||||
t.Fatalf("empty cleanup output = %q / %q", out.String(), errOut.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupSameNameBackupFailureSkipsTarget(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
dest := filepath.Join(home, ".agents", "skills")
|
||||
plan := &skillSetupPlan{
|
||||
Mode: skillSetupModeMulti,
|
||||
Source: "source",
|
||||
MultiSkillNames: []string{"dingtalk-a"},
|
||||
Targets: []skillSetupTargetPlan{{
|
||||
Destination: dest,
|
||||
Backups: []skillSetupBackup{{
|
||||
Path: filepath.Join(dest, "dingtalk-a"),
|
||||
Reason: skillSetupBackupReplace,
|
||||
}},
|
||||
}},
|
||||
}
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
|
||||
testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) {
|
||||
return "", errors.New("backup denied")
|
||||
})
|
||||
copyCalls := 0
|
||||
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error { copyCalls++; return nil })
|
||||
var out, errOut bytes.Buffer
|
||||
installed, skipped, err := executeSkillSetupPlan(plan, &out, &errOut)
|
||||
if err != nil || installed != 0 || skipped != 1 || copyCalls != 1 {
|
||||
t.Fatalf("same-name failure = (%d, %d, %v), copy=%d", installed, skipped, err, copyCalls)
|
||||
}
|
||||
if !strings.Contains(errOut.String(), "Skill 备份失败,已执行回滚,跳过整个 Agent 目标") {
|
||||
t.Fatalf("same-name warning = %q", errOut.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillprovenance"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupPersistsOfficialSnapshot(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
testseam.Swap(t, &skillSetupResolveMode, func(mode string, _ bool, _ io.Writer) (string, error) { return mode, nil })
|
||||
testseam.Swap(t, &skillSetupResolveSource, func(string, string) (string, func(), error) { return "source", func() {}, nil })
|
||||
testseam.Swap(t, &skillSetupResolveTargets, func(string, string) ([]string, error) { return []string{filepath.Join(home, "skills")}, nil })
|
||||
testseam.Swap(t, &skillSetupListMulti, func(string) ([]string, error) {
|
||||
return []string{"dingtalk-a", "dingtalk-b", "dingtalk-shared"}, nil
|
||||
})
|
||||
testseam.Swap(t, &skillSetupFilterMulti, filterMultiSkillNames)
|
||||
testseam.Swap(t, &skillSetupBuildProvenance, func(name, _ string, version, source string) (skillprovenance.Record, error) {
|
||||
return skillprovenance.Record{Name: name, Version: version, Source: source, Digest: "sha256:test", DigestScope: skillprovenance.DigestScope}, nil
|
||||
})
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
|
||||
testseam.Swap(t, &skillSetupReadState, func(string) (*skillstate.State, bool, error) {
|
||||
return &skillstate.State{ManagedSkills: []skillprovenance.Record{{Name: "dingtalk-existing"}}}, true, nil
|
||||
})
|
||||
testseam.Swap(t, &skillSetupExecutePlan, func(plan *skillSetupPlan, _ io.Writer, _ io.Writer) (int, int, error) {
|
||||
if plan.Mode == skillSetupModeMono {
|
||||
return 1, 0, nil
|
||||
}
|
||||
return 2, 0, nil
|
||||
})
|
||||
testseam.Swap(t, &skillSetupNow, func() time.Time {
|
||||
return time.Date(2026, 8, 10, 1, 2, 3, 0, time.UTC)
|
||||
})
|
||||
|
||||
var saved skillstate.State
|
||||
testseam.Swap(t, &skillSetupWriteState, func(_ string, state skillstate.State) error {
|
||||
saved = state
|
||||
return nil
|
||||
})
|
||||
cmd := skillSetupCoverageCommand(t, skillSetupModeMulti, true)
|
||||
if err := cmd.Flags().Set("skill", "a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(saved.OfficialSkills, []string{"dingtalk-a", "dingtalk-b", "dingtalk-shared"}) ||
|
||||
!reflect.DeepEqual(saved.UpdatedSkills, []string{"dingtalk-shared", "dingtalk-a"}) ||
|
||||
!reflect.DeepEqual(skillprovenance.Names(saved.ManagedSkills), map[string]bool{"dingtalk-a": true, "dingtalk-existing": true, "dingtalk-shared": true}) {
|
||||
t.Fatalf("saved = %#v", saved)
|
||||
}
|
||||
|
||||
testseam.Swap(t, &skillSetupWriteState, func(string, skillstate.State) error { return errors.New("denied") })
|
||||
cmd = skillSetupCoverageCommand(t, skillSetupModeMulti, true)
|
||||
if err := cmd.RunE(cmd, nil); err == nil || !strings.Contains(err.Error(), "信息快照失败") {
|
||||
t.Fatalf("write-state error = %v", err)
|
||||
}
|
||||
|
||||
testseam.Swap(t, &skillSetupRemoveState, func(string) error { return errors.New("denied") })
|
||||
cmd = skillSetupCoverageCommand(t, skillSetupModeMono, true)
|
||||
if err := cmd.RunE(cmd, nil); err == nil || !strings.Contains(err.Error(), "清理 multi") {
|
||||
t.Fatalf("remove-state error = %v", err)
|
||||
}
|
||||
|
||||
testseam.Swap(t, &skillSetupRemoveState, func(string) error { return nil })
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", errors.New("no home") })
|
||||
cmd = skillSetupCoverageCommand(t, skillSetupModeMono, true)
|
||||
if err := cmd.RunE(cmd, nil); err == nil || !strings.Contains(err.Error(), "无法解析 HOME") {
|
||||
t.Fatalf("home error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupFilteredUnreadableStateStopsBeforeInstall(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
testseam.Swap(t, &skillSetupResolveMode, func(mode string, _ bool, _ io.Writer) (string, error) { return mode, nil })
|
||||
testseam.Swap(t, &skillSetupResolveSource, func(string, string) (string, func(), error) { return "source", func() {}, nil })
|
||||
testseam.Swap(t, &skillSetupResolveTargets, func(string, string) ([]string, error) { return []string{filepath.Join(home, "skills")}, nil })
|
||||
testseam.Swap(t, &skillSetupListMulti, func(string) ([]string, error) { return []string{"dingtalk-a", "dingtalk-shared"}, nil })
|
||||
testseam.Swap(t, &skillSetupFilterMulti, filterMultiSkillNames)
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
|
||||
testseam.Swap(t, &skillSetupBuildProvenance, func(name, _ string, version, source string) (skillprovenance.Record, error) {
|
||||
return skillprovenance.Record{Name: name, Version: version, Source: source, Digest: "sha256:test", DigestScope: skillprovenance.DigestScope}, nil
|
||||
})
|
||||
stateErr := errors.New("state denied")
|
||||
testseam.Swap(t, &skillSetupReadState, func(string) (*skillstate.State, bool, error) { return nil, false, stateErr })
|
||||
executed := 0
|
||||
testseam.Swap(t, &skillSetupExecutePlan, func(*skillSetupPlan, io.Writer, io.Writer) (int, int, error) {
|
||||
executed++
|
||||
return 1, 0, nil
|
||||
})
|
||||
|
||||
cmd := skillSetupCoverageCommand(t, skillSetupModeMulti, true)
|
||||
if err := cmd.Flags().Set("skill", "a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := cmd.RunE(cmd, nil)
|
||||
if !errors.Is(err, stateErr) || executed != 0 {
|
||||
t.Fatalf("filtered setup = err %v, executed %d", err, executed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupProvenancePreflightFailures(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
filtered bool
|
||||
buildError error
|
||||
homeError error
|
||||
}{
|
||||
{name: "digest", buildError: errors.New("digest denied")},
|
||||
{name: "filtered home", filtered: true, homeError: errors.New("home denied")},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
testseam.Swap(t, &skillSetupResolveMode, func(mode string, _ bool, _ io.Writer) (string, error) { return mode, nil })
|
||||
testseam.Swap(t, &skillSetupResolveSource, func(string, string) (string, func(), error) { return "source", func() {}, nil })
|
||||
testseam.Swap(t, &skillSetupResolveTargets, func(string, string) ([]string, error) { return []string{filepath.Join(home, "skills")}, nil })
|
||||
testseam.Swap(t, &skillSetupListMulti, func(string) ([]string, error) { return []string{"dingtalk-a", "dingtalk-shared"}, nil })
|
||||
testseam.Swap(t, &skillSetupFilterMulti, filterMultiSkillNames)
|
||||
testseam.Swap(t, &skillSetupBuildProvenance, func(name, _ string, version, source string) (skillprovenance.Record, error) {
|
||||
if tc.buildError != nil {
|
||||
return skillprovenance.Record{}, tc.buildError
|
||||
}
|
||||
return skillprovenance.Record{Name: name, Version: version, Source: source, Digest: "sha256:test", DigestScope: skillprovenance.DigestScope}, nil
|
||||
})
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) {
|
||||
if tc.homeError != nil {
|
||||
return "", tc.homeError
|
||||
}
|
||||
return home, nil
|
||||
})
|
||||
executed := 0
|
||||
testseam.Swap(t, &skillSetupExecutePlan, func(*skillSetupPlan, io.Writer, io.Writer) (int, int, error) {
|
||||
executed++
|
||||
return 1, 0, nil
|
||||
})
|
||||
|
||||
cmd := skillSetupCoverageCommand(t, skillSetupModeMulti, true)
|
||||
if tc.filtered {
|
||||
if err := cmd.Flags().Set("skill", "a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
err := cmd.RunE(cmd, nil)
|
||||
if err == nil || executed != 0 {
|
||||
t.Fatalf("preflight = err %v, executed %d", err, executed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupPartialInstallDoesNotWriteState(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
testseam.Swap(t, &skillSetupResolveMode, func(mode string, _ bool, _ io.Writer) (string, error) { return mode, nil })
|
||||
testseam.Swap(t, &skillSetupResolveSource, func(string, string) (string, func(), error) { return "source", func() {}, nil })
|
||||
testseam.Swap(t, &skillSetupResolveTargets, func(string, string) ([]string, error) {
|
||||
return []string{filepath.Join(home, "skills")}, nil
|
||||
})
|
||||
testseam.Swap(t, &skillSetupListMulti, func(string) ([]string, error) {
|
||||
return []string{"dingtalk-a", "dingtalk-b", "dingtalk-shared"}, nil
|
||||
})
|
||||
testseam.Swap(t, &skillSetupFilterMulti, filterMultiSkillNames)
|
||||
testseam.Swap(t, &skillSetupBuildProvenance, func(name, _ string, version, source string) (skillprovenance.Record, error) {
|
||||
return skillprovenance.Record{Name: name, Version: version, Source: source, Digest: "sha256:test", DigestScope: skillprovenance.DigestScope}, nil
|
||||
})
|
||||
testseam.Swap(t, &skillSetupExecutePlan, func(*skillSetupPlan, io.Writer, io.Writer) (int, int, error) {
|
||||
return 2, 1, nil
|
||||
})
|
||||
|
||||
writes := 0
|
||||
testseam.Swap(t, &skillSetupWriteState, func(string, skillstate.State) error {
|
||||
writes++
|
||||
return nil
|
||||
})
|
||||
cmd := skillSetupCoverageCommand(t, skillSetupModeMulti, true)
|
||||
err := cmd.RunE(cmd, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "Skill 安装不完整") || !strings.Contains(err.Error(), "skipped=1") {
|
||||
t.Fatalf("partial setup error = %v", err)
|
||||
}
|
||||
if writes != 0 {
|
||||
t.Fatalf("partial setup wrote %d complete state snapshot(s)", writes)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,10 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillprovenance"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func TestSkillSetupCommandRegistered(t *testing.T) {
|
||||
@@ -25,6 +29,189 @@ func TestSkillSetupCommandRegistered(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageSkillSetupExamplesDoNotBypassConfirmation guards
|
||||
// the P1 review finding: copyable examples must preview or enter the normal
|
||||
// confirmation path, never carry the scripting-only confirmation bypass.
|
||||
func TestCrossPlatformCoverageSkillSetupExamplesDoNotBypassConfirmation(t *testing.T) {
|
||||
cmd := newSkillSetupCommand()
|
||||
var examples []string
|
||||
for _, line := range strings.Split(cmd.Example, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
examples = append(examples, line)
|
||||
if strings.Contains(line, "--yes") {
|
||||
t.Fatalf("skill setup example bypasses confirmation: %q", line)
|
||||
}
|
||||
}
|
||||
if len(examples) != 2 || !strings.Contains(examples[0], "--dry-run") || strings.Contains(examples[1], "--dry-run") {
|
||||
t.Fatalf("examples must show preview then interactive confirmation: %v", examples)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupHelpDescribesFullUpgradeRefresh(t *testing.T) {
|
||||
help := newSkillSetupCommand().Long
|
||||
for _, want := range []string{"每次 dws upgrade", "全量覆盖预制 skill", "本地删除", "会在升级时恢复"} {
|
||||
if !strings.Contains(help, want) {
|
||||
t.Fatalf("skill setup help missing full-refresh contract %q:\n%s", want, help)
|
||||
}
|
||||
}
|
||||
for _, stale := range []string{"跳过本地已删除", "--force 恢复全量"} {
|
||||
if strings.Contains(help, stale) {
|
||||
t.Fatalf("skill setup help still advertises retired incremental behavior %q:\n%s", stale, help)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageSkillSetupDeclinedConfirmationNeverRemoves verifies
|
||||
// the destructive half of the setup contract: when the user declines the
|
||||
// confirmation prompt, nothing is installed and nothing is removed (neither
|
||||
// the opposite-mode leftovers nor stale skills). Confirming must then run the
|
||||
// exact cleanup previewed earlier: leftovers are backed up to
|
||||
// ~/.dws/skill-backups/ before they disappear.
|
||||
func TestCrossPlatformCoverageSkillSetupDeclinedConfirmationNeverRemoves(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
setTestHome(t, home)
|
||||
|
||||
multiSrc := writeMultiSkillSource(t, []string{"dingtalk-aitable", "dingtalk-calendar"})
|
||||
agentHome := filepath.Join(home, ".claude", "skills")
|
||||
|
||||
// Opposite-mode leftover (mono dws/) plus a stale multi skill the full
|
||||
// install would clean; both must survive a declined confirmation.
|
||||
for _, leftover := range []string{filepath.Join(agentHome, "dws"), filepath.Join(agentHome, "dingtalk-stale")} {
|
||||
if err := os.MkdirAll(leftover, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(leftover, "SKILL.md"), []byte("keep-me"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
useManagedSkillNames(t, "dingtalk-stale")
|
||||
|
||||
oldConfirm := skillSetupConfirmPlan
|
||||
t.Cleanup(func() { skillSetupConfirmPlan = oldConfirm })
|
||||
|
||||
// Declined confirmation: nothing may change on disk.
|
||||
skillSetupConfirmPlan = func(io.Writer, *skillSetupPlan) (bool, error) { return false, nil }
|
||||
cmd := newSkillSetupCommand()
|
||||
var out, errOut bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&errOut)
|
||||
cmd.SetArgs([]string{"--mode", "multi", "--target", "claude", "--source", multiSrc})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("declined setup should succeed as a no-op: %v (%s)", err, errOut.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), "已取消") {
|
||||
t.Fatalf("expected cancellation notice, got %q", out.String())
|
||||
}
|
||||
for _, survivor := range []string{filepath.Join(agentHome, "dws"), filepath.Join(agentHome, "dingtalk-stale")} {
|
||||
if _, err := os.Stat(filepath.Join(survivor, "SKILL.md")); err != nil {
|
||||
t.Fatalf("declined confirmation removed %s: %v", survivor, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(agentHome, "dingtalk-aitable")); !os.IsNotExist(err) {
|
||||
t.Fatalf("declined confirmation must not install either: %v", err)
|
||||
}
|
||||
|
||||
// Confirmed: the previewed victims are backed up + removed, bundle skills land.
|
||||
skillSetupConfirmPlan = func(io.Writer, *skillSetupPlan) (bool, error) { return true, nil }
|
||||
out.Reset()
|
||||
errOut.Reset()
|
||||
cmd = newSkillSetupCommand()
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&errOut)
|
||||
cmd.SetArgs([]string{"--mode", "multi", "--target", "claude", "--source", multiSrc})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("confirmed setup failed: %v (%s)", err, errOut.String())
|
||||
}
|
||||
for _, gone := range []string{filepath.Join(agentHome, "dws"), filepath.Join(agentHome, "dingtalk-stale")} {
|
||||
if _, err := os.Stat(gone); !os.IsNotExist(err) {
|
||||
t.Fatalf("confirmed setup should remove %s (stat err=%v)", gone, err)
|
||||
}
|
||||
}
|
||||
for _, n := range []string{"dingtalk-aitable", "dingtalk-calendar"} {
|
||||
if _, err := os.Stat(filepath.Join(agentHome, n, "SKILL.md")); err != nil {
|
||||
t.Fatalf("confirmed setup missing %s: %v", n, err)
|
||||
}
|
||||
}
|
||||
// Every removal went through the reversible backup path, not a hard delete.
|
||||
backupRoot := filepath.Join(home, ".dws", "skill-backups")
|
||||
entries, err := os.ReadDir(backupRoot)
|
||||
if err != nil || len(entries) == 0 {
|
||||
t.Fatalf("confirmed setup must preserve victims under %s (entries=%v, err=%v)", backupRoot, entries, err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "已备份并清理对面模式残留") || !strings.Contains(out.String(), "已备份并清理过期 skill") {
|
||||
t.Fatalf("expected backup-and-remove log lines, got %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageSkillSetupNonInteractiveRequiresYes pins the real
|
||||
// non-TTY safety boundary: an explicit mode alone is not consent to move
|
||||
// directories. The same command with --yes performs the previewed backup and
|
||||
// installation.
|
||||
func TestCrossPlatformCoverageSkillSetupNonInteractiveRequiresYes(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
setTestHome(t, home)
|
||||
oldInteractive := skillSetupInteractive
|
||||
skillSetupInteractive = func() bool { return false }
|
||||
t.Cleanup(func() { skillSetupInteractive = oldInteractive })
|
||||
|
||||
multiSrc := writeMultiSkillSource(t, []string{"dingtalk-aitable"})
|
||||
agentHome := filepath.Join(home, ".claude", "skills")
|
||||
mono := filepath.Join(agentHome, "dws")
|
||||
if err := os.MkdirAll(mono, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(mono, "SKILL.md"), []byte("keep-me"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
run := func(extra ...string) error {
|
||||
cmd := newSkillSetupCommand()
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
args := []string{"--mode", "multi", "--target", "claude", "--source", multiSrc}
|
||||
cmd.SetArgs(append(args, extra...))
|
||||
return cmd.Execute()
|
||||
}
|
||||
|
||||
if err := run(); err == nil || !strings.Contains(err.Error(), "--yes") {
|
||||
t.Fatalf("non-interactive setup error = %v, want explicit --yes requirement", err)
|
||||
}
|
||||
if data, err := os.ReadFile(filepath.Join(mono, "SKILL.md")); err != nil || string(data) != "keep-me" {
|
||||
t.Fatalf("unconfirmed setup changed mono (data=%q, err=%v)", string(data), err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(agentHome, "dingtalk-aitable")); !os.IsNotExist(err) {
|
||||
t.Fatalf("unconfirmed setup installed multi, stat err=%v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, ".dws", "skill-backups")); !os.IsNotExist(err) {
|
||||
t.Fatalf("unconfirmed setup created backup state, stat err=%v", err)
|
||||
}
|
||||
|
||||
if err := run("--yes"); err != nil {
|
||||
t.Fatalf("explicitly confirmed setup failed: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(mono); !os.IsNotExist(err) {
|
||||
t.Fatalf("confirmed setup kept mono, stat err=%v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(agentHome, "dingtalk-aitable", "SKILL.md")); err != nil {
|
||||
t.Fatalf("confirmed setup did not install multi: %v", err)
|
||||
}
|
||||
backupFound := false
|
||||
_ = filepath.Walk(filepath.Join(home, ".dws", "skill-backups"), func(path string, info os.FileInfo, walkErr error) error {
|
||||
if walkErr == nil && info != nil && !info.IsDir() && info.Name() == "SKILL.md" {
|
||||
if data, readErr := os.ReadFile(path); readErr == nil && string(data) == "keep-me" {
|
||||
backupFound = true
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if !backupFound {
|
||||
t.Fatal("confirmed setup did not back up mono")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSkillSetupModeFlagDirect(t *testing.T) {
|
||||
got, err := resolveSkillSetupMode("mono", true, &bytes.Buffer{})
|
||||
if err != nil || got != skillSetupModeMono {
|
||||
@@ -39,14 +226,14 @@ func TestResolveSkillSetupModeFlagDirect(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSkillSetupModeNonInteractiveDefaultsMono(t *testing.T) {
|
||||
func TestResolveSkillSetupModeNonInteractiveDefaultsMulti(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
got, err := resolveSkillSetupMode("", true, &buf)
|
||||
if err != nil || got != skillSetupModeMono {
|
||||
t.Fatalf("non-interactive empty mode should default to mono, got %q err=%v", got, err)
|
||||
if err != nil || got != skillSetupModeMulti {
|
||||
t.Fatalf("non-interactive empty mode should default to multi, got %q err=%v", got, err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "mono") {
|
||||
t.Fatalf("expected output to mention mono fallback, got %q", buf.String())
|
||||
if !strings.Contains(buf.String(), "multi") {
|
||||
t.Fatalf("expected output to mention multi fallback, got %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,7 +330,51 @@ func TestResolveSkillSetupTargetsMultiOmitsDwsTail(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallSkillToHomesEndToEnd(t *testing.T) {
|
||||
func TestCrossPlatformCoverageResolveSkillSetupTargetsPrefersSpecificAgentRoot(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
|
||||
testseam.Swap(t, &skillSetupAgentHomes, []string{".agents/skills", ".codex/skills"})
|
||||
if err := os.MkdirAll(filepath.Join(home, ".codex"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := resolveSkillSetupTargets("all", skillSetupModeMulti)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := filepath.Join(home, ".codex", "skills")
|
||||
if len(got) != 1 || filepath.Clean(got[0]) != filepath.Clean(want) {
|
||||
t.Fatalf("targets = %v, want [%s]", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveSkillSetupTargetsDetectsZCode(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
|
||||
if err := os.MkdirAll(filepath.Join(home, ".zcode"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := resolveSkillSetupTargets("all", skillSetupModeMulti)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := filepath.Join(home, ".zcode", "skills")
|
||||
if len(got) != 1 || filepath.Clean(got[0]) != filepath.Clean(want) {
|
||||
t.Fatalf("targets = %v, want [%s]", got, want)
|
||||
}
|
||||
|
||||
explicit, err := resolveSkillSetupTargets("zcode", skillSetupModeMono)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantMono := filepath.Join(want, "dws")
|
||||
if len(explicit) != 1 || filepath.Clean(explicit[0]) != filepath.Clean(wantMono) {
|
||||
t.Fatalf("explicit zcode targets = %v, want [%s]", explicit, wantMono)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageInstallSkillToHomesEndToEnd(t *testing.T) {
|
||||
src := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("# test"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -205,7 +436,7 @@ func writeMultiSkillSource(t *testing.T, names []string) string {
|
||||
return root
|
||||
}
|
||||
|
||||
func TestInstallMultiSkillToHomes(t *testing.T) {
|
||||
func TestCrossPlatformCoverageInstallMultiSkillToHomes(t *testing.T) {
|
||||
names := []string{"dingtalk-aitable", "dingtalk-calendar", "dingtalk-doc"}
|
||||
src := writeMultiSkillSource(t, names)
|
||||
|
||||
@@ -221,7 +452,7 @@ func TestInstallMultiSkillToHomes(t *testing.T) {
|
||||
dst2 := filepath.Join(t.TempDir(), ".cursor", "skills")
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
installed, skipped, err := installMultiSkillToHomes(src, got, []string{dst1, dst2}, &stdout, &stderr)
|
||||
installed, skipped, err := installMultiSkillToHomes(src, got, []string{dst1, dst2}, &stdout, &stderr, false)
|
||||
if err != nil {
|
||||
t.Fatalf("installMultiSkillToHomes err: %v", err)
|
||||
}
|
||||
@@ -245,12 +476,14 @@ func TestInstallMultiSkillToHomes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillSetupMutualExclusion(t *testing.T) {
|
||||
func TestCrossPlatformCoverageSkillSetupMutualExclusion(t *testing.T) {
|
||||
names := []string{"dingtalk-aitable", "dingtalk-calendar"}
|
||||
src := writeMultiSkillSource(t, names)
|
||||
|
||||
// Simulate a pre-existing mono install under <agent-home>/dws/
|
||||
agentHome := filepath.Join(t.TempDir(), ".claude", "skills")
|
||||
homeRoot := t.TempDir()
|
||||
setTestHome(t, homeRoot)
|
||||
agentHome := filepath.Join(homeRoot, ".claude", "skills")
|
||||
monoLeftover := filepath.Join(agentHome, "dws")
|
||||
if err := os.MkdirAll(filepath.Join(monoLeftover, "references"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -265,13 +498,16 @@ func TestSkillSetupMutualExclusion(t *testing.T) {
|
||||
}
|
||||
|
||||
// Confirm mutualExclusionVictims sees the leftover
|
||||
victims := mutualExclusionVictims(agentHome, skillSetupModeMulti)
|
||||
victims, vErr := mutualExclusionVictims(agentHome, skillSetupModeMulti)
|
||||
if vErr != nil {
|
||||
t.Fatalf("mutualExclusionVictims err: %v", vErr)
|
||||
}
|
||||
if len(victims) != 1 || victims[0] != monoLeftover {
|
||||
t.Fatalf("expected victims=[%s], got %v", monoLeftover, victims)
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
installed, skipped, err := installMultiSkillToHomes(src, names, []string{agentHome}, &stdout, &stderr)
|
||||
installed, skipped, err := installMultiSkillToHomes(src, names, []string{agentHome}, &stdout, &stderr, false)
|
||||
if err != nil {
|
||||
t.Fatalf("install err: %v (stderr=%s)", err, stderr.String())
|
||||
}
|
||||
@@ -290,7 +526,7 @@ func TestSkillSetupMutualExclusion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
// the cleanup line should appear in stdout (best-effort observability)
|
||||
if !strings.Contains(stdout.String(), "已清理对面模式残留") {
|
||||
if !strings.Contains(stdout.String(), "已备份并清理对面模式残留") {
|
||||
t.Fatalf("expected cleanup log line, got stdout=%q", stdout.String())
|
||||
}
|
||||
|
||||
@@ -318,11 +554,48 @@ func TestSkillSetupMutualExclusion(t *testing.T) {
|
||||
if _, err := os.Stat(filepath.Join(monoDest, "SKILL.md")); err != nil {
|
||||
t.Fatalf("mono SKILL.md missing: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "已清理对面模式残留") {
|
||||
if !strings.Contains(stdout.String(), "已备份并清理对面模式残留") {
|
||||
t.Fatalf("expected cleanup log line on mono install, got stdout=%q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupMonoPreservesUnregisteredDingtalkSkill(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
setTestHome(t, home)
|
||||
base := filepath.Join(home, ".agents", "skills")
|
||||
managed := filepath.Join(base, "dingtalk-managed-old")
|
||||
legacyOfficial := filepath.Join(base, "dingtalk-aitable")
|
||||
custom := filepath.Join(base, "dingtalk-custom")
|
||||
for _, dir := range []string{managed, legacyOfficial, custom} {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(filepath.Base(dir)), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
useManagedSkillNames(t, filepath.Base(managed))
|
||||
|
||||
monoSrc := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(monoSrc, "SKILL.md"), []byte("mono"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out, errOut bytes.Buffer
|
||||
installed, skipped, err := installSkillToHomes(monoSrc, []string{filepath.Join(base, "dws")}, &out, &errOut)
|
||||
if err != nil || installed != 1 || skipped != 0 {
|
||||
t.Fatalf("mono install = (%d, %d, %v), stderr=%s", installed, skipped, err, errOut.String())
|
||||
}
|
||||
if _, err := os.Stat(managed); !os.IsNotExist(err) {
|
||||
t.Fatalf("centrally managed DWS multi Skill must be removed during mono switch: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(legacyOfficial); !os.IsNotExist(err) {
|
||||
t.Fatalf("pre-state official multi Skill must be removed during mono switch: %v", err)
|
||||
}
|
||||
if got, err := os.ReadFile(filepath.Join(custom, "SKILL.md")); err != nil || string(got) != "dingtalk-custom" {
|
||||
t.Fatalf("unregistered market/user dingtalk-* Skill changed: data=%q err=%v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSkillSourceCandidatesIncludesUserCache verifies that the user-level
|
||||
// cache populated by install.sh / install.ps1 / npm install.js is part of the
|
||||
// fallback candidate list, so `dws skill setup` can find a source on a fresh
|
||||
@@ -464,10 +737,11 @@ func TestFilterMultiSkillNames(t *testing.T) {
|
||||
// TestSkillSetupMultiAdditivePreservesSiblings verifies the key UX promise of
|
||||
// `dws skill setup --mode multi -s aitable`: installing a subset must NOT
|
||||
// touch already-installed dingtalk-* siblings (additive semantics).
|
||||
func TestSkillSetupMultiAdditivePreservesSiblings(t *testing.T) {
|
||||
func TestCrossPlatformCoverageSkillSetupMultiAdditivePreservesSiblings(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{
|
||||
"dingtalk-aitable", "dingtalk-calendar", "dingtalk-doc",
|
||||
})
|
||||
setTestHome(t, t.TempDir())
|
||||
agentHome := filepath.Join(t.TempDir(), ".claude", "skills")
|
||||
|
||||
// Pretend the user already installed two dingtalk-* skills earlier.
|
||||
@@ -493,7 +767,7 @@ func TestSkillSetupMultiAdditivePreservesSiblings(t *testing.T) {
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
installed, skipped, err := installMultiSkillToHomes(src, filtered, []string{agentHome}, &stdout, &stderr)
|
||||
installed, skipped, err := installMultiSkillToHomes(src, filtered, []string{agentHome}, &stdout, &stderr, true)
|
||||
if err != nil {
|
||||
t.Fatalf("install err: %v (stderr=%s)", err, stderr.String())
|
||||
}
|
||||
@@ -561,6 +835,135 @@ func TestResolveSkillSetupSourceMultiFinds(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageSkillSetupMultiFullInstallCleansStale verifies that a full (unfiltered)
|
||||
// multi install removes stale dingtalk-* / dws-shared directories that are no
|
||||
// longer part of the bundle, matching install.sh / install.js / upgrade paths.
|
||||
// The additive counterpart (filtered install) is covered by
|
||||
// TestCrossPlatformCoverageSkillSetupMultiAdditivePreservesSiblings.
|
||||
func TestCrossPlatformCoverageSkillSetupMultiFullInstallCleansStale(t *testing.T) {
|
||||
names := []string{"dingtalk-aitable"}
|
||||
src := writeMultiSkillSource(t, names)
|
||||
|
||||
homeRoot := t.TempDir()
|
||||
setTestHome(t, homeRoot)
|
||||
agentHome := filepath.Join(homeRoot, ".claude", "skills")
|
||||
// Stale multi skills absent from the bundle, plus a non-DWS dir that must survive.
|
||||
for _, n := range []string{"dingtalk-stale", "dws-shared", "other-skill"} {
|
||||
dir := filepath.Join(agentHome, n)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("OLD "+n), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
useManagedSkillNames(t, "dingtalk-stale")
|
||||
custom := filepath.Join(agentHome, "dingtalk-custom")
|
||||
if err := os.MkdirAll(custom, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(custom, "SKILL.md"), []byte("market skill"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
installed, skipped, err := installMultiSkillToHomes(src, names, []string{agentHome}, &stdout, &stderr, false)
|
||||
if err != nil {
|
||||
t.Fatalf("install err: %v (stderr=%s)", err, stderr.String())
|
||||
}
|
||||
if installed != 1 || skipped != 0 {
|
||||
t.Fatalf("expected installed=1 skipped=0, got %d/%d", installed, skipped)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(agentHome, "dingtalk-aitable", "SKILL.md")); err != nil {
|
||||
t.Errorf("missing installed skill: %v", err)
|
||||
}
|
||||
for _, stale := range []string{"dingtalk-stale", "dws-shared"} {
|
||||
if _, err := os.Stat(filepath.Join(agentHome, stale)); !os.IsNotExist(err) {
|
||||
t.Errorf("stale %q should be removed by a full multi install (stat err=%v)", stale, err)
|
||||
}
|
||||
}
|
||||
body, err := os.ReadFile(filepath.Join(agentHome, "other-skill", "SKILL.md"))
|
||||
if err != nil || !strings.HasPrefix(string(body), "OLD ") {
|
||||
t.Errorf("non-DWS dir must be preserved (body=%q, err=%v)", string(body), err)
|
||||
}
|
||||
if body, err := os.ReadFile(filepath.Join(custom, "SKILL.md")); err != nil || string(body) != "market skill" {
|
||||
t.Errorf("unregistered market/user dingtalk-* dir must survive (body=%q, err=%v)", string(body), err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "已备份并清理过期 skill") {
|
||||
t.Errorf("expected stale cleanup log line, got stdout=%q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSkillSetupMutualExclusionScanWarning verifies that a victim-scan failure
|
||||
// surfaces as an errOut warning instead of silently skipping cleanup.
|
||||
func TestCrossPlatformCoverageSkillSetupMutualExclusionScanWarning(t *testing.T) {
|
||||
oldReadDir := skillSetupReadDir
|
||||
t.Cleanup(func() { skillSetupReadDir = oldReadDir })
|
||||
scanFail := errors.New("scan boom")
|
||||
skillSetupReadDir = func(string) ([]os.DirEntry, error) { return nil, scanFail }
|
||||
|
||||
monoDest := filepath.Join(t.TempDir(), "agent", "dws")
|
||||
if _, err := mutualExclusionVictims(monoDest, skillSetupModeMono); err == nil {
|
||||
t.Fatal("scan failure should surface as an error")
|
||||
}
|
||||
|
||||
var out, errOut bytes.Buffer
|
||||
cleanupMutualExclusion(monoDest, skillSetupModeMono, &out, &errOut)
|
||||
if !strings.Contains(errOut.String(), "互斥清理扫描失败") {
|
||||
t.Fatalf("expected scan warning on errOut, got %q", errOut.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunSkillSetupThreadsFilteredFlag verifies runSkillSetup tells
|
||||
// installMultiSkillToHomes whether -s/--skill or -x/--exclude was used, so a
|
||||
// full install cleans stale siblings while a filtered install stays additive.
|
||||
func TestRunSkillSetupThreadsFilteredFlag(t *testing.T) {
|
||||
oldMode, oldSource, oldTargets := skillSetupResolveMode, skillSetupResolveSource, skillSetupResolveTargets
|
||||
oldList, oldFilter, oldExecute := skillSetupListMulti, skillSetupFilterMulti, skillSetupExecutePlan
|
||||
t.Cleanup(func() {
|
||||
skillSetupResolveMode, skillSetupResolveSource, skillSetupResolveTargets = oldMode, oldSource, oldTargets
|
||||
skillSetupListMulti, skillSetupFilterMulti, skillSetupExecutePlan = oldList, oldFilter, oldExecute
|
||||
})
|
||||
|
||||
skillSetupResolveMode = func(mode string, _ bool, _ io.Writer) (string, error) { return mode, nil }
|
||||
skillSetupResolveSource = func(string, string) (string, func(), error) { return "source", func() {}, nil }
|
||||
skillSetupResolveTargets = func(string, string) ([]string, error) {
|
||||
return []string{filepath.Join(t.TempDir(), "dest")}, nil
|
||||
}
|
||||
skillSetupListMulti = func(string) ([]string, error) { return []string{"dingtalk-aitable", "dws-shared"}, nil }
|
||||
skillSetupFilterMulti = filterMultiSkillNames
|
||||
home := t.TempDir()
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
|
||||
testseam.Swap(t, &skillSetupBuildProvenance, func(name, _ string, version, source string) (skillprovenance.Record, error) {
|
||||
return skillprovenance.Record{Name: name, Version: version, Source: source, Digest: "sha256:test", DigestScope: skillprovenance.DigestScope}, nil
|
||||
})
|
||||
testseam.Swap(t, &skillSetupWriteState, func(string, skillstate.State) error { return nil })
|
||||
var gotFiltered []bool
|
||||
skillSetupExecutePlan = func(plan *skillSetupPlan, _, _ io.Writer) (int, int, error) {
|
||||
gotFiltered = append(gotFiltered, plan.Filtered)
|
||||
return 1, 0, nil
|
||||
}
|
||||
|
||||
// Full install (no -s/-x): filtered must be false.
|
||||
cmd := skillSetupCoverageCommand(t, skillSetupModeMulti, true)
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("full install run err: %v", err)
|
||||
}
|
||||
|
||||
// Filtered install: filtered must be true.
|
||||
cmd = skillSetupCoverageCommand(t, skillSetupModeMulti, true)
|
||||
if err := cmd.Flags().Set("skill", "aitable"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("filtered install run err: %v", err)
|
||||
}
|
||||
|
||||
if len(gotFiltered) != 2 || gotFiltered[0] != false || gotFiltered[1] != true {
|
||||
t.Fatalf("filtered flag threading = %v, want [false true]", gotFiltered)
|
||||
}
|
||||
}
|
||||
func executeMultiSkillSetupTest(t *testing.T, src string, dests []string, args ...string) (string, string, error) {
|
||||
t.Helper()
|
||||
originalTargets := skillSetupResolveTargets
|
||||
@@ -633,7 +1036,7 @@ func assertNoEventMigrationStages(t *testing.T, agentHome string) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillSetupSelectiveEventMigratesOnlyFoldedTargets(t *testing.T) {
|
||||
func TestCrossPlatformCoverageSkillSetupSelectiveEventMigratesOnlyFoldedTargets(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{
|
||||
multiEventSkill, multiSharedSkill, multiMiscSkill, "dingtalk-doc",
|
||||
})
|
||||
@@ -653,13 +1056,10 @@ func TestSkillSetupSelectiveEventMigratesOnlyFoldedTargets(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{freshHome, foldedHome}, "--skill", "event")
|
||||
stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{freshHome, foldedHome}, "--skill", "event", "--yes")
|
||||
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)
|
||||
}
|
||||
@@ -703,7 +1103,7 @@ func TestSkillSetupSelectiveEventMigratesOnlyFoldedTargets(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillSetupEventMigrationDryRunAndExplicitExclude(t *testing.T) {
|
||||
func TestCrossPlatformCoverageSkillSetupEventMigrationDryRunAndExplicitExclude(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{
|
||||
multiEventSkill, multiSharedSkill, multiMiscSkill, "dingtalk-doc",
|
||||
})
|
||||
@@ -854,7 +1254,7 @@ func TestSkillSetupSelectiveEventPreservesFoldedMiscAfterPrimarySkip(t *testing.
|
||||
originalInstallMulti := skillSetupInstallMulti
|
||||
t.Cleanup(func() { skillSetupInstallMulti = originalInstallMulti })
|
||||
calls := 0
|
||||
skillSetupInstallMulti = func(string, []string, []string, io.Writer, io.Writer) (int, int, error) {
|
||||
skillSetupInstallMulti = func(string, []string, []string, io.Writer, io.Writer, bool) (int, int, error) {
|
||||
calls++
|
||||
if calls > 1 {
|
||||
t.Fatal("misc migration companion ran after a primary install skip")
|
||||
@@ -891,7 +1291,7 @@ func TestSkillSetupFreshTargetFailureDoesNotTouchFoldedPair(t *testing.T) {
|
||||
originalInstallMulti := skillSetupInstallMulti
|
||||
t.Cleanup(func() { skillSetupInstallMulti = originalInstallMulti })
|
||||
calls := 0
|
||||
skillSetupInstallMulti = func(string, []string, []string, io.Writer, io.Writer) (int, int, error) {
|
||||
skillSetupInstallMulti = func(string, []string, []string, io.Writer, io.Writer, bool) (int, int, error) {
|
||||
calls++
|
||||
if calls > 1 {
|
||||
t.Fatal("folded target prerequisites ran after fresh target failure")
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// detectNestedMultiSkillLayout detects the compatibility gap where an older
|
||||
// running dws process replaces itself with a newer binary, but then installs
|
||||
// the new release bundle with its legacy mono copier. That produces the
|
||||
// impossible layout <agent>/dws/multi/<skill>/SKILL.md.
|
||||
//
|
||||
// Detection is deliberately read-only. Ordinary commands must not turn a
|
||||
// compatibility warning into an unconfirmed, cross-Agent Skill refresh.
|
||||
// A legitimate mono install has no dws/multi product tree and is ignored.
|
||||
func detectNestedMultiSkillLayout() (bool, error) {
|
||||
home, err := skillSetupUserHomeDir()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, rel := range skillSetupAgentHomes {
|
||||
nested := filepath.Join(home, rel, "dws", "multi")
|
||||
if isSkillSourceRoot(nested, skillSetupModeMulti) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func shouldDetectNestedSkillLayout(cmd *cobra.Command) bool {
|
||||
if cmd == nil {
|
||||
return true
|
||||
}
|
||||
if cmd.Name() == "upgrade" {
|
||||
return false
|
||||
}
|
||||
return !(cmd.Name() == "setup" && cmd.Parent() != nil && cmd.Parent().Name() == "skill")
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageStartupDetectsNestedUpgradeLayoutWithoutMutation(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
|
||||
testseam.Swap(t, &skillSetupAgentHomes, []string{".agents/skills", ".codex/skills"})
|
||||
nested := filepath.Join(home, ".agents", "skills", "dws", "multi", "dingtalk-chat")
|
||||
if err := os.MkdirAll(nested, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(nested, "SKILL.md"), []byte("old nested"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
found, err := detectNestedMultiSkillLayout()
|
||||
if err != nil || !found {
|
||||
t.Fatalf("detect nested layout = (%v, %v), want (true, nil)", found, err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(nested, "SKILL.md"))
|
||||
if err != nil || string(data) != "old nested" {
|
||||
t.Fatalf("detection changed nested Skill: data=%q err=%v", data, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, ".codex", "skills", "dingtalk-chat")); !os.IsNotExist(err) {
|
||||
t.Fatalf("detection unexpectedly installed a canonical Skill: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageStartupDetectionIgnoresMonoAndReportsReadFailure(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
|
||||
testseam.Swap(t, &skillSetupAgentHomes, []string{".agents/skills"})
|
||||
mono := filepath.Join(home, ".agents", "skills", "dws")
|
||||
if err := os.MkdirAll(mono, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(mono, "SKILL.md"), []byte("valid mono"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found, err := detectNestedMultiSkillLayout()
|
||||
if err != nil || found {
|
||||
t.Fatalf("valid mono detection = (%v, %v), want (false, nil)", found, err)
|
||||
}
|
||||
|
||||
failure := errors.New("HOME failure")
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", failure })
|
||||
found, err = detectNestedMultiSkillLayout()
|
||||
if found || !errors.Is(err, failure) {
|
||||
t.Fatalf("HOME failure detection = (%v, %v)", found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageStartupDetectionSkipsExplicitSkillManagers(t *testing.T) {
|
||||
upgradeCmd := &cobra.Command{Use: "upgrade"}
|
||||
if shouldDetectNestedSkillLayout(upgradeCmd) {
|
||||
t.Fatal("upgrade must manage its own Skill lifecycle")
|
||||
}
|
||||
skillCmd := &cobra.Command{Use: "skill"}
|
||||
setupCmd := &cobra.Command{Use: "setup"}
|
||||
skillCmd.AddCommand(setupCmd)
|
||||
if shouldDetectNestedSkillLayout(setupCmd) {
|
||||
t.Fatal("skill setup must manage its own Skill lifecycle")
|
||||
}
|
||||
if !shouldDetectNestedSkillLayout(&cobra.Command{Use: "version"}) || !shouldDetectNestedSkillLayout(nil) {
|
||||
t.Fatal("ordinary commands must trigger read-only detection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageStartupWarningIsReadOnlyAndDoesNotBypassConfirmation(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
|
||||
testseam.Swap(t, &skillSetupAgentHomes, []string{".codex/skills"})
|
||||
nested := filepath.Join(home, ".codex", "skills", "dws", "multi", "dingtalk-chat")
|
||||
if err := os.MkdirAll(nested, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(nested, "SKILL.md"), []byte("old nested"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Any accidental return to startup mutation must fail this test before it
|
||||
// can touch the fixture.
|
||||
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error {
|
||||
t.Fatal("ordinary command attempted to copy Skills")
|
||||
return nil
|
||||
})
|
||||
testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) {
|
||||
t.Fatal("ordinary command attempted to back up or remove Skills")
|
||||
return "", nil
|
||||
})
|
||||
testseam.Swap(t, &skillSetupWriteState, func(string, skillstate.State) error {
|
||||
t.Fatal("ordinary command attempted to write Skill state")
|
||||
return nil
|
||||
})
|
||||
|
||||
root := newRootCommandWithEngine(context.Background(), nil, false, true)
|
||||
cmd := &cobra.Command{Use: "version"}
|
||||
cmd.SetContext(context.Background())
|
||||
var stderr bytes.Buffer
|
||||
cmd.SetErr(&stderr)
|
||||
if err := root.PersistentPreRunE(cmd, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
warning := stderr.String()
|
||||
if !strings.Contains(warning, "dws skill setup --mode multi") {
|
||||
t.Fatalf("safe migration hint missing: %q", warning)
|
||||
}
|
||||
if strings.Contains(warning, "--yes") {
|
||||
t.Fatalf("migration hint bypasses confirmation: %q", warning)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(nested, "SKILL.md"))
|
||||
if err != nil || string(data) != "old nested" {
|
||||
t.Fatalf("ordinary command changed nested Skill: data=%q err=%v", data, err)
|
||||
}
|
||||
}
|
||||
@@ -57,9 +57,9 @@ var (
|
||||
downloadUpgradeProgress = upgrade.DownloadWithProgress
|
||||
extractUpgradeZip = upgrade.ExtractZip
|
||||
findExtractedBinary = upgrade.FindBinaryInDir
|
||||
locateUpgradeSkill = upgrade.LocateSkillMD
|
||||
locateUpgradeSkill = upgrade.LocateSkillsRoot
|
||||
replaceUpgradeSelf = upgrade.ReplaceSelf
|
||||
installUpgradeSkills = upgrade.UpgradeSkillLocations
|
||||
installUpgradeSkills = upgrade.UpgradeSkillLocationsWithOptions
|
||||
upgradeMkdirTemp = os.MkdirTemp
|
||||
upgradeRemoveAll = os.RemoveAll
|
||||
upgradeReadFile = os.ReadFile
|
||||
@@ -99,7 +99,8 @@ func newUpgradeCommand() *cobra.Command {
|
||||
Long: `检查并升级 DWS CLI 到最新版本。
|
||||
|
||||
自动下载匹配当前平台的二进制文件和技能包,通过 SHA256 校验后原子替换。
|
||||
升级前会自动备份当前版本,可通过 --rollback 回滚。`,
|
||||
升级前会自动备份当前版本,可通过 --rollback 回滚。
|
||||
每次升级都会按新版本官方清单全量覆盖预制 Skill;--force 仅额外允许重装当前版本。`,
|
||||
Example: ` dws upgrade # 交互式升级到最新版本
|
||||
dws upgrade --check # 仅检查是否有新版本
|
||||
dws upgrade --list # 列出最近版本
|
||||
@@ -107,6 +108,7 @@ func newUpgradeCommand() *cobra.Command {
|
||||
dws upgrade --beta # 升级到最新 beta 预发布版本
|
||||
dws upgrade --version v1.0.7 # 升级到指定正式版本
|
||||
dws upgrade --version v1.0.8-beta.1 # 升级到指定 beta 版本
|
||||
dws upgrade --force # 即使已是最新版本也重装当前版本
|
||||
dws upgrade --rollback # 回滚到上一版本
|
||||
dws upgrade --dry-run # 仅预览升级步骤,不实际执行
|
||||
dws upgrade -y # 跳过确认直接升级`,
|
||||
@@ -158,7 +160,7 @@ func newUpgradeCommand() *cobra.Command {
|
||||
cmd.Flags().StringVar(&flagVersion, "version", "", "升级到指定版本")
|
||||
cmd.Flags().BoolVar(&flagBeta, "beta", false, "使用最新 beta 预发布版本(默认使用正式 release)")
|
||||
cmd.Flags().BoolVar(&flagRollback, "rollback", false, "回滚到上一版本")
|
||||
cmd.Flags().BoolVar(&flagForce, "force", false, "强制重新安装当前版本")
|
||||
cmd.Flags().BoolVar(&flagForce, "force", false, "即使已是最新版本也强制重新安装当前版本")
|
||||
cmd.Flags().BoolVar(&flagSkipSkills, "skip-skills", false, "跳过技能包更新")
|
||||
|
||||
return cmd
|
||||
@@ -596,7 +598,9 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
|
||||
}
|
||||
|
||||
if hasSkills {
|
||||
result, installErr := installUpgradeSkills(skillSrc)
|
||||
result, installErr := installUpgradeSkills(skillSrc, upgrade.SkillUpgradeOptions{
|
||||
Version: release.Version,
|
||||
})
|
||||
if installErr != nil {
|
||||
fmt.Printf(" %s\n", ugRed("✗"))
|
||||
return fmt.Errorf("技能包安装失败: %w", installErr)
|
||||
|
||||
@@ -285,7 +285,7 @@ func TestCrossPlatformCoverageRunUpgradeAllStagesCoverage(t *testing.T) {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
installUpgradeSkills = func(string) (*upgradepkg.SkillUpgradeResult, error) {
|
||||
installUpgradeSkills = func(string, upgradepkg.SkillUpgradeOptions) (*upgradepkg.SkillUpgradeResult, error) {
|
||||
if stage == "install" {
|
||||
return nil, fail
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillprovenance"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
|
||||
)
|
||||
|
||||
// TestCrossPlatformCoverageUpgradeSkillLocationsMonoSeedMigratesToMulti is the fake-HOME E2E for
|
||||
// the 2026-08-05 owner decision: upgrade is not disk-sticky. Seeding a mono
|
||||
// layout then calling UpgradeSkillLocations with a multi bundle must install
|
||||
// product skills, remove dws/, and leave non-DWS dirs alone.
|
||||
func TestCrossPlatformCoverageUpgradeSkillLocationsMonoSeedMigratesToMulti(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
setTestHome(t, home)
|
||||
upgrade.SwapUserHomeDirForTest(t, func() (string, error) { return home, nil })
|
||||
|
||||
agentsBase := filepath.Join(home, ".agents", "skills")
|
||||
if err := os.MkdirAll(filepath.Join(agentsBase, "dws"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(agentsBase, "dws", "SKILL.md"), []byte("old mono"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(agentsBase, "other-skill"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(agentsBase, "other-skill", "SKILL.md"), []byte("not dws"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
extract := t.TempDir()
|
||||
multiRoot := filepath.Join(extract, "multi")
|
||||
for _, name := range []string{"dingtalk-chat", "dws-shared"} {
|
||||
dir := filepath.Join(multiRoot, name)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("# "+name), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
result, err := upgrade.UpgradeSkillLocations(multiRoot)
|
||||
if err != nil {
|
||||
t.Fatalf("UpgradeSkillLocations() error = %v", err)
|
||||
}
|
||||
if failed := result.Failed(); len(failed) != 0 {
|
||||
t.Fatalf("expected 0 failures, got %v", failed)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(agentsBase, "dws")); !os.IsNotExist(err) {
|
||||
t.Fatalf("mono leftover dws/ must be gone, stat err=%v", err)
|
||||
}
|
||||
for _, name := range []string{"dingtalk-chat", "dws-shared"} {
|
||||
if _, err := os.Stat(filepath.Join(agentsBase, name, "SKILL.md")); err != nil {
|
||||
t.Errorf("multi skill missing: %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(agentsBase, "other-skill", "SKILL.md")); err != nil {
|
||||
t.Errorf("non-DWS dir should be preserved: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpgradeSkillLocationsRealBundleMigratesMono runs the upgrade path against
|
||||
// the repository's actual multi bundle (skills/multi, post-#887 layout with
|
||||
// dingtalk-shared). Disk is seeded with a mono install plus the leftovers the
|
||||
// rename and stale releases leave behind: a pre-rename dws-shared directory
|
||||
// and a dingtalk-* skill no longer in the bundle. All three must be gone after
|
||||
// the upgrade, every bundle skill installed, non-DWS dirs untouched, and the
|
||||
// ~/.dws/skills/multi cache refreshed.
|
||||
func TestCrossPlatformCoverageUpgradeSkillLocationsRealBundleMigratesMono(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
setTestHome(t, home)
|
||||
upgrade.SwapUserHomeDirForTest(t, func() (string, error) { return home, nil })
|
||||
|
||||
bundle := filepath.Join("..", "..", "skills", "multi")
|
||||
entries, err := os.ReadDir(bundle)
|
||||
if err != nil {
|
||||
t.Fatalf("real multi bundle missing: %v", err)
|
||||
}
|
||||
var bundleNames []string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
bundleNames = append(bundleNames, e.Name())
|
||||
}
|
||||
}
|
||||
if len(bundleNames) == 0 {
|
||||
t.Fatal("real multi bundle is empty")
|
||||
}
|
||||
|
||||
agentsBase := filepath.Join(home, ".agents", "skills")
|
||||
// Mono install plus pre-rename shared and a stale product skill.
|
||||
for _, name := range []string{"dws", "dws-shared", "dingtalk-stale", "other-skill"} {
|
||||
dir := filepath.Join(agentsBase, name)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("# "+name), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := skillstate.Write(home, skillstate.State{ManagedSkills: []skillprovenance.Record{{Name: "dingtalk-stale"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
custom := filepath.Join(agentsBase, "dingtalk-custom")
|
||||
if err := os.MkdirAll(custom, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(custom, "SKILL.md"), []byte("market skill"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result, err := upgrade.UpgradeSkillLocations(bundle)
|
||||
if err != nil {
|
||||
t.Fatalf("UpgradeSkillLocations() error = %v", err)
|
||||
}
|
||||
if failed := result.Failed(); len(failed) != 0 {
|
||||
t.Fatalf("expected 0 failures, got %v", failed)
|
||||
}
|
||||
|
||||
for _, stale := range []string{"dws", "dws-shared", "dingtalk-stale"} {
|
||||
if _, err := os.Stat(filepath.Join(agentsBase, stale)); !os.IsNotExist(err) {
|
||||
t.Errorf("leftover %q must be removed by the upgrade, stat err=%v", stale, err)
|
||||
}
|
||||
}
|
||||
for _, name := range bundleNames {
|
||||
if _, err := os.Stat(filepath.Join(agentsBase, name, "SKILL.md")); err != nil {
|
||||
t.Errorf("bundle skill %q missing after upgrade: %v", name, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(agentsBase, "other-skill", "SKILL.md")); err != nil {
|
||||
t.Errorf("non-DWS dir should be preserved: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(custom, "SKILL.md")); err != nil {
|
||||
t.Errorf("unregistered market/user dingtalk-* dir should be preserved: %v", err)
|
||||
}
|
||||
// Shared skill must come from the renamed bundle dir, never the legacy name.
|
||||
if _, err := os.Stat(filepath.Join(agentsBase, "dingtalk-shared", "SKILL.md")); err != nil {
|
||||
t.Errorf("dingtalk-shared missing: %v", err)
|
||||
}
|
||||
// Cache refresh so `dws skill setup` fallbacks stay on the upgraded version.
|
||||
if _, err := os.Stat(filepath.Join(home, ".dws", "skills", "multi", "SKILL.md")); err != nil {
|
||||
// multi cache mirrors the bundle root, which has no top-level SKILL.md;
|
||||
// check a bundle skill inside the cache instead.
|
||||
if _, err2 := os.Stat(filepath.Join(home, ".dws", "skills", "multi", bundleNames[0], "SKILL.md")); err2 != nil {
|
||||
t.Errorf("multi cache not refreshed: %v / %v", err, err2)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,27 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageUpgradeHelpDoesNotMakeFullSkillRefreshForceOnly(t *testing.T) {
|
||||
cmd := newUpgradeCommand()
|
||||
force := cmd.Flags().Lookup("force")
|
||||
if force == nil {
|
||||
t.Fatal("upgrade --force flag is missing")
|
||||
}
|
||||
for _, text := range []string{cmd.Example, force.Usage} {
|
||||
if strings.Contains(text, "恢复全部官方 Skill") {
|
||||
t.Fatalf("upgrade help still implies that only --force performs the full Skill refresh: %q", text)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(force.Usage, "已是最新版本") {
|
||||
t.Fatalf("upgrade --force help must explain its remaining purpose, got %q", force.Usage)
|
||||
}
|
||||
for _, want := range []string{"每次升级", "全量覆盖预制 Skill", "--force 仅额外允许重装当前版本"} {
|
||||
if !strings.Contains(cmd.Long, want) {
|
||||
t.Fatalf("upgrade help missing full-refresh contract %q: %s", want, cmd.Long)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- ensureV ---
|
||||
|
||||
func TestEnsureV(t *testing.T) {
|
||||
|
||||
@@ -1356,6 +1356,8 @@ func TestDeliveryCatalogChatParamDeclsFrom87910880Reviewed(t *testing.T) {
|
||||
{"chat message edit", "conversation-id", "openConversationId", true, ""},
|
||||
{"chat message edit", "msg-id", "openMessageId", true, ""},
|
||||
{"chat message edit", "at-open-dingtalk-ids", "atOpenDingTalkIds", false, "array"},
|
||||
{"chat message send-card", "at-all", "atAll", false, ""},
|
||||
{"chat message send-card", "at-open-dingtalk-ids", "atOpenDingTalkIds", false, "array"},
|
||||
{"chat message update-text-emotion", "msg-id", "openMsgId", true, ""},
|
||||
{"chat message update-text-emotion", "old-emotion-id", "oldEmotionId", true, ""},
|
||||
{"chat category batch-info", "category-ids", "categoryIds", true, "array"},
|
||||
|
||||
@@ -5721,27 +5721,41 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。
|
||||
Use: "send-card",
|
||||
Short: "创建并推送流式卡片",
|
||||
Long: `向群聊或单聊创建并推送流式卡片。群聊传 --group,单聊传 --receiver,二者互斥。
|
||||
群聊创建卡片时可通过 --at-open-dingtalk-ids @指定成员,或通过 --at-all @所有人。
|
||||
创建时无需传入卡片内容,后续通过 update-card 更新内容。
|
||||
|
||||
注意:send-card 必须和 update-card 搭配使用。发送卡片后,使用返回的 bizId 调用 update-card 更新内容,
|
||||
最后一次更新必须将 --flow-status 设为 3(finish),否则卡片会一直处于"生成中"的加载状态。
|
||||
flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成(FINISH),4=执行中(EXECUTING),5=错误(ERROR)。`,
|
||||
Example: ` dws chat message send-card --group <openConversationId>
|
||||
dws chat message send-card --group <openConversationId> --at-open-dingtalk-ids <openDingTalkId>
|
||||
dws chat message send-card --group <openConversationId> --at-all
|
||||
dws chat message send-card --receiver <openDingTalkId>
|
||||
# 查询群 ID: dws chat search --query "群名"
|
||||
# 查询人员: dws contact user search --keyword "姓名" --format json`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
groupID := flagOrFallback(cmd, "group", "conversation-id", "id", "chat")
|
||||
receiver, _ := cmd.Flags().GetString("receiver")
|
||||
atOpenDingTalkIDs := uniqueNonEmptyStrings(parseCSVValues(mustGetFlag(cmd, "at-open-dingtalk-ids")))
|
||||
atAll, _ := cmd.Flags().GetBool("at-all")
|
||||
if groupID == "" && receiver == "" {
|
||||
return fmt.Errorf("--group or --receiver is required")
|
||||
}
|
||||
if groupID != "" && receiver != "" {
|
||||
return fmt.Errorf("--group and --receiver are mutually exclusive")
|
||||
}
|
||||
if groupID == "" && (len(atOpenDingTalkIDs) > 0 || atAll) {
|
||||
return fmt.Errorf("--at-open-dingtalk-ids and --at-all are only supported with --group")
|
||||
}
|
||||
toolArgs := map[string]any{}
|
||||
if groupID != "" {
|
||||
toolArgs["openConversationId"] = groupID
|
||||
if len(atOpenDingTalkIDs) > 0 {
|
||||
toolArgs["atOpenDingTalkIds"] = atOpenDingTalkIDs
|
||||
}
|
||||
if atAll {
|
||||
toolArgs["atAll"] = true
|
||||
}
|
||||
}
|
||||
if receiver != "" {
|
||||
resolved, err := resolveOpenDingTalkID(cmd.Context(), receiver)
|
||||
@@ -5766,19 +5780,21 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
CLIPath: "chat message send-card",
|
||||
PrimaryCLIPath: "chat message send-card",
|
||||
},
|
||||
Description: "创建并向群聊或单聊发送互动卡片",
|
||||
Description: "创建并向群聊或单聊发送互动卡片;群聊创建时可 @成员或 @所有人",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "create_and_send_card"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "创建并向群聊或单聊发送互动卡片",
|
||||
UseWhen: []string{"需要卡片式交互且已准备接收会话或用户时"},
|
||||
AgentSummary: "创建并向群聊或单聊发送互动卡片;群聊创建时可 @成员或 @所有人",
|
||||
UseWhen: []string{"需要创建卡片且已准备接收会话或用户时;群聊创建可同时指定 @成员或 @所有人"},
|
||||
AvoidWhen: []string{"只发送普通文本时使用 send 或 send-by-bot"},
|
||||
Examples: []string{"dws chat message send-card --group <openConversationId>"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "at-all", Property: "atAll", Required: boolPtr(false), InterfaceType: "boolean"},
|
||||
{Name: "at-open-dingtalk-ids", Property: "atOpenDingTalkIds", Required: boolPtr(false), InterfaceType: "array"},
|
||||
{Name: "group", Property: "openConversationId"},
|
||||
{Name: "receiver", Property: "receiverOpenDingTalkId"},
|
||||
},
|
||||
@@ -5786,6 +5802,8 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
})
|
||||
chatMessageSendCardCmd.Flags().String("group", "", "群聊 openConversationId(群聊时必填,与 --receiver 互斥)")
|
||||
chatMessageSendCardCmd.Flags().String("receiver", "", "单聊接收者 openDingTalkId(单聊时必填,与 --group 互斥)")
|
||||
chatMessageSendCardCmd.Flags().String("at-open-dingtalk-ids", "", "群聊创建卡片时 @ 的 openDingTalkId 列表,逗号分隔(仅与 --group 一起使用)")
|
||||
chatMessageSendCardCmd.Flags().Bool("at-all", false, "群聊创建卡片时 @ 所有人(仅与 --group 一起使用)")
|
||||
|
||||
chatMessageUpdateCardCmd := &cobra.Command{
|
||||
Use: "update-card",
|
||||
|
||||
@@ -77,7 +77,7 @@ func TestCrossPlatformCoverageNativeMessageUpdateCardVerifiesWrite(t *testing.T)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("generic success is unverified", func(t *testing.T) {
|
||||
t.Run("success acknowledgement is verified", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"success":true,"errorCode":null}`}}}
|
||||
err := runNativeCardUpdate(t, caller,
|
||||
"message", "update-card",
|
||||
@@ -85,9 +85,11 @@ func TestCrossPlatformCoverageNativeMessageUpdateCardVerifiesWrite(t *testing.T)
|
||||
"--content", "完成",
|
||||
"--flow-status", "3",
|
||||
)
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != "streaming_card_update_unverified" {
|
||||
t.Fatalf("error = %#v, want streaming_card_update_unverified", err)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls != 1 || caller.tool != "update_streaming_card" {
|
||||
t.Fatalf("call = count:%d tool:%q", caller.calls, caller.tool)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -272,6 +273,52 @@ func TestCrossPlatformCoverageChatCreateAndMessageSendEdges(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatNativeSendCardMentions(t *testing.T) {
|
||||
previousDeps, previousArgs := deps, os.Args
|
||||
os.Args = []string{"dws", "chat"}
|
||||
t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs })
|
||||
|
||||
t.Run("group forwards mention arguments", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
err := runChatCoverageCommand(t, caller,
|
||||
"message", "send-card",
|
||||
"--group=cid",
|
||||
"--at-open-dingtalk-ids=D1,D2,D1",
|
||||
"--at-all",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string]any{
|
||||
"openConversationId": "cid",
|
||||
"atOpenDingTalkIds": []string{"D1", "D2"},
|
||||
"atAll": true,
|
||||
}
|
||||
if caller.calls != 1 || caller.server != "im" || caller.tool != "create_and_send_card" || !reflect.DeepEqual(caller.args, want) {
|
||||
t.Fatalf("call = count:%d server:%q tool:%q args:%#v, want %#v", caller.calls, caller.server, caller.tool, caller.args, want)
|
||||
}
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{name: "member mention rejects direct message", args: []string{"--receiver=D1", "--at-open-dingtalk-ids=D2"}},
|
||||
{name: "at all rejects direct message", args: []string{"--receiver=D1", "--at-all"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
err := runChatCoverageCommand(t, caller, append([]string{"message", "send-card"}, tc.args...)...)
|
||||
if err == nil || !strings.Contains(err.Error(), "only supported with --group") {
|
||||
t.Fatalf("error = %v, want group-only mention validation", err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("invalid direct-message mentions made %d tool calls", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatWebhookReplyConversationAndDownloadEdges(t *testing.T) {
|
||||
previousDeps, previousArgs := deps, os.Args
|
||||
os.Args = []string{"dws", "chat"}
|
||||
|
||||
@@ -281,6 +281,85 @@ func parseDriveUploadInfo(text string) (resourceURL, uploadID string, headers ma
|
||||
return
|
||||
}
|
||||
|
||||
// DriveUploadRequest describes the reusable Drive upload transaction used by
|
||||
// the native leaf and the curated +upload shortcut. FilePath must already be
|
||||
// resolved and validated by the caller.
|
||||
type DriveUploadRequest struct {
|
||||
FilePath string
|
||||
FileName string
|
||||
FileSize int64
|
||||
SpaceID string
|
||||
ParentID string
|
||||
OverwriteFile string
|
||||
MIMEType string
|
||||
}
|
||||
|
||||
// UploadDriveFileData runs credentials -> OSS PUT -> commit exactly once and
|
||||
// returns the parsed commit response without rendering it. Unlike the legacy
|
||||
// leaf helper, this path fails when the commit has no non-empty JSON response;
|
||||
// the Shortcut can then require terminal success evidence and read back the
|
||||
// created node before reporting success.
|
||||
func UploadDriveFileData(ctx context.Context, request DriveUploadRequest) (map[string]any, error) {
|
||||
if strings.TrimSpace(request.FilePath) == "" || strings.TrimSpace(request.FileName) == "" || request.FileSize <= 0 {
|
||||
return nil, fmt.Errorf("invalid drive upload request")
|
||||
}
|
||||
step1Args := map[string]any{
|
||||
"fileName": request.FileName,
|
||||
"fileSize": float64(request.FileSize),
|
||||
}
|
||||
if request.SpaceID != "" {
|
||||
step1Args["spaceId"] = request.SpaceID
|
||||
}
|
||||
if request.MIMEType != "" {
|
||||
step1Args["mimeType"] = request.MIMEType
|
||||
}
|
||||
if request.OverwriteFile != "" {
|
||||
step1Args["overwriteFileId"] = request.OverwriteFile
|
||||
} else if request.ParentID != "" {
|
||||
step1Args["parentId"] = request.ParentID
|
||||
}
|
||||
|
||||
credentialText, err := callMCPToolReturnTextOnServer(ctx, "drive", "get_upload_info", step1Args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
uploadID, err := driveUploadPut(ctx, credentialText, func(refreshCtx context.Context) (string, error) {
|
||||
return callMCPToolReturnTextOnServer(refreshCtx, "drive", "get_upload_info", step1Args)
|
||||
}, request.FilePath, request.FileSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
commitArgs := map[string]any{
|
||||
"fileName": request.FileName,
|
||||
"fileSize": float64(request.FileSize),
|
||||
"uploadId": uploadID,
|
||||
}
|
||||
if request.SpaceID != "" {
|
||||
commitArgs["spaceId"] = request.SpaceID
|
||||
}
|
||||
if request.OverwriteFile != "" {
|
||||
commitArgs["overwriteFileId"] = request.OverwriteFile
|
||||
} else if request.ParentID != "" {
|
||||
commitArgs["parentId"] = request.ParentID
|
||||
}
|
||||
commitText, err := callMCPToolReturnTextOnServer(ctx, "drive", "commit_upload", commitArgs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(commitText) == "" {
|
||||
return nil, fmt.Errorf("commit_upload returned no business result; remote effect is unknown")
|
||||
}
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal([]byte(commitText), &result); err != nil {
|
||||
return nil, fmt.Errorf("parse commit_upload response: %w", err)
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil, fmt.Errorf("commit_upload returned an empty JSON object; remote effect is unknown")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func newDriveCommand() *cobra.Command {
|
||||
// Product-level Agent routing Decl (migrated from selection/drive.json
|
||||
// products.drive). Catalog assembly stamps provenance contract_final.
|
||||
@@ -3204,6 +3283,7 @@ func newDriveCommand() *cobra.Command {
|
||||
driveListCmd,
|
||||
driveListSpacesCmd,
|
||||
driveInfoCmd,
|
||||
newDriveFileCommentCmd(),
|
||||
driveDownloadCmd,
|
||||
driveDownloadVersionCmd,
|
||||
driveMkdirCmd,
|
||||
|
||||
@@ -154,6 +154,88 @@ func TestCrossPlatformCoverageDriveUploadTransportCoverage(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageUploadDriveFileDataStrictTransaction(t *testing.T) {
|
||||
credential := `{"result":{"uploadType":"httpToCenterWithToken","resourceUrl":"https://c.example.com/u?upload_key=u1","uploadId":"u1","headers":{"dentry-token":"token"}}}`
|
||||
request := DriveUploadRequest{FilePath: "fixture.bin", FileName: "fixture.bin", FileSize: 7, SpaceID: "space", ParentID: "folder", MIMEType: "application/octet-stream"}
|
||||
|
||||
t.Run("success with parent", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: credential}, {text: `{"success":true,"result":{"fileId":"n1"}}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil })
|
||||
t.Cleanup(func() { SetHTTPPutFile(nil) })
|
||||
result, err := UploadDriveFileData(context.Background(), request)
|
||||
if err != nil || result["success"] != true || caller.calls != 2 {
|
||||
t.Fatalf("result=%v calls=%d error=%v", result, caller.calls, err)
|
||||
}
|
||||
if caller.args["spaceId"] != "space" || caller.args["parentId"] != "folder" {
|
||||
t.Fatalf("commit args=%v", caller.args)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("success with overwrite", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: credential}, {text: `{"success":true}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil })
|
||||
t.Cleanup(func() { SetHTTPPutFile(nil) })
|
||||
overwrite := request
|
||||
overwrite.ParentID = "ignored"
|
||||
overwrite.OverwriteFile = "existing"
|
||||
if _, err := UploadDriveFileData(context.Background(), overwrite); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.args["overwriteFileId"] != "existing" {
|
||||
t.Fatalf("commit args=%v", caller.args)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("credential refresh callback", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: credential}, {text: credential}, {text: `{"success":true}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
putCalls := 0
|
||||
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error {
|
||||
putCalls++
|
||||
if putCalls == 1 {
|
||||
return &httpStatusError{StatusCode: 401, Body: "expired"}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
t.Cleanup(func() { SetHTTPPutFile(nil) })
|
||||
if _, err := UploadDriveFileData(context.Background(), request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls != 3 || putCalls != 2 {
|
||||
t.Fatalf("caller calls=%d put calls=%d", caller.calls, putCalls)
|
||||
}
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
request DriveUploadRequest
|
||||
steps []scriptedToolStep
|
||||
putErr error
|
||||
want string
|
||||
}{
|
||||
{name: "invalid request", request: DriveUploadRequest{}, want: "invalid drive upload request"},
|
||||
{name: "credential failure", request: request, steps: []scriptedToolStep{{err: errors.New("credentials")}}, want: "credentials"},
|
||||
{name: "put failure", request: request, steps: []scriptedToolStep{{text: credential}}, putErr: errors.New("put failed"), want: "put failed"},
|
||||
{name: "commit failure", request: request, steps: []scriptedToolStep{{text: credential}, {err: errors.New("commit failed")}}, want: "commit failed"},
|
||||
{name: "empty commit", request: request, steps: []scriptedToolStep{{text: credential}, {text: " "}}, want: "no business result"},
|
||||
{name: "malformed commit", request: request, steps: []scriptedToolStep{{text: credential}, {text: "{"}}, want: "parse commit_upload response"},
|
||||
{name: "empty object commit", request: request, steps: []scriptedToolStep{{text: credential}, {text: `{}`}}, want: "empty JSON object"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: tc.steps}
|
||||
installScriptedCaller(t, caller)
|
||||
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return tc.putErr })
|
||||
t.Cleanup(func() { SetHTTPPutFile(nil) })
|
||||
_, err := UploadDriveFileData(context.Background(), tc.request)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("error=%v, want %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveCommandRemainingEdges(t *testing.T) {
|
||||
file := filepath.Join(t.TempDir(), "fixture.txt")
|
||||
_ = os.WriteFile(file, []byte("fixture"), 0o600)
|
||||
|
||||
@@ -0,0 +1,576 @@
|
||||
// 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 helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"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/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const (
|
||||
fileCommentServer = "doc-comment"
|
||||
listFileCommentsTool = "list_file_comments"
|
||||
createFileCommentTool = "create_file_comment"
|
||||
fileCommentMaxPageSize = 200
|
||||
fileCommentMaxAutoPages = 10
|
||||
fileCommentMaxContentLength = 2099
|
||||
)
|
||||
|
||||
type fileCommentPage struct {
|
||||
nodeID string
|
||||
total any
|
||||
hasMore bool
|
||||
nextCursor string
|
||||
comments []map[string]any
|
||||
}
|
||||
|
||||
func fileCommentNodeFlag() LeafFlag {
|
||||
return LeafFlag{Name: "node", Usage: "文件 ID (dentryUuid)、数字 dentry ID 或钉盘文件 URL", Required: true, Aliases: []string{"url", "id", "node-id", "file-id"}, Bind: "fileId", Trim: true}
|
||||
}
|
||||
|
||||
func fileCommentSpaceIDFlag() LeafFlag {
|
||||
return LeafFlag{Name: "space-id", Usage: "钉盘空间 ID;仅数字 dentry ID 必填", Bind: "spaceId", OmitEmpty: true, Trim: true, RequiredWhen: "--node is a numeric dentry ID"}
|
||||
}
|
||||
|
||||
// newDriveFileCommentCmd follows the existing resource-first comment surface:
|
||||
// doc comment, sheet comment, and drive comment. The public command belongs to
|
||||
// Drive, while the implementation routes to the shared doc-comment MCP server.
|
||||
func newDriveFileCommentCmd() *cobra.Command {
|
||||
commentCmd := &cobra.Command{
|
||||
Use: "comment",
|
||||
Short: "普通文件评论管理",
|
||||
Long: "管理钉盘普通预览文件的评论:查询评论列表或创建全文纯文本评论。",
|
||||
RunE: groupRunE,
|
||||
}
|
||||
|
||||
listCmd := NewLeafCommand(LeafSpec{
|
||||
Use: "list",
|
||||
Short: "查询普通文件评论列表",
|
||||
Long: `查询钉盘普通预览文件的评论。支持 dentryUuid、钉盘文件 URL,以及配合
|
||||
--space-id 使用的数字 dentry ID。支持的文件类型由服务端判定。
|
||||
|
||||
默认返回一页;--all 固定按每页 200 条自动翻页,最多 10 页。--scope 在 CLI
|
||||
侧按服务端返回的统一 anchor 过滤;total 始终表示文件的全部有效评论数,count
|
||||
表示本次输出且符合 scope 的评论数。`,
|
||||
Example: ` dws drive comment list --node <dentryUuid> --format json
|
||||
dws drive comment list --node <dentryUuid> --all --format json`,
|
||||
Tool: listFileCommentsTool,
|
||||
Flags: []LeafFlag{
|
||||
fileCommentNodeFlag(),
|
||||
fileCommentSpaceIDFlag(),
|
||||
{Name: "limit", Usage: "每页评论数,范围 1-200", Kind: LeafInt, Default: "200", Aliases: []string{"page-size"}, Bind: "maxResults"},
|
||||
{Name: "cursor", Usage: "分页游标,取自上页 nextCursor", Bind: "nextToken", OmitEmpty: true, Trim: true},
|
||||
{Name: "all", Usage: "自动拉取全部评论,最多 10 页 / 2000 条", Kind: LeafBool, Bind: "all"},
|
||||
{Name: "scope", Usage: "评论范围: all(全部) / whole(全文) / partial(历史局部)", Default: "all", Bind: "scope", Trim: true, Enum: []string{"all", "whole", "partial"}},
|
||||
},
|
||||
Constraints: []LeafConstraint{
|
||||
{Kind: LeafMutuallyExclusive, Flags: []string{"all", "cursor"}, Description: "--all 与 --cursor 互斥"},
|
||||
{Kind: LeafMutuallyExclusive, Flags: []string{"all", "limit"}, Description: "--all 与显式 --limit/--page-size 互斥"},
|
||||
{Kind: "custom", Flags: []string{"limit"}, Description: "--limit/--page-size 必须在 1-200 之间"},
|
||||
{Kind: "custom", Flags: []string{"cursor"}, Description: "--cursor 必须是服务端返回的非负数字游标"},
|
||||
},
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "drive",
|
||||
Name: "list_file_comments",
|
||||
CanonicalPath: "drive.list_file_comments",
|
||||
CLIPath: "drive comment list",
|
||||
PrimaryCLIPath: "drive comment list",
|
||||
},
|
||||
Description: "查询钉盘普通预览文件评论,支持安全分页聚合和全文/局部范围过滤",
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: contract.DryRunPreviewRequest},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "The CLI wraps doc-comment/list_file_comments with bounded auto-pagination, cursor anomaly guards, local anchor-scope filtering, and a stable output projection, so no single direct MCP interface represents the complete command contract.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询 PDF、DOCX、XLSX 等钉盘普通预览文件上的评论",
|
||||
UseWhen: []string{
|
||||
"用户要查看普通文件上的全文评论或历史高亮/矩形评论时",
|
||||
"需要从评论列表取得 commentId、作者、时间和统一 anchor,或用 --all 拉取完整列表时",
|
||||
},
|
||||
AvoidWhen: []string{
|
||||
"在线文字文档评论使用 dws doc comment list",
|
||||
"在线表格单元格评论使用 dws sheet comment list",
|
||||
},
|
||||
Examples: []string{
|
||||
"dws drive comment list --node <dentryUuid> --format json",
|
||||
"dws drive comment list --node <dentryUuid> --all --format json",
|
||||
},
|
||||
},
|
||||
},
|
||||
Validate: validateFileCommentList,
|
||||
Call: runFileCommentList,
|
||||
})
|
||||
|
||||
createCmd := NewLeafCommand(LeafSpec{
|
||||
Use: "create",
|
||||
Short: "创建普通文件全文评论",
|
||||
Long: `在钉盘普通预览文件上创建一条全文纯文本评论。当前不支持 @人、通知选项或
|
||||
局部锚点;content 按服务端规则使用 UTF-16 长度计数,最多 2099。`,
|
||||
Example: ` dws drive comment create --node <dentryUuid> --content "请补充最终结论" --format json`,
|
||||
Tool: createFileCommentTool,
|
||||
Flags: []LeafFlag{
|
||||
fileCommentNodeFlag(),
|
||||
fileCommentSpaceIDFlag(),
|
||||
{Name: "content", Usage: "全文评论内容,纯文本且 UTF-16 长度不超过 2099", Required: true, Bind: "content"},
|
||||
},
|
||||
Constraints: []LeafConstraint{
|
||||
{Kind: "custom", Flags: []string{"content"}, Description: "--content 去除首尾空白后必须非空,且 UTF-16 长度不超过 2099"},
|
||||
},
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "user_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "drive",
|
||||
Name: "create_file_comment",
|
||||
CanonicalPath: "drive.create_file_comment",
|
||||
CLIPath: "drive comment create",
|
||||
PrimaryCLIPath: "drive comment create",
|
||||
},
|
||||
Description: "在钉盘普通预览文件上创建一条全文纯文本评论",
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: contract.DryRunPreviewRequest},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "The CLI wraps doc-comment/create_file_comment with exact local content validation, runtime confirmation, and a stable flattened output projection, so no single direct MCP interface represents the complete command contract.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "在 PDF、DOCX、XLSX 等钉盘普通预览文件上创建全文纯文本评论",
|
||||
UseWhen: []string{
|
||||
"用户明确要求在普通文件上留下不绑定具体位置的评论时",
|
||||
},
|
||||
AvoidWhen: []string{
|
||||
"在线文字文档评论使用 dws doc comment create",
|
||||
"在线表格单元格评论使用 dws sheet comment create;当前普通文件评论不支持 @人或局部锚点",
|
||||
},
|
||||
Examples: []string{
|
||||
"dws drive comment create --node <dentryUuid> --content \"请补充最终结论\" --format json",
|
||||
},
|
||||
},
|
||||
},
|
||||
Validate: validateFileCommentCreate,
|
||||
Call: runFileCommentCreate,
|
||||
})
|
||||
|
||||
commentCmd.AddCommand(listCmd, createCmd)
|
||||
return commentCmd
|
||||
}
|
||||
|
||||
func validateFileCommentList(cmd *cobra.Command, _ []string) error {
|
||||
if err := validateFileCommentNodeSpace(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
limit, _ := cmd.Flags().GetInt("limit")
|
||||
if cmd.Flags().Changed("page-size") {
|
||||
limit, _ = cmd.Flags().GetInt("page-size")
|
||||
}
|
||||
if limit < 1 || limit > fileCommentMaxPageSize {
|
||||
return &CLIError{
|
||||
Code: CodeInvalidParam,
|
||||
Message: fmt.Sprintf("--limit/--page-size 必须在 1-%d 之间", fileCommentMaxPageSize),
|
||||
}
|
||||
}
|
||||
if cursor, _ := cmd.Flags().GetString("cursor"); strings.TrimSpace(cursor) != "" {
|
||||
cursor = strings.TrimSpace(cursor)
|
||||
if !allASCIIDigits(cursor) {
|
||||
return &CLIError{Code: CodeInvalidParam, Message: "--cursor 必须是服务端返回的非负数字游标"}
|
||||
}
|
||||
if _, err := strconv.ParseInt(cursor, 10, 64); err != nil {
|
||||
return &CLIError{Code: CodeInvalidParam, Message: "--cursor 超出 64 位整数范围"}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateFileCommentCreate(cmd *cobra.Command, _ []string) error {
|
||||
if err := validateFileCommentNodeSpace(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
content, _ := cmd.Flags().GetString("content")
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return &CLIError{Code: CodeInvalidParam, Message: "--content 去除首尾空白后不能为空"}
|
||||
}
|
||||
length := fileCommentUTF16Length(content)
|
||||
if length > fileCommentMaxContentLength {
|
||||
return &CLIError{
|
||||
Code: CodeInputTooLarge,
|
||||
Message: fmt.Sprintf("--content 最多 %d 个 UTF-16 代码单元,当前为 %d", fileCommentMaxContentLength, length),
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateFileCommentNodeSpace(cmd *cobra.Command) error {
|
||||
node := corecmd.EffectiveValue(cmd, fileCommentNodeFlag())
|
||||
if !allASCIIDigits(node) {
|
||||
return nil
|
||||
}
|
||||
if spaceID := corecmd.EffectiveValue(cmd, fileCommentSpaceIDFlag()); spaceID == "" {
|
||||
return &CLIError{
|
||||
Code: CodeInvalidParam,
|
||||
Message: "--node 为数字 dentry ID 时必须同时提供 --space-id",
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func fileCommentUTF16Length(value string) int {
|
||||
length := 0
|
||||
for _, r := range value {
|
||||
length++
|
||||
if r > 0xffff {
|
||||
length++
|
||||
}
|
||||
}
|
||||
return length
|
||||
}
|
||||
|
||||
func allASCIIDigits(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range value {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func runFileCommentList(cmd *cobra.Command, tool string, args map[string]any) error {
|
||||
fetchAll, _ := args["all"].(bool)
|
||||
scope, _ := args["scope"].(string)
|
||||
if scope == "" {
|
||||
scope = "all"
|
||||
}
|
||||
request := fileCommentMCPArgs(args)
|
||||
if fetchAll {
|
||||
request["maxResults"] = fileCommentMaxPageSize
|
||||
delete(request, "nextToken")
|
||||
}
|
||||
if deps != nil && deps.Caller != nil && deps.Caller.DryRun() {
|
||||
return callMCPToolOnServer(fileCommentServer, tool, request)
|
||||
}
|
||||
|
||||
if !fetchAll {
|
||||
page, err := fetchFileCommentPage(tool, request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateFileCommentNextCursor(page, stringArg(request, "nextToken")); err != nil {
|
||||
return err
|
||||
}
|
||||
page.comments = filterFileCommentsByScope(page.comments, scope)
|
||||
return output.WriteCommandPayload(cmd, fileCommentListPayload(page, scope), output.FormatJSON)
|
||||
}
|
||||
|
||||
var aggregate fileCommentPage
|
||||
aggregate.comments = make([]map[string]any, 0)
|
||||
seenCursors := map[string]bool{}
|
||||
currentCursor := ""
|
||||
for pageNumber := 0; pageNumber < fileCommentMaxAutoPages; pageNumber++ {
|
||||
if currentCursor == "" {
|
||||
delete(request, "nextToken")
|
||||
} else {
|
||||
request["nextToken"] = currentCursor
|
||||
}
|
||||
page, err := fetchFileCommentPage(tool, request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if pageNumber == 0 {
|
||||
aggregate.nodeID = page.nodeID
|
||||
aggregate.total = page.total
|
||||
}
|
||||
aggregate.comments = append(aggregate.comments, filterFileCommentsByScope(page.comments, scope)...)
|
||||
if !page.hasMore {
|
||||
aggregate.hasMore = false
|
||||
aggregate.nextCursor = ""
|
||||
return output.WriteCommandPayload(cmd, fileCommentListPayload(aggregate, scope), output.FormatJSON)
|
||||
}
|
||||
if err := validateFileCommentNextCursor(page, currentCursor); err != nil {
|
||||
return err
|
||||
}
|
||||
if seenCursors[page.nextCursor] {
|
||||
return fileCommentPaginationError("分页游标发生循环,结果可能不完整")
|
||||
}
|
||||
seenCursors[page.nextCursor] = true
|
||||
currentCursor = page.nextCursor
|
||||
}
|
||||
return fileCommentPaginationError(fmt.Sprintf("自动翻页达到 %d 页上限但服务端仍返回 hasMore=true,结果可能不完整", fileCommentMaxAutoPages))
|
||||
}
|
||||
|
||||
func runFileCommentCreate(cmd *cobra.Command, tool string, args map[string]any) error {
|
||||
request := fileCommentMCPArgs(args)
|
||||
if deps != nil && deps.Caller != nil && deps.Caller.DryRun() {
|
||||
return callMCPToolOnServer(fileCommentServer, tool, request)
|
||||
}
|
||||
raw, err := callMCPToolReturnTextOnServer(context.Background(), fileCommentServer, tool, request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload, err := decodeFileCommentPayload(tool, raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nodeID, ok := nonEmptyStringField(payload, "fileId")
|
||||
if !ok {
|
||||
return invalidFileCommentResponse(tool, "缺少 fileId", nil)
|
||||
}
|
||||
comment, ok := payload["comment"].(map[string]any)
|
||||
if !ok {
|
||||
return invalidFileCommentResponse(tool, "缺少 comment 对象", nil)
|
||||
}
|
||||
if err := validateFileCommentItem(tool, "comment", comment); err != nil {
|
||||
return err
|
||||
}
|
||||
out := map[string]any{"nodeId": nodeID}
|
||||
for key, value := range projectFileComment(comment) {
|
||||
out[key] = value
|
||||
}
|
||||
return output.WriteCommandPayload(cmd, out, output.FormatJSON)
|
||||
}
|
||||
|
||||
func fileCommentMCPArgs(args map[string]any) map[string]any {
|
||||
out := make(map[string]any, len(args))
|
||||
for key, value := range args {
|
||||
switch key {
|
||||
case "all", "scope":
|
||||
continue
|
||||
default:
|
||||
out[key] = value
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func fetchFileCommentPage(tool string, request map[string]any) (fileCommentPage, error) {
|
||||
raw, err := callMCPToolReturnTextOnServer(context.Background(), fileCommentServer, tool, request)
|
||||
if err != nil {
|
||||
return fileCommentPage{}, err
|
||||
}
|
||||
payload, err := decodeFileCommentPayload(tool, raw)
|
||||
if err != nil {
|
||||
return fileCommentPage{}, err
|
||||
}
|
||||
nodeID, ok := nonEmptyStringField(payload, "fileId")
|
||||
if !ok {
|
||||
return fileCommentPage{}, invalidFileCommentResponse(tool, "缺少 fileId", nil)
|
||||
}
|
||||
total, ok := payload["total"]
|
||||
if !ok {
|
||||
return fileCommentPage{}, invalidFileCommentResponse(tool, "缺少 total", nil)
|
||||
}
|
||||
hasMore, ok := payload["hasMore"].(bool)
|
||||
if !ok {
|
||||
return fileCommentPage{}, invalidFileCommentResponse(tool, "缺少布尔字段 hasMore", nil)
|
||||
}
|
||||
nextCursor := ""
|
||||
if value, exists := payload["nextToken"]; exists && value != nil {
|
||||
var stringValue bool
|
||||
nextCursor, stringValue = value.(string)
|
||||
if !stringValue {
|
||||
return fileCommentPage{}, invalidFileCommentResponse(tool, "nextToken 不是字符串", nil)
|
||||
}
|
||||
}
|
||||
rawItems, exists := payload["items"]
|
||||
if !exists {
|
||||
return fileCommentPage{}, invalidFileCommentResponse(tool, "缺少 items", nil)
|
||||
}
|
||||
items, ok := rawItems.([]any)
|
||||
if rawItems == nil {
|
||||
items = []any{}
|
||||
ok = true
|
||||
}
|
||||
if !ok {
|
||||
return fileCommentPage{}, invalidFileCommentResponse(tool, "items 不是数组", nil)
|
||||
}
|
||||
comments := make([]map[string]any, 0, len(items))
|
||||
for index, item := range items {
|
||||
comment, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
return fileCommentPage{}, invalidFileCommentResponse(tool, fmt.Sprintf("items[%d] 不是对象", index), nil)
|
||||
}
|
||||
if err := validateFileCommentItem(tool, fmt.Sprintf("items[%d]", index), comment); err != nil {
|
||||
return fileCommentPage{}, err
|
||||
}
|
||||
comments = append(comments, projectFileComment(comment))
|
||||
}
|
||||
return fileCommentPage{
|
||||
nodeID: nodeID, total: total, hasMore: hasMore,
|
||||
nextCursor: strings.TrimSpace(nextCursor), comments: comments,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func decodeFileCommentPayload(tool, raw string) (map[string]any, error) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil, invalidFileCommentResponse(tool, "返回为空", nil)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
||||
return nil, invalidFileCommentResponse(tool, "返回不是有效 JSON", err)
|
||||
}
|
||||
for depth := 0; depth < 2; depth++ {
|
||||
if _, ok := payload["fileId"]; ok {
|
||||
break
|
||||
}
|
||||
var nested map[string]any
|
||||
for _, key := range []string{"result", "data"} {
|
||||
if value, ok := payload[key].(map[string]any); ok {
|
||||
nested = value
|
||||
break
|
||||
}
|
||||
}
|
||||
if nested == nil {
|
||||
break
|
||||
}
|
||||
payload = nested
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func projectFileComment(comment map[string]any) map[string]any {
|
||||
out := map[string]any{}
|
||||
for _, key := range []string{"commentId", "parentCommentId", "content", "createdAt", "updatedAt", "options", "anchor"} {
|
||||
if value, ok := comment[key]; ok {
|
||||
out[key] = value
|
||||
}
|
||||
}
|
||||
if value, ok := comment["commentCustomType"]; ok {
|
||||
out["customType"] = value
|
||||
}
|
||||
creator := map[string]any{}
|
||||
for source, target := range map[string]string{
|
||||
"creatorId": "userId", "creatorName": "name", "creatorAvatar": "avatar",
|
||||
} {
|
||||
if value, ok := comment[source]; ok {
|
||||
creator[target] = value
|
||||
}
|
||||
}
|
||||
if len(creator) > 0 {
|
||||
out["creator"] = creator
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func validateFileCommentItem(tool, path string, comment map[string]any) error {
|
||||
if _, ok := nonEmptyStringField(comment, "commentId"); !ok {
|
||||
return invalidFileCommentResponse(tool, path+" 缺少 commentId", nil)
|
||||
}
|
||||
if _, ok := comment["anchor"].(map[string]any); !ok {
|
||||
return invalidFileCommentResponse(tool, path+" 缺少 anchor 对象", nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func filterFileCommentsByScope(comments []map[string]any, scope string) []map[string]any {
|
||||
if scope == "" || scope == "all" {
|
||||
return comments
|
||||
}
|
||||
out := make([]map[string]any, 0, len(comments))
|
||||
for _, comment := range comments {
|
||||
anchor, _ := comment["anchor"].(map[string]any)
|
||||
commentScope, _ := anchor["scope"].(string)
|
||||
if commentScope == scope {
|
||||
out = append(out, comment)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func fileCommentListPayload(page fileCommentPage, scope string) map[string]any {
|
||||
comments := page.comments
|
||||
if comments == nil {
|
||||
comments = make([]map[string]any, 0)
|
||||
}
|
||||
nextCursor := any(nil)
|
||||
if page.nextCursor != "" {
|
||||
nextCursor = page.nextCursor
|
||||
}
|
||||
return map[string]any{
|
||||
"nodeId": page.nodeID,
|
||||
"total": page.total,
|
||||
"count": len(page.comments),
|
||||
"hasMore": page.hasMore,
|
||||
"nextCursor": nextCursor,
|
||||
"complete": !page.hasMore,
|
||||
"scope": scope,
|
||||
"comments": comments,
|
||||
}
|
||||
}
|
||||
|
||||
func validateFileCommentNextCursor(page fileCommentPage, currentCursor string) error {
|
||||
if !page.hasMore {
|
||||
return nil
|
||||
}
|
||||
if page.nextCursor == "" {
|
||||
return fileCommentPaginationError("服务端返回 hasMore=true 但 nextCursor 为空,结果可能不完整")
|
||||
}
|
||||
if !allASCIIDigits(page.nextCursor) {
|
||||
return fileCommentPaginationError("服务端返回的 nextCursor 不是非负数字游标,结果可能不完整")
|
||||
}
|
||||
if _, err := strconv.ParseInt(page.nextCursor, 10, 64); err != nil {
|
||||
return fileCommentPaginationError("服务端返回的 nextCursor 超出 64 位整数范围,结果可能不完整")
|
||||
}
|
||||
if page.nextCursor == currentCursor {
|
||||
return fileCommentPaginationError("服务端分页游标未前进,结果可能不完整")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func fileCommentPaginationError(message string) error {
|
||||
return &CLIError{
|
||||
Code: CodeContentTruncated,
|
||||
Message: message,
|
||||
Suggestion: "请稍后重试,或去掉 --all 后使用服务端返回的 --cursor 分页读取",
|
||||
Operation: fileCommentServer + "/" + listFileCommentsTool,
|
||||
}
|
||||
}
|
||||
|
||||
func invalidFileCommentResponse(tool, reason string, cause error) error {
|
||||
return &CLIError{
|
||||
Code: CodeMCPToolError,
|
||||
Message: fmt.Sprintf("%s 返回结构异常:%s", tool, reason),
|
||||
Suggestion: "请确认 doc-comment MCP 与当前 DWS 契约一致",
|
||||
Operation: fileCommentServer + "/" + tool,
|
||||
Cause: cause,
|
||||
}
|
||||
}
|
||||
|
||||
func nonEmptyStringField(payload map[string]any, key string) (string, bool) {
|
||||
value, ok := payload[key].(string)
|
||||
value = strings.TrimSpace(value)
|
||||
return value, ok && value != ""
|
||||
}
|
||||
|
||||
func stringArg(args map[string]any, key string) string {
|
||||
value, _ := args[key].(string)
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
@@ -0,0 +1,647 @@
|
||||
// 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 helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type fileCommentTestCall struct {
|
||||
server string
|
||||
tool string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
type fileCommentTestCaller struct {
|
||||
calls []fileCommentTestCall
|
||||
responses []string
|
||||
err error
|
||||
dryRun bool
|
||||
}
|
||||
|
||||
func (c *fileCommentTestCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
copied := make(map[string]any, len(args))
|
||||
for key, value := range args {
|
||||
copied[key] = value
|
||||
}
|
||||
c.calls = append(c.calls, fileCommentTestCall{server: server, tool: tool, args: copied})
|
||||
if c.err != nil {
|
||||
return nil, c.err
|
||||
}
|
||||
response := `{"result":{"fileId":"file-1","comment":{"commentId":"1","content":"ok","anchor":{"version":"v1","surface":"file","scope":"whole"}}}}`
|
||||
if len(c.responses) > 0 {
|
||||
response = c.responses[0]
|
||||
c.responses = c.responses[1:]
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: response}}}, nil
|
||||
}
|
||||
|
||||
func (c *fileCommentTestCaller) Format() string { return "json" }
|
||||
func (c *fileCommentTestCaller) DryRun() bool { return c.dryRun }
|
||||
func (c *fileCommentTestCaller) Fields() string { return "" }
|
||||
func (c *fileCommentTestCaller) JQ() string { return "" }
|
||||
|
||||
func executeFileCommentCommand(t *testing.T, caller *fileCommentTestCaller, stdin string, args ...string) ([]byte, error) {
|
||||
t.Helper()
|
||||
testseam.Protect(t, &deps)
|
||||
InitDeps(caller)
|
||||
var stdout bytes.Buffer
|
||||
deps.Out.w = &stdout
|
||||
deps.Out.errW = io.Discard
|
||||
|
||||
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
root.SetOut(&stdout)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetIn(strings.NewReader(stdin))
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.PersistentFlags().String("format", "json", "")
|
||||
drive := &cobra.Command{Use: "drive"}
|
||||
drive.AddCommand(newDriveFileCommentCmd())
|
||||
root.AddCommand(drive)
|
||||
var setOutput func(*cobra.Command)
|
||||
setOutput = func(command *cobra.Command) {
|
||||
command.SetOut(&stdout)
|
||||
command.SetErr(io.Discard)
|
||||
for _, child := range command.Commands() {
|
||||
setOutput(child)
|
||||
}
|
||||
}
|
||||
setOutput(root)
|
||||
root.SetArgs(args)
|
||||
err := root.Execute()
|
||||
return stdout.Bytes(), err
|
||||
}
|
||||
|
||||
func decodeFileCommentTestOutput(t *testing.T, raw []byte) map[string]any {
|
||||
t.Helper()
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||
t.Fatalf("decode output %q: %v", raw, err)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func TestDriveFileCommentListMapsFiltersAndProjects(t *testing.T) {
|
||||
caller := &fileCommentTestCaller{responses: []string{`{
|
||||
"result": {
|
||||
"fileId": "resolved-file",
|
||||
"total": 2,
|
||||
"count": 2,
|
||||
"hasMore": false,
|
||||
"nextToken": null,
|
||||
"items": [
|
||||
{
|
||||
"commentId": "101",
|
||||
"parentCommentId": null,
|
||||
"content": "whole",
|
||||
"creatorId": "user-1",
|
||||
"creatorName": "Alice",
|
||||
"creatorAvatar": "https://avatar/1",
|
||||
"createdAt": 1785920000000,
|
||||
"updatedAt": 1785920000100,
|
||||
"commentCustomType": "common",
|
||||
"anchor": {"version":"v1","surface":"file","scope":"whole"}
|
||||
},
|
||||
{
|
||||
"commentId": "102",
|
||||
"content": "partial",
|
||||
"commentCustomType": "highlight",
|
||||
"options": {"page":"1"},
|
||||
"anchor": {"version":"v1","surface":"file","scope":"partial","selector":{"kind":"legacy-highlight"}}
|
||||
}
|
||||
]
|
||||
}
|
||||
}`}}
|
||||
|
||||
out, err := executeFileCommentCommand(t, caller, "",
|
||||
"drive", "comment", "list",
|
||||
"--node", "https://alidocs.dingtalk.com/i/drive/file",
|
||||
"--space-id", "123", "--limit", "20", "--scope", "whole",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("calls = %#v", caller.calls)
|
||||
}
|
||||
call := caller.calls[0]
|
||||
if call.server != fileCommentServer || call.tool != listFileCommentsTool {
|
||||
t.Fatalf("target = %s/%s", call.server, call.tool)
|
||||
}
|
||||
wantArgs := map[string]any{
|
||||
"fileId": "https://alidocs.dingtalk.com/i/drive/file",
|
||||
"spaceId": "123",
|
||||
"maxResults": 20,
|
||||
}
|
||||
if !reflect.DeepEqual(call.args, wantArgs) {
|
||||
t.Fatalf("args = %#v, want %#v", call.args, wantArgs)
|
||||
}
|
||||
|
||||
payload := decodeFileCommentTestOutput(t, out)
|
||||
if payload["nodeId"] != "resolved-file" || payload["total"] != float64(2) ||
|
||||
payload["count"] != float64(1) || payload["complete"] != true || payload["scope"] != "whole" {
|
||||
t.Fatalf("list output = %#v", payload)
|
||||
}
|
||||
if payload["nextCursor"] != nil || payload["hasMore"] != false {
|
||||
t.Fatalf("pagination output = %#v", payload)
|
||||
}
|
||||
comments := payload["comments"].([]any)
|
||||
comment := comments[0].(map[string]any)
|
||||
if comment["commentId"] != "101" || comment["customType"] != "common" {
|
||||
t.Fatalf("comment projection = %#v", comment)
|
||||
}
|
||||
creator := comment["creator"].(map[string]any)
|
||||
if creator["userId"] != "user-1" || creator["name"] != "Alice" || creator["avatar"] != "https://avatar/1" {
|
||||
t.Fatalf("creator projection = %#v", creator)
|
||||
}
|
||||
if _, leaked := comment["creatorId"]; leaked {
|
||||
t.Fatalf("raw creator fields leaked: %#v", comment)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveFileCommentListKeepsEmptyCommentsAsArray(t *testing.T) {
|
||||
caller := &fileCommentTestCaller{responses: []string{`{
|
||||
"fileId":"file-1","total":0,"count":0,"hasMore":false,"nextToken":null,"items":[]
|
||||
}`}}
|
||||
|
||||
out, err := executeFileCommentCommand(t, caller, "",
|
||||
"drive", "comment", "list", "--node", "file-1",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload := decodeFileCommentTestOutput(t, out)
|
||||
comments, ok := payload["comments"].([]any)
|
||||
if !ok || len(comments) != 0 {
|
||||
t.Fatalf("comments = %#v, want an empty JSON array", payload["comments"])
|
||||
}
|
||||
if payload["total"] != float64(0) || payload["count"] != float64(0) ||
|
||||
payload["hasMore"] != false || payload["complete"] != true || payload["nextCursor"] != nil {
|
||||
t.Fatalf("empty list output = %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveFileCommentListAllAggregatesPages(t *testing.T) {
|
||||
caller := &fileCommentTestCaller{responses: []string{
|
||||
`{"fileId":"file-1","total":3,"count":2,"hasMore":true,"nextToken":"2","items":[
|
||||
{"commentId":"1","anchor":{"scope":"whole"}},
|
||||
{"commentId":"2","anchor":{"scope":"partial"}}
|
||||
]}`,
|
||||
`{"fileId":"file-1","total":3,"count":1,"hasMore":false,"nextToken":null,"items":[
|
||||
{"commentId":"3","anchor":{"scope":"partial"}}
|
||||
]}`,
|
||||
}}
|
||||
|
||||
out, err := executeFileCommentCommand(t, caller, "",
|
||||
"drive", "comment", "list", "--node", "file-1", "--all", "--scope", "partial",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 2 {
|
||||
t.Fatalf("calls = %#v", caller.calls)
|
||||
}
|
||||
if caller.calls[0].args["maxResults"] != fileCommentMaxPageSize {
|
||||
t.Fatalf("first args = %#v", caller.calls[0].args)
|
||||
}
|
||||
if _, ok := caller.calls[0].args["nextToken"]; ok {
|
||||
t.Fatalf("first page unexpectedly has cursor: %#v", caller.calls[0].args)
|
||||
}
|
||||
if caller.calls[1].args["nextToken"] != "2" {
|
||||
t.Fatalf("second args = %#v", caller.calls[1].args)
|
||||
}
|
||||
for _, call := range caller.calls {
|
||||
if _, ok := call.args["all"]; ok {
|
||||
t.Fatalf("local --all leaked to MCP: %#v", call.args)
|
||||
}
|
||||
if _, ok := call.args["scope"]; ok {
|
||||
t.Fatalf("local --scope leaked to MCP: %#v", call.args)
|
||||
}
|
||||
}
|
||||
|
||||
payload := decodeFileCommentTestOutput(t, out)
|
||||
if payload["total"] != float64(3) || payload["count"] != float64(2) ||
|
||||
payload["hasMore"] != false || payload["complete"] != true || payload["nextCursor"] != nil {
|
||||
t.Fatalf("aggregate output = %#v", payload)
|
||||
}
|
||||
comments := payload["comments"].([]any)
|
||||
if comments[0].(map[string]any)["commentId"] != "2" || comments[1].(map[string]any)["commentId"] != "3" {
|
||||
t.Fatalf("scope-filtered comments = %#v", comments)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveFileCommentListRejectsPaginationAnomalies(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
response string
|
||||
message string
|
||||
}{
|
||||
{
|
||||
name: "missing cursor",
|
||||
args: []string{"drive", "comment", "list", "--node", "file-1", "--all"},
|
||||
response: `{"fileId":"file-1","total":1,"count":1,"hasMore":true,"nextToken":null,"items":[]}`,
|
||||
message: "nextCursor 为空",
|
||||
},
|
||||
{
|
||||
name: "stalled cursor",
|
||||
args: []string{"drive", "comment", "list", "--node", "file-1", "--cursor", "2"},
|
||||
response: `{"fileId":"file-1","total":2,"count":1,"hasMore":true,"nextToken":"2","items":[]}`,
|
||||
message: "游标未前进",
|
||||
},
|
||||
{
|
||||
name: "invalid server cursor",
|
||||
args: []string{"drive", "comment", "list", "--node", "file-1"},
|
||||
response: `{"fileId":"file-1","total":2,"count":1,"hasMore":true,"nextToken":"next","items":[]}`,
|
||||
message: "不是非负数字游标",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
caller := &fileCommentTestCaller{responses: []string{tt.response}}
|
||||
_, err := executeFileCommentCommand(t, caller, "", tt.args...)
|
||||
var cliErr *CLIError
|
||||
if !errors.As(err, &cliErr) || cliErr.Code != CodeContentTruncated || !strings.Contains(cliErr.Message, tt.message) {
|
||||
t.Fatalf("error = %#v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("calls = %#v", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveFileCommentListValidatesLocalParameters(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{"all with cursor", []string{"--all", "--cursor", "1"}},
|
||||
{"all with limit", []string{"--all", "--limit", "20"}},
|
||||
{"zero limit", []string{"--limit", "0"}},
|
||||
{"large limit", []string{"--page-size", "201"}},
|
||||
{"nonnumeric cursor", []string{"--cursor", "next"}},
|
||||
{"overflow cursor", []string{"--cursor", "999999999999999999999999999999"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
caller := &fileCommentTestCaller{}
|
||||
args := []string{"drive", "comment", "list", "--node", "file-1"}
|
||||
args = append(args, tt.args...)
|
||||
if _, err := executeFileCommentCommand(t, caller, "", args...); err == nil {
|
||||
t.Fatal("invalid arguments unexpectedly succeeded")
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("invalid arguments reached MCP: %#v", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveFileCommentNumericNodeRequiresSpaceIDForListAndCreateAliases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{
|
||||
name: "list id alias",
|
||||
args: []string{"drive", "comment", "list", "--id", "231773999335"},
|
||||
},
|
||||
{
|
||||
name: "create file-id alias",
|
||||
args: []string{"drive", "comment", "create", "--file-id", "231773999335", "--content", "test", "--yes"},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
caller := &fileCommentTestCaller{}
|
||||
_, err := executeFileCommentCommand(t, caller, "", tt.args...)
|
||||
var cliErr *CLIError
|
||||
if !errors.As(err, &cliErr) || cliErr.Code != CodeInvalidParam || !strings.Contains(cliErr.Message, "--space-id") {
|
||||
t.Fatalf("error = %#v", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("numeric node without space-id reached MCP: %#v", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
caller := &fileCommentTestCaller{responses: []string{`{
|
||||
"fileId":"resolved-file","total":0,"count":0,"hasMore":false,"nextToken":null,"items":[]
|
||||
}`}}
|
||||
if _, err := executeFileCommentCommand(t, caller, "",
|
||||
"drive", "comment", "list", "--url", "231773999335", "--space-id", "2402756201",
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].args["fileId"] != "231773999335" || caller.calls[0].args["spaceId"] != "2402756201" {
|
||||
t.Fatalf("numeric node with space-id args = %#v", caller.calls)
|
||||
}
|
||||
if allASCIIDigits("") {
|
||||
t.Fatal("empty string unexpectedly accepted as numeric")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveFileCommentCreateValidatesMapsAndProjects(t *testing.T) {
|
||||
validContent := strings.Repeat("a", fileCommentMaxContentLength)
|
||||
caller := &fileCommentTestCaller{responses: []string{`{"result":{"fileId":"resolved-file","comment":{
|
||||
"commentId":"912345","parentCommentId":null,"content":"created","creatorId":"user-1",
|
||||
"createdAt":1785920000000,"commentCustomType":"common",
|
||||
"anchor":{"version":"v1","surface":"file","scope":"whole","selector":null}
|
||||
}}}`}}
|
||||
out, err := executeFileCommentCommand(t, caller, "",
|
||||
"drive", "comment", "create", "--node", "file-1", "--space-id", "123",
|
||||
"--content", validContent, "--yes",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].server != fileCommentServer || caller.calls[0].tool != createFileCommentTool {
|
||||
t.Fatalf("calls = %#v", caller.calls)
|
||||
}
|
||||
wantArgs := map[string]any{"fileId": "file-1", "spaceId": "123", "content": validContent}
|
||||
if !reflect.DeepEqual(caller.calls[0].args, wantArgs) {
|
||||
t.Fatalf("args = %#v, want %#v", caller.calls[0].args, wantArgs)
|
||||
}
|
||||
payload := decodeFileCommentTestOutput(t, out)
|
||||
if payload["nodeId"] != "resolved-file" || payload["commentId"] != "912345" ||
|
||||
payload["content"] != "created" || payload["customType"] != "common" {
|
||||
t.Fatalf("create output = %#v", payload)
|
||||
}
|
||||
if _, nested := payload["comment"]; nested {
|
||||
t.Fatalf("create output was not flattened: %#v", payload)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
content string
|
||||
}{
|
||||
{"blank", " \t"},
|
||||
{"ascii over limit", strings.Repeat("a", fileCommentMaxContentLength+1)},
|
||||
{"utf16 over limit", strings.Repeat("😀", 1050)},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
invalidCaller := &fileCommentTestCaller{}
|
||||
_, err := executeFileCommentCommand(t, invalidCaller, "",
|
||||
"drive", "comment", "create", "--node", "file-1", "--content", tc.content, "--yes",
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("invalid content unexpectedly succeeded")
|
||||
}
|
||||
if len(invalidCaller.calls) != 0 {
|
||||
t.Fatalf("invalid content reached MCP: %#v", invalidCaller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveFileCommentCreatePublishesAndEnforcesConfirmation(t *testing.T) {
|
||||
group := newDriveFileCommentCmd()
|
||||
create, remaining, err := group.Find([]string{"create"})
|
||||
if err != nil || len(remaining) != 0 {
|
||||
t.Fatalf("find create: remaining=%v err=%v", remaining, err)
|
||||
}
|
||||
final, ok := contractfinal.RuntimeContractFinal(create)
|
||||
if !ok || final.Safety == nil {
|
||||
t.Fatal("create command is missing ContractFinal Safety")
|
||||
}
|
||||
if safety := *final.Safety; safety.Effect != "write" || safety.Risk != "medium" ||
|
||||
safety.Confirmation != "user_required" || safety.Idempotency != "unknown" {
|
||||
t.Fatalf("create safety = %#v", safety)
|
||||
}
|
||||
|
||||
caller := &fileCommentTestCaller{}
|
||||
_, err = executeFileCommentCommand(t, caller, "",
|
||||
"drive", "comment", "create", "--node", "file-1", "--content", "需要确认",
|
||||
)
|
||||
var appErr *apperrors.Error
|
||||
if !errors.As(err, &appErr) || appErr.Reason != "confirmation_required" {
|
||||
t.Fatalf("closed-stdin error = %#v", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("unconfirmed create reached MCP: %#v", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveFileCommentRejectsMalformedResponseInsteadOfReturningEmpty(t *testing.T) {
|
||||
caller := &fileCommentTestCaller{responses: []string{`{"result":{"fileId":"file-1","total":0,"hasMore":false}}`}}
|
||||
_, err := executeFileCommentCommand(t, caller, "", "drive", "comment", "list", "--node", "file-1")
|
||||
var cliErr *CLIError
|
||||
if !errors.As(err, &cliErr) || cliErr.Code != CodeMCPToolError || !strings.Contains(cliErr.Message, "缺少 items") {
|
||||
t.Fatalf("error = %#v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveFileCommentDryRunAndCallErrors(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
args []string
|
||||
tool string
|
||||
}{
|
||||
{
|
||||
name: "list dry run",
|
||||
args: []string{"drive", "comment", "list", "--node", "file-1"},
|
||||
tool: listFileCommentsTool,
|
||||
},
|
||||
{
|
||||
name: "create dry run",
|
||||
args: []string{"drive", "comment", "create", "--node", "file-1", "--content", "test", "--yes"},
|
||||
tool: createFileCommentTool,
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
caller := &fileCommentTestCaller{dryRun: true}
|
||||
out, err := executeFileCommentCommand(t, caller, "", tt.args...)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 0 || !strings.Contains(string(out), `"tool": "`+tt.tool+`"`) {
|
||||
t.Fatalf("calls = %#v, output = %s", caller.calls, out)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
sentinel := errors.New("mcp unavailable")
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{
|
||||
name: "list call error",
|
||||
args: []string{"drive", "comment", "list", "--node", "file-1", "--all"},
|
||||
},
|
||||
{
|
||||
name: "create call error",
|
||||
args: []string{"drive", "comment", "create", "--node", "file-1", "--content", "test", "--yes"},
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
caller := &fileCommentTestCaller{err: sentinel}
|
||||
_, err := executeFileCommentCommand(t, caller, "", tt.args...)
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Fatalf("error = %#v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveFileCommentListResponseValidationBranches(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
response string
|
||||
message string
|
||||
}{
|
||||
{name: "empty", response: "", message: "返回为空"},
|
||||
{name: "invalid json", response: "{", message: "不是有效 JSON"},
|
||||
{name: "missing file id", response: `{}`, message: "缺少 fileId"},
|
||||
{name: "missing total", response: `{"fileId":"file-1","hasMore":false,"items":[]}`, message: "缺少 total"},
|
||||
{name: "missing has more", response: `{"fileId":"file-1","total":0,"items":[]}`, message: "缺少布尔字段 hasMore"},
|
||||
{name: "next token type", response: `{"fileId":"file-1","total":1,"hasMore":true,"nextToken":2,"items":[]}`, message: "nextToken 不是字符串"},
|
||||
{name: "items type", response: `{"fileId":"file-1","total":0,"hasMore":false,"items":{}}`, message: "items 不是数组"},
|
||||
{name: "item type", response: `{"fileId":"file-1","total":1,"hasMore":false,"items":[1]}`, message: "items[0] 不是对象"},
|
||||
{name: "missing comment id", response: `{"fileId":"file-1","total":1,"hasMore":false,"items":[{"anchor":{}}]}`, message: "缺少 commentId"},
|
||||
{name: "missing anchor", response: `{"fileId":"file-1","total":1,"hasMore":false,"items":[{"commentId":"1"}]}`, message: "缺少 anchor 对象"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
caller := &fileCommentTestCaller{responses: []string{tt.response}}
|
||||
_, err := executeFileCommentCommand(t, caller, "", "drive", "comment", "list", "--node", "file-1")
|
||||
var cliErr *CLIError
|
||||
if !errors.As(err, &cliErr) || cliErr.Code != CodeMCPToolError || !strings.Contains(cliErr.Message, tt.message) {
|
||||
t.Fatalf("error = %#v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
caller := &fileCommentTestCaller{responses: []string{`{
|
||||
"data":{"fileId":"file-1","total":0,"hasMore":false,"nextToken":null,"items":null}
|
||||
}`}}
|
||||
out, err := executeFileCommentCommand(t, caller, "", "drive", "comment", "list", "--node", "file-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if comments := decodeFileCommentTestOutput(t, out)["comments"].([]any); len(comments) != 0 {
|
||||
t.Fatalf("comments = %#v", comments)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveFileCommentCreateResponseValidationBranches(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
response string
|
||||
message string
|
||||
}{
|
||||
{name: "invalid json", response: "{", message: "不是有效 JSON"},
|
||||
{name: "missing file id", response: `{"comment":{"commentId":"1","anchor":{}}}`, message: "缺少 fileId"},
|
||||
{name: "missing comment", response: `{"fileId":"file-1"}`, message: "缺少 comment 对象"},
|
||||
{name: "missing comment id", response: `{"fileId":"file-1","comment":{"anchor":{}}}`, message: "缺少 commentId"},
|
||||
{name: "missing anchor", response: `{"fileId":"file-1","comment":{"commentId":"1"}}`, message: "缺少 anchor 对象"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
caller := &fileCommentTestCaller{responses: []string{tt.response}}
|
||||
_, err := executeFileCommentCommand(t, caller, "",
|
||||
"drive", "comment", "create", "--node", "file-1", "--content", "test", "--yes",
|
||||
)
|
||||
var cliErr *CLIError
|
||||
if !errors.As(err, &cliErr) || cliErr.Code != CodeMCPToolError || !strings.Contains(cliErr.Message, tt.message) {
|
||||
t.Fatalf("error = %#v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveFileCommentPaginationCycleLimitAndPartialPage(t *testing.T) {
|
||||
cycleCaller := &fileCommentTestCaller{responses: []string{
|
||||
`{"fileId":"file-1","total":0,"hasMore":true,"nextToken":"2","items":[]}`,
|
||||
`{"fileId":"file-1","total":0,"hasMore":true,"nextToken":"3","items":[]}`,
|
||||
`{"fileId":"file-1","total":0,"hasMore":true,"nextToken":"2","items":[]}`,
|
||||
}}
|
||||
_, err := executeFileCommentCommand(t, cycleCaller, "", "drive", "comment", "list", "--node", "file-1", "--all")
|
||||
var cliErr *CLIError
|
||||
if !errors.As(err, &cliErr) || cliErr.Code != CodeContentTruncated || !strings.Contains(cliErr.Message, "发生循环") {
|
||||
t.Fatalf("cycle error = %#v", err)
|
||||
}
|
||||
|
||||
limitResponses := make([]string, 0, fileCommentMaxAutoPages)
|
||||
for index := 0; index < fileCommentMaxAutoPages; index++ {
|
||||
limitResponses = append(limitResponses, fmt.Sprintf(
|
||||
`{"fileId":"file-1","total":0,"hasMore":true,"nextToken":"%d","items":[]}`,
|
||||
index+1,
|
||||
))
|
||||
}
|
||||
limitCaller := &fileCommentTestCaller{responses: limitResponses}
|
||||
_, err = executeFileCommentCommand(t, limitCaller, "", "drive", "comment", "list", "--node", "file-1", "--all")
|
||||
if !errors.As(err, &cliErr) || cliErr.Code != CodeContentTruncated || !strings.Contains(cliErr.Message, "10 页上限") {
|
||||
t.Fatalf("page limit error = %#v", err)
|
||||
}
|
||||
|
||||
partialCaller := &fileCommentTestCaller{responses: []string{`{
|
||||
"fileId":"file-1","total":1,"hasMore":true,"nextToken":"2",
|
||||
"items":[{"commentId":"1","anchor":{"scope":"whole"}}]
|
||||
}`}}
|
||||
out, err := executeFileCommentCommand(t, partialCaller, "", "drive", "comment", "list", "--node", "file-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload := decodeFileCommentTestOutput(t, out)
|
||||
if payload["nextCursor"] != "2" || payload["complete"] != false || payload["hasMore"] != true {
|
||||
t.Fatalf("partial page = %#v", payload)
|
||||
}
|
||||
|
||||
overflowCaller := &fileCommentTestCaller{responses: []string{`{
|
||||
"fileId":"file-1","total":0,"hasMore":true,
|
||||
"nextToken":"999999999999999999999999999999","items":[]
|
||||
}`}}
|
||||
_, err = executeFileCommentCommand(t, overflowCaller, "", "drive", "comment", "list", "--node", "file-1")
|
||||
if !errors.As(err, &cliErr) || cliErr.Code != CodeContentTruncated || !strings.Contains(cliErr.Message, "超出 64 位整数范围") {
|
||||
t.Fatalf("overflow cursor error = %#v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveFileCommentInternalDefaults(t *testing.T) {
|
||||
caller := &fileCommentTestCaller{responses: []string{`{
|
||||
"fileId":"file-1","total":0,"hasMore":false,"nextToken":null,"items":[]
|
||||
}`}}
|
||||
testseam.Protect(t, &deps)
|
||||
InitDeps(caller)
|
||||
var stdout bytes.Buffer
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
cmd.SetOut(&stdout)
|
||||
if err := runFileCommentList(cmd, listFileCommentsTool, map[string]any{"fileId": "file-1", "maxResults": 200}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload := decodeFileCommentTestOutput(t, stdout.Bytes())
|
||||
if payload["scope"] != "all" {
|
||||
t.Fatalf("scope = %#v", payload["scope"])
|
||||
}
|
||||
if comments := fileCommentListPayload(fileCommentPage{}, "all")["comments"].([]map[string]any); len(comments) != 0 {
|
||||
t.Fatalf("nil comments projection = %#v", comments)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -1322,10 +1325,13 @@ ORG 类型授权不会出现在查询结果中。`,
|
||||
|
||||
通过 --workspace 指定知识库,支持传入知识库 ID 或知识库 URL。
|
||||
支持分页,通过 --cursor 传入上次返回的 nextToken 获取下一页。
|
||||
|
||||
权限要求:调用者需具备知识库的成员权限,非成员会被拒绝访问。`,
|
||||
使用 --exclude-file 可排除普通文件、媒体文件、文件夹及 Office 文件动态,仅保留在线文档操作(创建/更新/评论/点赞等)。
|
||||
当用户要求"只看文档操作""排除文件上传"等意图时,必须使用此 flag,
|
||||
禁止在客户端自行过滤。`,
|
||||
Example: ` dws wiki feed list --workspace <workspaceId>
|
||||
dws wiki feed list --workspace <workspaceId> --limit 10
|
||||
dws wiki feed list --workspace <workspaceId> --exclude-file
|
||||
dws wiki feed list --workspace <workspaceId> --limit 10 --exclude-file
|
||||
dws wiki feed list --workspace <workspaceId> --cursor <nextToken>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
workspaceID, err := mustFlagOrFallback(cmd, "workspace", "workspace-id")
|
||||
@@ -1345,7 +1351,19 @@ ORG 类型授权不会出现在查询结果中。`,
|
||||
v, _ := cmd.Flags().GetBool("exclude-file")
|
||||
toolArgs["excludeFile"] = v
|
||||
}
|
||||
return callMCPTool("list_workspace_feeds", toolArgs)
|
||||
// 调用后对 feeds[].time 毫秒时间戳做本地化格式化,
|
||||
// 使 Agent 直接看到可读时间,无需自行转换
|
||||
text, err := callMCPToolReturnText(context.Background(), "list_workspace_feeds", toolArgs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 保持与共享 dispatcher (callMCPToolInternalOpts) 一致的输出契约:
|
||||
// 仅 --format json 做时间/标签增强,raw、table 及其他格式输出原始 MCP 文本
|
||||
if deps.Caller.Format() != "json" {
|
||||
deps.Out.PrintRaw(text)
|
||||
return nil
|
||||
}
|
||||
return deps.Out.PrintJSON(formatFeedTime(text))
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(feedListCmd, LeafSpec{
|
||||
@@ -1391,9 +1409,9 @@ ORG 类型授权不会出现在查询结果中。`,
|
||||
},
|
||||
})
|
||||
feedListCmd.Flags().String("workspace", "", "知识库 ID 或 URL (必填)")
|
||||
feedListCmd.Flags().Int("limit", 0, "每页数量 (默认 20,最大 50)")
|
||||
feedListCmd.Flags().Int("limit", 0, "每页数量 (默认 10,最大 20)。用户未明确要求条数时禁止加此 flag,让服务端走默认 10")
|
||||
feedListCmd.Flags().String("cursor", "", "分页游标 (首页留空)")
|
||||
feedListCmd.Flags().Bool("exclude-file", false, "是否排除文件相关的动态 (默认 false)")
|
||||
feedListCmd.Flags().Bool("exclude-file", false, "排除普通文件、媒体文件、文件夹及 Office 文件动态,仅保留在线文档操作(创建/更新/评论/点赞)。用户要求排除文件/只看文档操作时必须使用此 flag,禁止客户端过滤")
|
||||
feedListCmd.Flags().String("workspace-id", "", "")
|
||||
_ = feedListCmd.Flags().MarkHidden("workspace-id")
|
||||
RegisterCrossProductAliases(feedListCmd)
|
||||
@@ -1430,3 +1448,62 @@ ORG 类型授权不会出现在查询结果中。`,
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
// beijingLoc 用于将时间戳格式化为北京时间(UTC+8)
|
||||
var beijingLoc = time.FixedZone("CST", 8*3600)
|
||||
|
||||
// formatFeedTime 将知识库动态 feeds[].time 毫秒时间戳转为可读时间字符串,
|
||||
// 写入 timeFormatted 字段,同时保留原始 time 毫秒时间戳不变(避免破坏已有脚本)。
|
||||
// 解析失败时原样返回,不阻断输出。
|
||||
func formatFeedTime(raw string) any {
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &result); err != nil {
|
||||
return raw
|
||||
}
|
||||
feeds, ok := result["feeds"].([]any)
|
||||
if !ok {
|
||||
return result
|
||||
}
|
||||
for _, f := range feeds {
|
||||
item, ok := f.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
ms, ok := item["time"].(float64)
|
||||
if !ok || ms <= 0 {
|
||||
continue
|
||||
}
|
||||
// 保留原始 time 毫秒时间戳,新增 timeFormatted 可读字段
|
||||
item["timeFormatted"] = time.UnixMilli(int64(ms)).In(beijingLoc).Format("2006-01-02 15:04")
|
||||
enrichFeedFields(item)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// enrichFeedFields 为单条 feed 补充可读字段(typeLabel),
|
||||
func enrichFeedFields(item map[string]any) {
|
||||
// 将 type 数字映射为可读标签,使 Agent 无需查表即可直接展示
|
||||
if typeNum, ok := item["type"].(float64); ok {
|
||||
if label, known := feedTypeLabels[int(typeNum)]; known {
|
||||
item["typeLabel"] = label
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// feedTypeLabels 将知识库动态 type 数字映射为可读中文标签,
|
||||
// 与 lippi-combo OpenFeedItemDTO.type 枚举对齐。
|
||||
var feedTypeLabels = map[int]string{
|
||||
0: "创建文档",
|
||||
1: "更新文档",
|
||||
2: "评论文档",
|
||||
3: "点赞文档",
|
||||
4: "加入团队空间",
|
||||
5: "表格选区数据变更",
|
||||
6: "更新 office 文件",
|
||||
7: "上传普通文件",
|
||||
8: "上传媒体文件",
|
||||
9: "上传文件夹",
|
||||
10: "上传文件夹 V2",
|
||||
11: "加入团队",
|
||||
12: "创建知识库",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,582 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ---------- formatFeedTime unit tests ----------
|
||||
|
||||
func TestCrossPlatformCoverageFormatFeedTimeInvalidJSON(t *testing.T) {
|
||||
// JSON 解析失败 → 原样返回原始字符串
|
||||
raw := "not-json"
|
||||
got := formatFeedTime(raw)
|
||||
s, ok := got.(string)
|
||||
if !ok || s != raw {
|
||||
t.Fatalf("formatFeedTime(invalid) = %v (%T), want raw string %q", got, got, raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFormatFeedTimeEmptyInput(t *testing.T) {
|
||||
// 空字符串 → JSON 解析失败 → 原样返回
|
||||
got := formatFeedTime("")
|
||||
s, ok := got.(string)
|
||||
if !ok || s != "" {
|
||||
t.Fatalf("formatFeedTime(\"\") = %v (%T), want \"\"", got, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFormatFeedTimeNoFeedsKey(t *testing.T) {
|
||||
// 合法 JSON 但没有 feeds 字段 → 返回解析后的 map
|
||||
raw := `{"status":"ok","count":0}`
|
||||
got := formatFeedTime(raw)
|
||||
m, ok := got.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("formatFeedTime(no feeds) = %T, want map[string]any", got)
|
||||
}
|
||||
if m["status"] != "ok" {
|
||||
t.Fatalf("status = %v, want ok", m["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFormatFeedTimeFeedsNotArray(t *testing.T) {
|
||||
// feeds 不是数组 → 返回解析后的 map
|
||||
raw := `{"feeds":"unexpected-string"}`
|
||||
got := formatFeedTime(raw)
|
||||
m, ok := got.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("formatFeedTime(feeds string) = %T, want map[string]any", got)
|
||||
}
|
||||
if m["feeds"] != "unexpected-string" {
|
||||
t.Fatalf("feeds = %v, want unexpected-string", m["feeds"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFormatFeedTimeFeedsNull(t *testing.T) {
|
||||
// feeds 为 null → 类型断言失败 → 返回解析后的 map
|
||||
raw := `{"feeds":null}`
|
||||
got := formatFeedTime(raw)
|
||||
_, ok := got.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("formatFeedTime(feeds null) = %T, want map[string]any", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFormatFeedTimeNonMapItem(t *testing.T) {
|
||||
// feeds 数组元素不是 object → continue 跳过
|
||||
raw := `{"feeds":["string-item", 42, null]}`
|
||||
got := formatFeedTime(raw)
|
||||
m, ok := got.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected map, got %T", got)
|
||||
}
|
||||
feeds := m["feeds"].([]any)
|
||||
if len(feeds) != 3 {
|
||||
t.Fatalf("feeds length = %d, want 3", len(feeds))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFormatFeedTimeTimeMissingOrInvalid(t *testing.T) {
|
||||
// time 字段缺失或不是 float64 或 <= 0 → 跳过格式化
|
||||
raw := `{"feeds":[
|
||||
{"id":"a"},
|
||||
{"id":"b","time":"not-a-number"},
|
||||
{"id":"c","time":0},
|
||||
{"id":"d","time":-1000}
|
||||
]}`
|
||||
got := formatFeedTime(raw)
|
||||
m, ok := got.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected map, got %T", got)
|
||||
}
|
||||
feeds := m["feeds"].([]any)
|
||||
for i, f := range feeds {
|
||||
item := f.(map[string]any)
|
||||
if _, hasTimeMs := item["timeMs"]; hasTimeMs {
|
||||
t.Fatalf("feed[%d] should not have timeMs, got %v", i, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFormatFeedTimeNormalPath(t *testing.T) {
|
||||
// 正常路径:time 保留原始毫秒时间戳,timeFormatted 被格式化为北京时间
|
||||
// 1750067400000 ms = 2025-06-16 17:50 Beijing time (UTC+8)
|
||||
tsMs := float64(1750067400000)
|
||||
raw := buildFeedJSON(t, []map[string]any{
|
||||
{"time": tsMs, "type": float64(1), "id": "f1"},
|
||||
})
|
||||
|
||||
got := formatFeedTime(raw)
|
||||
m, ok := got.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected map, got %T", got)
|
||||
}
|
||||
feeds := m["feeds"].([]any)
|
||||
if len(feeds) != 1 {
|
||||
t.Fatalf("feeds length = %d, want 1", len(feeds))
|
||||
}
|
||||
item := feeds[0].(map[string]any)
|
||||
|
||||
// time 保留原始毫秒时间戳不变
|
||||
if ms, ok := item["time"].(float64); !ok || ms != tsMs {
|
||||
t.Fatalf("time = %v (%T), want original ms %v", item["time"], item["time"], tsMs)
|
||||
}
|
||||
|
||||
// timeFormatted 被格式化为北京时间字符串(硬编码期望值)
|
||||
timeStr, ok := item["timeFormatted"].(string)
|
||||
if !ok {
|
||||
t.Fatalf("timeFormatted = %T, want string", item["timeFormatted"])
|
||||
}
|
||||
if timeStr != "2025-06-16 17:50" {
|
||||
t.Fatalf("timeFormatted = %q, want %q", timeStr, "2025-06-16 17:50")
|
||||
}
|
||||
|
||||
// typeLabel 被填充
|
||||
if item["typeLabel"] != "更新文档" {
|
||||
t.Fatalf("typeLabel = %v, want 更新文档", item["typeLabel"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFormatFeedTimeMultipleFeeds(t *testing.T) {
|
||||
// 多个 feed,混合有效/无效时间戳
|
||||
ts1 := float64(1750067400000)
|
||||
ts2 := float64(1700000000000)
|
||||
raw := buildFeedJSON(t, []map[string]any{
|
||||
{"time": ts1, "type": float64(0), "id": "f1"},
|
||||
{"time": ts2, "type": float64(12), "id": "f2"},
|
||||
{"id": "f3"}, // 没有 time → 跳过
|
||||
})
|
||||
|
||||
got := formatFeedTime(raw)
|
||||
m := got.(map[string]any)
|
||||
feeds := m["feeds"].([]any)
|
||||
|
||||
// 前两个有时间戳
|
||||
for i := 0; i < 2; i++ {
|
||||
item := feeds[i].(map[string]any)
|
||||
if _, ok := item["time"].(float64); !ok {
|
||||
t.Fatalf("feed[%d] time should remain float64, got %T", i, item["time"])
|
||||
}
|
||||
if _, ok := item["timeFormatted"].(string); !ok {
|
||||
t.Fatalf("feed[%d] timeFormatted not string", i)
|
||||
}
|
||||
}
|
||||
|
||||
// 第三个没有时间戳
|
||||
item3 := feeds[2].(map[string]any)
|
||||
if _, hasTimeFormatted := item3["timeFormatted"]; hasTimeFormatted {
|
||||
t.Fatalf("feed[2] should not have timeFormatted")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- enrichFeedFields unit tests ----------
|
||||
|
||||
func TestCrossPlatformCoverageEnrichFeedFieldsPreservesAllFields(t *testing.T) {
|
||||
// enrichFeedFields 不删除任何字段,仅新增 typeLabel
|
||||
item := map[string]any{
|
||||
"parentDoc": map[string]any{"name": "parent"},
|
||||
"userInfo": map[string]any{"nick": "user1"},
|
||||
"id": "f1",
|
||||
"type": float64(1),
|
||||
}
|
||||
enrichFeedFields(item)
|
||||
if _, ok := item["parentDoc"]; !ok {
|
||||
t.Fatal("parentDoc should be preserved")
|
||||
}
|
||||
if _, ok := item["userInfo"]; !ok {
|
||||
t.Fatal("userInfo should be preserved")
|
||||
}
|
||||
if item["id"] != "f1" {
|
||||
t.Fatal("id should be preserved")
|
||||
}
|
||||
if item["typeLabel"] != "更新文档" {
|
||||
t.Fatalf("typeLabel = %v, want 更新文档", item["typeLabel"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEnrichFeedFieldsTypeLabelKnown(t *testing.T) {
|
||||
// 已知 type → 添加 typeLabel
|
||||
for typeNum, expectedLabel := range feedTypeLabels {
|
||||
item := map[string]any{"type": float64(typeNum)}
|
||||
enrichFeedFields(item)
|
||||
if item["typeLabel"] != expectedLabel {
|
||||
t.Fatalf("type %d: typeLabel = %v, want %q", typeNum, item["typeLabel"], expectedLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEnrichFeedFieldsTypeLabelUnknown(t *testing.T) {
|
||||
// 未知 type → 不添加 typeLabel
|
||||
item := map[string]any{"type": float64(999)}
|
||||
enrichFeedFields(item)
|
||||
if _, ok := item["typeLabel"]; ok {
|
||||
t.Fatal("typeLabel should not be set for unknown type 999")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEnrichFeedFieldsTypeNotFloat(t *testing.T) {
|
||||
// type 不是 float64 → 不添加 typeLabel
|
||||
item := map[string]any{"type": "string-type"}
|
||||
enrichFeedFields(item)
|
||||
if _, ok := item["typeLabel"]; ok {
|
||||
t.Fatal("typeLabel should not be set for non-float type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEnrichFeedFieldsUsersPreserved(t *testing.T) {
|
||||
// users 数组中的 user 对象所有字段保留不变
|
||||
item := map[string]any{
|
||||
"users": []any{
|
||||
map[string]any{"nick": "Alice", "avatarUrl": "http://img", "userId": "u1"},
|
||||
map[string]any{"nick": "Bob", "email": "bob@test"},
|
||||
},
|
||||
}
|
||||
enrichFeedFields(item)
|
||||
users := item["users"].([]any)
|
||||
u0 := users[0].(map[string]any)
|
||||
if u0["avatarUrl"] != "http://img" {
|
||||
t.Fatalf("users[0].avatarUrl should be preserved, got %v", u0["avatarUrl"])
|
||||
}
|
||||
if u0["userId"] != "u1" {
|
||||
t.Fatalf("users[0].userId should be preserved, got %v", u0["userId"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEnrichFeedFieldsUsersNonMapItem(t *testing.T) {
|
||||
// users 数组中有非 map 元素 → 不 panic
|
||||
item := map[string]any{
|
||||
"users": []any{"not-a-map", 42, nil},
|
||||
}
|
||||
enrichFeedFields(item) // 不应 panic
|
||||
users := item["users"].([]any)
|
||||
if len(users) != 3 {
|
||||
t.Fatalf("users length changed: %d", len(users))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEnrichFeedFieldsContentPreserved(t *testing.T) {
|
||||
// content 和 content.doc 所有字段保留不变
|
||||
item := map[string]any{
|
||||
"content": map[string]any{
|
||||
"doc": map[string]any{
|
||||
"name": "测试文档",
|
||||
"extension": "adoc",
|
||||
"docKey": "dk123",
|
||||
"url": "http://example",
|
||||
},
|
||||
"dentryKey": "entry1",
|
||||
"workspaceKey": "ws1",
|
||||
},
|
||||
}
|
||||
enrichFeedFields(item)
|
||||
content := item["content"].(map[string]any)
|
||||
if content["dentryKey"] != "entry1" {
|
||||
t.Fatalf("content.dentryKey = %v, want entry1", content["dentryKey"])
|
||||
}
|
||||
if content["workspaceKey"] != "ws1" {
|
||||
t.Fatalf("content.workspaceKey = %v, want ws1", content["workspaceKey"])
|
||||
}
|
||||
doc := content["doc"].(map[string]any)
|
||||
if doc["name"] != "测试文档" {
|
||||
t.Fatalf("doc.name = %v, want 测试文档", doc["name"])
|
||||
}
|
||||
if doc["docKey"] != "dk123" {
|
||||
t.Fatalf("doc.docKey = %v, want dk123", doc["docKey"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEnrichFeedFieldsNoContentNoUsers(t *testing.T) {
|
||||
// 没有 content / users / type → 所有字段保留
|
||||
item := map[string]any{
|
||||
"id": "f1",
|
||||
"parentDoc": map[string]any{"x": 1},
|
||||
}
|
||||
enrichFeedFields(item)
|
||||
if _, ok := item["parentDoc"]; !ok {
|
||||
t.Fatal("parentDoc should be preserved")
|
||||
}
|
||||
if item["id"] != "f1" {
|
||||
t.Fatal("id should be preserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEnrichFeedFieldsEmptyItem(t *testing.T) {
|
||||
// 空 map → 不 panic
|
||||
item := map[string]any{}
|
||||
enrichFeedFields(item)
|
||||
if len(item) != 0 {
|
||||
t.Fatalf("empty item changed: %v", item)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- RunE integration: feed list with formatFeedTime ----------
|
||||
|
||||
func TestCrossPlatformCoverageWikiFeedListFormatIntegration(t *testing.T) {
|
||||
// 通过 scriptedToolCaller 返回真实结构的 feed JSON,验证 RunE 中
|
||||
// callMCPToolReturnText → formatFeedTime → PrintJSON 完整路径(仅 --format json)
|
||||
caller := &scriptedToolCaller{format: "json"}
|
||||
ts := float64(1750067400000)
|
||||
feedPayload := map[string]any{
|
||||
"feeds": []any{
|
||||
map[string]any{
|
||||
"time": ts,
|
||||
"type": float64(0),
|
||||
"id": "feed1",
|
||||
"parentDoc": map[string]any{"name": "parent"},
|
||||
"userInfo": map[string]any{"nick": "user1", "avatarUrl": "http://img"},
|
||||
"users": []any{
|
||||
map[string]any{"nick": "Alice", "userId": "u1", "avatarUrl": "http://avatar"},
|
||||
},
|
||||
"content": map[string]any{
|
||||
"doc": map[string]any{
|
||||
"name": "测试文档",
|
||||
"extension": "adoc",
|
||||
"docKey": "dk123",
|
||||
},
|
||||
"dentryKey": "entry1",
|
||||
"workspaceKey": "ws1",
|
||||
},
|
||||
},
|
||||
map[string]any{
|
||||
"time": float64(1700000000000),
|
||||
"type": float64(7),
|
||||
"id": "feed2",
|
||||
},
|
||||
},
|
||||
}
|
||||
payloadBytes, err := json.Marshal(feedPayload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
caller.steps = []scriptedToolStep{{text: string(payloadBytes)}}
|
||||
|
||||
var out bytes.Buffer
|
||||
oldDeps := deps
|
||||
oldArgs := os.Args
|
||||
InitDeps(caller)
|
||||
deps.Out.w = &out
|
||||
deps.Out.errW = io.Discard
|
||||
t.Cleanup(func() {
|
||||
deps = oldDeps
|
||||
os.Args = oldArgs
|
||||
})
|
||||
|
||||
root := newWikiCommand()
|
||||
installExampleGlobalFlags(root)
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetIn(os.Stdin)
|
||||
|
||||
args := []string{"feed", "list", "--workspace", "ws1", "--exclude-file", "--format", "json"}
|
||||
root.SetArgs(args)
|
||||
os.Args = append([]string{"dws", "wiki"}, args...)
|
||||
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
|
||||
// 验证输出是合法 JSON 且包含格式化后的 feed
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal(out.Bytes(), &parsed); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v\noutput: %s", err, out.String())
|
||||
}
|
||||
feeds, ok := parsed["feeds"].([]any)
|
||||
if !ok || len(feeds) != 2 {
|
||||
t.Fatalf("feeds length unexpected: %v", parsed["feeds"])
|
||||
}
|
||||
|
||||
// 第一个 feed 验证 time 保留、timeFormatted 格式化、所有原始字段保留
|
||||
f0 := feeds[0].(map[string]any)
|
||||
// time 保留原始毫秒时间戳
|
||||
if f0["time"] != ts {
|
||||
t.Fatalf("time = %v, want %v", f0["time"], ts)
|
||||
}
|
||||
// timeFormatted 被填充
|
||||
if _, ok := f0["timeFormatted"].(string); !ok {
|
||||
t.Fatalf("timeFormatted should be string, got %T", f0["timeFormatted"])
|
||||
}
|
||||
// 所有原始字段保留(不删除任何字段)
|
||||
if _, ok := f0["parentDoc"]; !ok {
|
||||
t.Fatal("parentDoc should be preserved")
|
||||
}
|
||||
if _, ok := f0["userInfo"]; !ok {
|
||||
t.Fatal("userInfo should be preserved")
|
||||
}
|
||||
if f0["typeLabel"] != "创建文档" {
|
||||
t.Fatalf("typeLabel = %v, want 创建文档", f0["typeLabel"])
|
||||
}
|
||||
|
||||
// users 保留验证:avatarUrl / userId 均保留
|
||||
if users, ok := f0["users"].([]any); ok {
|
||||
u0 := users[0].(map[string]any)
|
||||
if u0["avatarUrl"] != "http://avatar" {
|
||||
t.Fatalf("users[0].avatarUrl should be preserved, got %v", u0["avatarUrl"])
|
||||
}
|
||||
if u0["userId"] != "u1" {
|
||||
t.Fatalf("users[0].userId should be preserved, got %v", u0["userId"])
|
||||
}
|
||||
}
|
||||
|
||||
// content 结构保留验证
|
||||
if content, ok := f0["content"].(map[string]any); ok {
|
||||
if content["dentryKey"] != "entry1" {
|
||||
t.Fatalf("content.dentryKey should be preserved, got %v", content["dentryKey"])
|
||||
}
|
||||
if content["workspaceKey"] != "ws1" {
|
||||
t.Fatalf("content.workspaceKey should be preserved, got %v", content["workspaceKey"])
|
||||
}
|
||||
doc := content["doc"].(map[string]any)
|
||||
if doc["docKey"] != "dk123" {
|
||||
t.Fatalf("content.doc.docKey should be preserved, got %v", doc["docKey"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWikiFeedListRawFormat(t *testing.T) {
|
||||
// --format raw: 输出原始 MCP 文本,不经过 formatFeedTime 后处理
|
||||
rawPayload := `{"feeds":[{"time":1750067400000,"type":1}]}`
|
||||
caller := &scriptedToolCaller{
|
||||
format: "raw",
|
||||
steps: []scriptedToolStep{{text: rawPayload}},
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
oldDeps := deps
|
||||
oldArgs := os.Args
|
||||
InitDeps(caller)
|
||||
deps.Out.w = &out
|
||||
deps.Out.errW = io.Discard
|
||||
t.Cleanup(func() {
|
||||
deps = oldDeps
|
||||
os.Args = oldArgs
|
||||
})
|
||||
|
||||
root := newWikiCommand()
|
||||
installExampleGlobalFlags(root)
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetIn(os.Stdin)
|
||||
|
||||
args := []string{"feed", "list", "--workspace", "ws1", "--format", "raw"}
|
||||
root.SetArgs(args)
|
||||
os.Args = append([]string{"dws", "wiki"}, args...)
|
||||
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
|
||||
// raw 输出应包含原始文本,不经过 formatFeedTime 格式化
|
||||
got := out.String()
|
||||
if !bytes.Contains([]byte(got), []byte(`"feeds"`)) {
|
||||
t.Fatalf("raw output should contain original payload, got: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWikiFeedListTableFormatMatchesDispatcher(t *testing.T) {
|
||||
// --format table 回归:与共享 dispatcher (callMCPToolInternalOpts) 契约一致,
|
||||
// 输出原始紧凑 MCP 文本,不做 pretty 化,也不注入 timeFormatted / typeLabel
|
||||
rawPayload := `{"feeds":[{"time":1750067400000,"type":1,"id":"f1"}]}`
|
||||
caller := &scriptedToolCaller{
|
||||
format: "table",
|
||||
steps: []scriptedToolStep{{text: rawPayload}},
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
oldDeps := deps
|
||||
oldArgs := os.Args
|
||||
InitDeps(caller)
|
||||
deps.Out.w = &out
|
||||
deps.Out.errW = io.Discard
|
||||
t.Cleanup(func() {
|
||||
deps = oldDeps
|
||||
os.Args = oldArgs
|
||||
})
|
||||
|
||||
root := newWikiCommand()
|
||||
installExampleGlobalFlags(root)
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetIn(os.Stdin)
|
||||
|
||||
args := []string{"feed", "list", "--workspace", "ws1", "--format", "table"}
|
||||
root.SetArgs(args)
|
||||
os.Args = append([]string{"dws", "wiki"}, args...)
|
||||
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
|
||||
// 与共享 dispatcher 一致:输出 = 原始 MCP 文本原文
|
||||
if got := strings.TrimSpace(out.String()); got != rawPayload {
|
||||
t.Fatalf("table output should equal raw MCP payload,\ngot: %s\nwant: %s", got, rawPayload)
|
||||
}
|
||||
// 不注入任何增强字段
|
||||
for _, injected := range []string{"timeFormatted", "typeLabel"} {
|
||||
if strings.Contains(out.String(), injected) {
|
||||
t.Fatalf("table output should not inject %s, got: %s", injected, out.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWikiFeedListEmptyResponse(t *testing.T) {
|
||||
// MCP 返回空结果 → formatFeedTime 处理空字符串 → 不报错
|
||||
caller := &scriptedToolCaller{} // 无 steps → 空结果
|
||||
|
||||
var out bytes.Buffer
|
||||
oldDeps := deps
|
||||
oldArgs := os.Args
|
||||
InitDeps(caller)
|
||||
deps.Out.w = &out
|
||||
deps.Out.errW = io.Discard
|
||||
t.Cleanup(func() {
|
||||
deps = oldDeps
|
||||
os.Args = oldArgs
|
||||
})
|
||||
|
||||
root := newWikiCommand()
|
||||
installExampleGlobalFlags(root)
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetIn(os.Stdin)
|
||||
|
||||
args := []string{"feed", "list", "--workspace", "ws1"}
|
||||
root.SetArgs(args)
|
||||
os.Args = append([]string{"dws", "wiki"}, args...)
|
||||
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
func buildFeedJSON(t *testing.T, feeds []map[string]any) string {
|
||||
t.Helper()
|
||||
payload := map[string]any{"feeds": feeds}
|
||||
b, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func mustMarshal(t *testing.T, v any) []byte {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package localio
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const defaultTextInputLimit = int64(8 << 20)
|
||||
|
||||
var (
|
||||
readTextInputAll = io.ReadAll
|
||||
statTextInputPath = os.Stat
|
||||
openTextInputFile = os.Open
|
||||
readTextInputRel = filepath.Rel
|
||||
)
|
||||
|
||||
// ReadTextInput resolves a literal, "-" stdin, or @workspace-relative file.
|
||||
// File symlinks must resolve inside the current working directory and all input
|
||||
// forms are bounded to prevent accidental unbounded memory use.
|
||||
func ReadTextInput(spec string, stdin io.Reader, maxBytes int64) (string, error) {
|
||||
if maxBytes <= 0 {
|
||||
maxBytes = defaultTextInputLimit
|
||||
}
|
||||
if spec == "-" {
|
||||
if stdin == nil {
|
||||
return "", fmt.Errorf("LOCAL_INPUT_INVALID: stdin 不可用")
|
||||
}
|
||||
data, err := readTextInputAll(io.LimitReader(stdin, maxBytes+1))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("LOCAL_INPUT_READ_FAILED: %w", err)
|
||||
}
|
||||
if int64(len(data)) > maxBytes {
|
||||
return "", fmt.Errorf("LOCAL_INPUT_TOO_LARGE: stdin 超过 %d 字节", maxBytes)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
if !strings.HasPrefix(spec, "@") {
|
||||
if int64(len(spec)) > maxBytes {
|
||||
return "", fmt.Errorf("LOCAL_INPUT_TOO_LARGE: 文本输入超过 %d 字节", maxBytes)
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
path := strings.TrimSpace(strings.TrimPrefix(spec, "@"))
|
||||
if path == "" {
|
||||
return "", fmt.Errorf("LOCAL_INPUT_INVALID: @file 路径不能为空")
|
||||
}
|
||||
if filepath.IsAbs(path) {
|
||||
return "", fmt.Errorf("LOCAL_INPUT_UNSAFE: @file 只接受工作目录内的相对路径")
|
||||
}
|
||||
clean := filepath.Clean(path)
|
||||
if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("LOCAL_INPUT_UNSAFE: @file 不能逃逸工作目录")
|
||||
}
|
||||
cwd, err := localGetwd()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("LOCAL_INPUT_READ_FAILED: %w", err)
|
||||
}
|
||||
realBase, err := localEvalSymlinks(cwd)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("LOCAL_INPUT_READ_FAILED: %w", err)
|
||||
}
|
||||
realPath, err := localEvalSymlinks(filepath.Join(realBase, clean))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("LOCAL_INPUT_READ_FAILED: %w", err)
|
||||
}
|
||||
rel, err := readTextInputRel(realBase, realPath)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
|
||||
return "", fmt.Errorf("LOCAL_INPUT_UNSAFE: @file 解析后逃逸工作目录")
|
||||
}
|
||||
pathInfo, err := statTextInputPath(realPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("LOCAL_INPUT_READ_FAILED: %w", err)
|
||||
}
|
||||
if !pathInfo.Mode().IsRegular() {
|
||||
return "", fmt.Errorf("LOCAL_INPUT_INVALID: @file 必须是普通文件")
|
||||
}
|
||||
if pathInfo.Size() > maxBytes {
|
||||
return "", fmt.Errorf("LOCAL_INPUT_TOO_LARGE: 文件大小 %d 超过 %d 字节", pathInfo.Size(), maxBytes)
|
||||
}
|
||||
file, err := openTextInputFile(realPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("LOCAL_INPUT_READ_FAILED: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("LOCAL_INPUT_READ_FAILED: %w", err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return "", fmt.Errorf("LOCAL_INPUT_INVALID: @file 必须是普通文件")
|
||||
}
|
||||
if info.Size() > maxBytes {
|
||||
return "", fmt.Errorf("LOCAL_INPUT_TOO_LARGE: 文件大小 %d 超过 %d 字节", info.Size(), maxBytes)
|
||||
}
|
||||
data, err := readTextInputAll(io.LimitReader(file, maxBytes+1))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("LOCAL_INPUT_READ_FAILED: %w", err)
|
||||
}
|
||||
if int64(len(data)) > maxBytes {
|
||||
return "", fmt.Errorf("LOCAL_INPUT_TOO_LARGE: 文件超过 %d 字节", maxBytes)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package localio
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageReadTextInputUsesOpenedDescriptorAndBoundedReadE2E(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
old, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(old) })
|
||||
|
||||
t.Run("file grows after stat", func(t *testing.T) {
|
||||
path := filepath.Join(dir, "grow.txt")
|
||||
if err := os.WriteFile(path, []byte("ok"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &readTextInputAll, func(reader io.Reader) ([]byte, error) {
|
||||
file, openErr := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)
|
||||
if openErr != nil {
|
||||
return nil, openErr
|
||||
}
|
||||
if _, writeErr := file.WriteString("-too-large"); writeErr != nil {
|
||||
_ = file.Close()
|
||||
return nil, writeErr
|
||||
}
|
||||
if closeErr := file.Close(); closeErr != nil {
|
||||
return nil, closeErr
|
||||
}
|
||||
return io.ReadAll(reader)
|
||||
})
|
||||
if _, err := ReadTextInput("@grow.txt", nil, 4); err == nil {
|
||||
t.Fatal("file growth bypassed size limit")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("path becomes directory before descriptor check", func(t *testing.T) {
|
||||
path := filepath.Join(dir, "becomes-directory.txt")
|
||||
if err := os.WriteFile(path, []byte("safe"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &openTextInputFile, func(candidate string) (*os.File, error) {
|
||||
if removeErr := os.Remove(candidate); removeErr != nil {
|
||||
return nil, removeErr
|
||||
}
|
||||
if mkdirErr := os.Mkdir(candidate, 0o700); mkdirErr != nil {
|
||||
return nil, mkdirErr
|
||||
}
|
||||
return os.Open(candidate)
|
||||
})
|
||||
if _, err := ReadTextInput("@becomes-directory.txt", nil, 10); err == nil {
|
||||
t.Fatal("path type replacement bypassed descriptor validation")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("file grows before descriptor check", func(t *testing.T) {
|
||||
path := filepath.Join(dir, "grows-before-stat.txt")
|
||||
if err := os.WriteFile(path, []byte("ok"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &openTextInputFile, func(candidate string) (*os.File, error) {
|
||||
file, openErr := os.Open(candidate)
|
||||
if openErr != nil {
|
||||
return nil, openErr
|
||||
}
|
||||
writer, writeOpenErr := os.OpenFile(candidate, os.O_APPEND|os.O_WRONLY, 0)
|
||||
if writeOpenErr != nil {
|
||||
_ = file.Close()
|
||||
return nil, writeOpenErr
|
||||
}
|
||||
if _, writeErr := writer.WriteString("-too-large"); writeErr != nil {
|
||||
_ = writer.Close()
|
||||
_ = file.Close()
|
||||
return nil, writeErr
|
||||
}
|
||||
if closeErr := writer.Close(); closeErr != nil {
|
||||
_ = file.Close()
|
||||
return nil, closeErr
|
||||
}
|
||||
return file, nil
|
||||
})
|
||||
if _, err := ReadTextInput("@grows-before-stat.txt", nil, 4); err == nil {
|
||||
t.Fatal("pre-open growth bypassed descriptor size validation")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//go:build unix
|
||||
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package localio
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageReadTextInputUsesOpenedDescriptorAfterPathReplacementE2E(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
old, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(old) })
|
||||
|
||||
path := filepath.Join(dir, "input.txt")
|
||||
replacement := filepath.Join(dir, "replacement.txt")
|
||||
if err := os.WriteFile(path, []byte("safe"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(replacement, []byte("replacement-exceeds-limit"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &openTextInputFile, func(candidate string) (*os.File, error) {
|
||||
file, openErr := os.Open(candidate)
|
||||
if openErr != nil {
|
||||
return nil, openErr
|
||||
}
|
||||
if renameErr := os.Rename(replacement, candidate); renameErr != nil {
|
||||
_ = file.Close()
|
||||
return nil, renameErr
|
||||
}
|
||||
return file, nil
|
||||
})
|
||||
got, err := ReadTextInput("@input.txt", nil, 10)
|
||||
if err != nil || got != "safe" {
|
||||
t.Fatalf("replacement read = %q, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReadTextInputRejectsUnconnectedFIFOWithoutBlockingE2E(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
old, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(old) })
|
||||
|
||||
fifo := filepath.Join(dir, "input.fifo")
|
||||
if err := syscall.Mkfifo(fifo, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := make(chan error, 1)
|
||||
go func() {
|
||||
_, readErr := ReadTextInput("@input.fifo", nil, 1024)
|
||||
result <- readErr
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-result:
|
||||
if err == nil || !strings.Contains(err.Error(), "LOCAL_INPUT_INVALID") {
|
||||
t.Fatalf("FIFO rejection error = %v", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("FIFO without a writer blocked text input validation")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package localio
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageReadTextInputLiteralStdinAndFileE2E(t *testing.T) {
|
||||
if got, err := ReadTextInput("literal", nil, 10); err != nil || got != "literal" {
|
||||
t.Fatalf("literal = %q, %v", got, err)
|
||||
}
|
||||
if got, err := ReadTextInput("-", strings.NewReader("stdin"), 10); err != nil || got != "stdin" {
|
||||
t.Fatalf("stdin = %q, %v", got, err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
old, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(old) })
|
||||
if err := os.WriteFile(filepath.Join(dir, "input.json"), []byte("file"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := ReadTextInput("@input.json", nil, 10); err != nil || got != "file" {
|
||||
t.Fatalf("file = %q, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReadTextInputRejectsEscapeAndOversizeE2E(t *testing.T) {
|
||||
for _, spec := range []string{"@../secret", "@/tmp/secret", "@"} {
|
||||
if _, err := ReadTextInput(spec, nil, 10); err == nil {
|
||||
t.Fatalf("unsafe input accepted: %q", spec)
|
||||
}
|
||||
}
|
||||
if _, err := ReadTextInput("too-large", nil, 2); err == nil {
|
||||
t.Fatal("oversize literal accepted")
|
||||
}
|
||||
if _, err := ReadTextInput("-", strings.NewReader("too-large"), 2); err == nil {
|
||||
t.Fatal("oversize stdin accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReadTextInputFailureBranchesE2E(t *testing.T) {
|
||||
if got, err := ReadTextInput("literal", nil, 0); err != nil || got != "literal" {
|
||||
t.Fatalf("default limit literal = %q, %v", got, err)
|
||||
}
|
||||
if _, err := ReadTextInput("-", nil, 1); err == nil {
|
||||
t.Fatal("nil stdin accepted")
|
||||
}
|
||||
t.Run("stdin read", func(t *testing.T) {
|
||||
testseam.Swap(t, &readTextInputAll, func(io.Reader) ([]byte, error) { return nil, errors.New("read") })
|
||||
if _, err := ReadTextInput("-", strings.NewReader("x"), 1); err == nil {
|
||||
t.Fatal("stdin read failure ignored")
|
||||
}
|
||||
})
|
||||
|
||||
dir := t.TempDir()
|
||||
file := filepath.Join(dir, "input.txt")
|
||||
if err := os.WriteFile(file, []byte("long"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(old) })
|
||||
if _, err := ReadTextInput("@input.txt", nil, 1); err == nil {
|
||||
t.Fatal("oversize file accepted")
|
||||
}
|
||||
if _, err := ReadTextInput("@.", nil, 10); err == nil {
|
||||
t.Fatal("directory accepted")
|
||||
}
|
||||
|
||||
for name, run := range map[string]func(*testing.T){
|
||||
"getwd": func(t *testing.T) {
|
||||
testseam.Swap(t, &localGetwd, func() (string, error) { return "", errors.New("getwd") })
|
||||
},
|
||||
"eval base": func(t *testing.T) {
|
||||
testseam.Swap(t, &localEvalSymlinks, func(string) (string, error) { return "", errors.New("eval base") })
|
||||
},
|
||||
"eval path": func(t *testing.T) {
|
||||
calls := 0
|
||||
testseam.Swap(t, &localEvalSymlinks, func(path string) (string, error) {
|
||||
calls++
|
||||
if calls == 2 {
|
||||
return "", errors.New("eval path")
|
||||
}
|
||||
return path, nil
|
||||
})
|
||||
},
|
||||
"relative": func(t *testing.T) {
|
||||
testseam.Swap(t, &readTextInputRel, func(string, string) (string, error) { return "", errors.New("rel") })
|
||||
},
|
||||
"path stat": func(t *testing.T) {
|
||||
testseam.Swap(t, &statTextInputPath, func(string) (os.FileInfo, error) { return nil, errors.New("stat") })
|
||||
},
|
||||
"open": func(t *testing.T) {
|
||||
testseam.Swap(t, &openTextInputFile, func(string) (*os.File, error) { return nil, errors.New("open") })
|
||||
},
|
||||
"opened file stat": func(t *testing.T) {
|
||||
testseam.Swap(t, &openTextInputFile, func(candidate string) (*os.File, error) {
|
||||
file, openErr := os.Open(candidate)
|
||||
if openErr == nil {
|
||||
_ = file.Close()
|
||||
}
|
||||
return file, openErr
|
||||
})
|
||||
},
|
||||
"read file": func(t *testing.T) {
|
||||
testseam.Swap(t, &readTextInputAll, func(io.Reader) ([]byte, error) { return nil, errors.New("read file") })
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
run(t)
|
||||
if _, err := ReadTextInput("@input.txt", nil, 10); err == nil {
|
||||
t.Fatalf("%s failure ignored", name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("resolved escape", func(t *testing.T) {
|
||||
calls := 0
|
||||
testseam.Swap(t, &localEvalSymlinks, func(path string) (string, error) {
|
||||
calls++
|
||||
if calls == 2 {
|
||||
return filepath.Join(filepath.Dir(path), "..", "outside"), nil
|
||||
}
|
||||
return path, nil
|
||||
})
|
||||
if _, err := ReadTextInput("@input.txt", nil, 10); err == nil {
|
||||
t.Fatal("resolved escape accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package localio
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
const defaultPublishBytesLimit = 64 << 20
|
||||
|
||||
var verifyPublishTarget = func(target *downloadTarget) error { return target.verifyParent() }
|
||||
|
||||
// PublishBytesOptions controls safe no-clobber publication beneath BaseDir.
|
||||
type PublishBytesOptions struct {
|
||||
BaseDir string
|
||||
Output string
|
||||
PreferredName string
|
||||
MaxBytes int64
|
||||
}
|
||||
|
||||
// PublishBytes writes an in-memory artifact through the same symlink-safe,
|
||||
// fsync and atomic no-clobber path used by remote downloads.
|
||||
func PublishBytes(payload []byte, opts PublishBytesOptions) (DownloadResult, error) {
|
||||
limit := opts.MaxBytes
|
||||
if limit <= 0 {
|
||||
limit = defaultPublishBytesLimit
|
||||
}
|
||||
if int64(len(payload)) > limit {
|
||||
return DownloadResult{}, fmt.Errorf("LOCAL_OUTPUT_TOO_LARGE: 内容大小 %d 超过上限 %d 字节", len(payload), limit)
|
||||
}
|
||||
target, err := openDownloadTarget(opts.BaseDir, opts.Output, "", opts.PreferredName)
|
||||
if err != nil {
|
||||
return DownloadResult{}, err
|
||||
}
|
||||
defer target.close()
|
||||
if err := verifyPublishTarget(target); err != nil {
|
||||
return DownloadResult{}, err
|
||||
}
|
||||
tmp, tmpName, err := createDownloadTemp(target.parentRoot)
|
||||
if err != nil {
|
||||
return DownloadResult{}, fmt.Errorf("创建输出临时文件失败: %w", err)
|
||||
}
|
||||
cleanup := func() {
|
||||
_ = tmp.Close()
|
||||
_ = target.parentRoot.Remove(tmpName)
|
||||
}
|
||||
if _, err := tmp.Write(payload); err != nil {
|
||||
cleanup()
|
||||
return DownloadResult{}, fmt.Errorf("写入输出临时文件失败: %w", err)
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
cleanup()
|
||||
return DownloadResult{}, fmt.Errorf("同步输出临时文件失败: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
cleanup()
|
||||
return DownloadResult{}, fmt.Errorf("关闭输出临时文件失败: %w", err)
|
||||
}
|
||||
if err := verifyPublishTarget(target); err != nil {
|
||||
cleanup()
|
||||
return DownloadResult{}, err
|
||||
}
|
||||
if err := publishTempFile(target.parentRoot, tmpName, target.destinationName); err != nil {
|
||||
cleanup()
|
||||
return DownloadResult{}, err
|
||||
}
|
||||
return DownloadResult{AbsolutePath: target.absolutePath, RelativePath: filepath.ToSlash(target.relativePath), SizeBytes: int64(len(payload))}, nil
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package localio
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoveragePublishBytesAtomicNoClobberE2E(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
result, err := PublishBytes([]byte("verified artifact"), PublishBytesOptions{BaseDir: base, Output: "out/", PreferredName: "transcript.json"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(base, result.RelativePath))
|
||||
if err != nil || string(raw) != "verified artifact" || result.SizeBytes != int64(len(raw)) {
|
||||
t.Fatalf("publish result=%#v raw=%q err=%v", result, raw, err)
|
||||
}
|
||||
if _, err := PublishBytes([]byte("overwrite"), PublishBytesOptions{BaseDir: base, Output: "out/transcript.json"}); err == nil {
|
||||
t.Fatal("existing output was overwritten")
|
||||
}
|
||||
if _, err := PublishBytes([]byte("escape"), PublishBytesOptions{BaseDir: base, Output: "../escape"}); err == nil {
|
||||
t.Fatal("path escape accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePublishBytesFailureBranchesE2E(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
if _, err := PublishBytes([]byte("xx"), PublishBytesOptions{BaseDir: base, Output: "large", MaxBytes: 1}); err == nil {
|
||||
t.Fatal("oversize payload accepted")
|
||||
}
|
||||
if _, err := PublishBytes([]byte("x"), PublishBytesOptions{BaseDir: base, Output: "default-limit", MaxBytes: 0}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
make func(*os.Root) (downloadTempFile, string, error)
|
||||
}{
|
||||
{name: "create", make: func(*os.Root) (downloadTempFile, string, error) { return nil, "", errors.New("create") }},
|
||||
{name: "write", make: func(root *os.Root) (downloadTempFile, string, error) {
|
||||
file, name, err := createDownloadTempInRoot(root)
|
||||
return &coverageTempFile{file: file.(*os.File), writeErr: errors.New("write")}, name, err
|
||||
}},
|
||||
{name: "sync", make: func(root *os.Root) (downloadTempFile, string, error) {
|
||||
file, name, err := createDownloadTempInRoot(root)
|
||||
return &coverageTempFile{file: file.(*os.File), syncErr: errors.New("sync")}, name, err
|
||||
}},
|
||||
{name: "close", make: func(root *os.Root) (downloadTempFile, string, error) {
|
||||
file, name, err := createDownloadTempInRoot(root)
|
||||
return &coverageTempFile{file: file.(*os.File), closeErr: errors.New("close")}, name, err
|
||||
}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
testseam.Swap(t, &createDownloadTemp, tc.make)
|
||||
if _, err := PublishBytes([]byte("x"), PublishBytesOptions{BaseDir: base, Output: tc.name}); err == nil {
|
||||
t.Fatalf("%s failure ignored", tc.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("initial parent verification", func(t *testing.T) {
|
||||
testseam.Swap(t, &verifyPublishTarget, func(*downloadTarget) error { return errors.New("verify") })
|
||||
if _, err := PublishBytes([]byte("x"), PublishBytesOptions{BaseDir: base, Output: "verify-initial"}); err == nil {
|
||||
t.Fatal("initial verification failure ignored")
|
||||
}
|
||||
})
|
||||
t.Run("second parent verification", func(t *testing.T) {
|
||||
calls := 0
|
||||
testseam.Swap(t, &verifyPublishTarget, func(*downloadTarget) error {
|
||||
calls++
|
||||
if calls > 1 {
|
||||
return errors.New("changed")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if _, err := PublishBytes([]byte("x"), PublishBytesOptions{BaseDir: base, Output: "verify-second"}); err == nil {
|
||||
t.Fatal("second verification failure ignored")
|
||||
}
|
||||
})
|
||||
t.Run("publish", func(t *testing.T) {
|
||||
testseam.Swap(t, &downloadRootLink, func(*os.Root, string, string) error { return errors.New("link") })
|
||||
if _, err := PublishBytes([]byte("x"), PublishBytesOptions{BaseDir: base, Output: "publish"}); err == nil {
|
||||
t.Fatal("publish failure ignored")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package localio
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
const defaultUploadLimit = int64(5 << 30)
|
||||
|
||||
var (
|
||||
newUploadHTTPClient = secureHTTPClient
|
||||
validateUploadURL = func(raw string) error {
|
||||
_, err := ValidateDownloadURL(raw)
|
||||
return err
|
||||
}
|
||||
openUploadFile = os.Open
|
||||
newUploadRequest = http.NewRequestWithContext
|
||||
)
|
||||
|
||||
// UploadResult records only non-sensitive transfer facts. The signed URL is
|
||||
// deliberately never returned.
|
||||
type UploadResult struct {
|
||||
SizeBytes int64
|
||||
Attempts int
|
||||
}
|
||||
|
||||
// PutFile uploads a regular local file to an exact trusted pre-signed HTTPS
|
||||
// endpoint. Redirects are rejected so file bytes and credentials cannot move to
|
||||
// a second origin. Transient failures reuse the same verified open file so a
|
||||
// path replacement cannot change the bytes sent by a later attempt.
|
||||
func PutFile(ctx context.Context, rawURL, path string, maxBytes int64) (UploadResult, error) {
|
||||
client := newUploadHTTPClient()
|
||||
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
|
||||
return putFileWithClient(ctx, rawURL, path, maxBytes, client, validateUploadURL)
|
||||
}
|
||||
|
||||
func putFileWithClient(ctx context.Context, rawURL, path string, maxBytes int64, client *http.Client, validate func(string) error) (UploadResult, error) {
|
||||
if validate == nil {
|
||||
return UploadResult{}, fmt.Errorf("upload URL validator is nil")
|
||||
}
|
||||
if err := validate(rawURL); err != nil {
|
||||
return UploadResult{}, err
|
||||
}
|
||||
if maxBytes <= 0 {
|
||||
maxBytes = defaultUploadLimit
|
||||
}
|
||||
file, err := openUploadFile(path)
|
||||
if err != nil {
|
||||
return UploadResult{}, fmt.Errorf("打开上传文件失败: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return UploadResult{}, fmt.Errorf("读取上传文件失败: %w", err)
|
||||
}
|
||||
if !info.Mode().IsRegular() || info.Size() <= 0 || info.Size() > maxBytes {
|
||||
return UploadResult{}, fmt.Errorf("上传文件必须是 1..%d 字节的普通文件", maxBytes)
|
||||
}
|
||||
result := UploadResult{SizeBytes: info.Size()}
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= 3; attempt++ {
|
||||
result.Attempts = attempt
|
||||
reader := io.NewSectionReader(file, 0, info.Size())
|
||||
request, requestErr := newUploadRequest(ctx, http.MethodPut, rawURL, reader)
|
||||
if requestErr != nil {
|
||||
return UploadResult{}, fmt.Errorf("创建上传请求失败: %w", requestErr)
|
||||
}
|
||||
request.ContentLength = info.Size()
|
||||
response, callErr := client.Do(request)
|
||||
if callErr == nil {
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 1<<20))
|
||||
bodyCloseErr := response.Body.Close()
|
||||
if response.StatusCode >= 200 && response.StatusCode < 300 && bodyCloseErr == nil {
|
||||
return result, nil
|
||||
}
|
||||
if response.StatusCode < 500 && response.StatusCode != http.StatusRequestTimeout && response.StatusCode != http.StatusTooManyRequests {
|
||||
return result, fmt.Errorf("上传返回 HTTP %d", response.StatusCode)
|
||||
}
|
||||
lastErr = fmt.Errorf("上传返回 HTTP %d", response.StatusCode)
|
||||
} else {
|
||||
lastErr = callErr
|
||||
}
|
||||
if attempt < 3 {
|
||||
timer := time.NewTimer(time.Duration(attempt) * 200 * time.Millisecond)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return result, ctx.Err()
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, fmt.Errorf("上传在 %d 次尝试后失败: %w", result.Attempts, lastErr)
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package localio
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoveragePutFileRetriesAndUploadsExactBytesE2E(t *testing.T) {
|
||||
payload := []byte("minutes-e2e")
|
||||
replacement := []byte("replacement-data")
|
||||
path := filepath.Join(t.TempDir(), "audio.wav")
|
||||
if err := os.WriteFile(path, payload, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
replacementPath := filepath.Join(t.TempDir(), "replacement.wav")
|
||||
if err := os.WriteFile(replacementPath, replacement, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
openCalls := 0
|
||||
testseam.Swap(t, &openUploadFile, func(candidate string) (*os.File, error) {
|
||||
openCalls++
|
||||
if openCalls > 1 {
|
||||
return os.Open(replacementPath)
|
||||
}
|
||||
return os.Open(candidate)
|
||||
})
|
||||
calls := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
if r.Method != http.MethodPut {
|
||||
t.Errorf("method = %s", r.Method)
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
if string(body) != string(payload) {
|
||||
t.Errorf("body = %q", body)
|
||||
}
|
||||
if calls == 1 {
|
||||
http.Error(w, "retry", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
result, err := putFileWithClient(context.Background(), server.URL, path, 100, server.Client(), func(string) error { return nil })
|
||||
if err != nil || calls != 2 || openCalls != 1 || result.Attempts != 2 || result.SizeBytes != int64(len(payload)) {
|
||||
t.Fatalf("upload = %#v calls=%d opens=%d err=%v", result, calls, openCalls, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePutFileValidatesOpenedDescriptorE2E(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
requestedPath := filepath.Join(base, "requested.wav")
|
||||
if err := os.WriteFile(requestedPath, []byte("ok"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
replacementPath := filepath.Join(base, "replacement.wav")
|
||||
if err := os.WriteFile(replacementPath, []byte("replacement-exceeds-limit"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &openUploadFile, func(string) (*os.File, error) {
|
||||
return os.Open(replacementPath)
|
||||
})
|
||||
called := false
|
||||
client := &http.Client{Transport: uploadRoundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
called = true
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("ok"))}, nil
|
||||
})}
|
||||
if _, err := putFileWithClient(context.Background(), "http://example.invalid", requestedPath, 2, client, func(string) error { return nil }); err == nil {
|
||||
t.Fatal("replacement descriptor bypassed size validation")
|
||||
}
|
||||
if called {
|
||||
t.Fatal("upload started before the opened descriptor was validated")
|
||||
}
|
||||
}
|
||||
|
||||
type uploadRoundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f uploadRoundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
return f(request)
|
||||
}
|
||||
|
||||
type uploadCloseErrorBody struct{ io.Reader }
|
||||
|
||||
func (uploadCloseErrorBody) Close() error { return errors.New("close") }
|
||||
|
||||
func TestCrossPlatformCoveragePutFileFailureBranchesE2E(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
path := filepath.Join(base, "audio.wav")
|
||||
if err := os.WriteFile(path, []byte("payload"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
okClient := &http.Client{Transport: uploadRoundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("ok"))}, nil
|
||||
})}
|
||||
if _, err := PutFile(context.Background(), "not-a-valid-upload-url", path, 10); err == nil {
|
||||
t.Fatal("default upload URL validation accepted invalid URL")
|
||||
}
|
||||
if _, err := putFileWithClient(context.Background(), "http://example.invalid", path, 10, okClient, nil); err == nil {
|
||||
t.Fatal("nil validator accepted")
|
||||
}
|
||||
if _, err := putFileWithClient(context.Background(), "http://example.invalid", path, 10, okClient, func(string) error { return errors.New("invalid") }); err == nil {
|
||||
t.Fatal("validator error ignored")
|
||||
}
|
||||
if _, err := putFileWithClient(context.Background(), "http://example.invalid", filepath.Join(base, "missing"), 10, okClient, func(string) error { return nil }); err == nil {
|
||||
t.Fatal("stat error ignored")
|
||||
}
|
||||
if _, err := putFileWithClient(context.Background(), "http://example.invalid", base, 10, okClient, func(string) error { return nil }); err == nil {
|
||||
t.Fatal("directory accepted")
|
||||
}
|
||||
empty := filepath.Join(base, "empty")
|
||||
if err := os.WriteFile(empty, nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := putFileWithClient(context.Background(), "http://example.invalid", empty, 10, okClient, func(string) error { return nil }); err == nil {
|
||||
t.Fatal("empty file accepted")
|
||||
}
|
||||
if _, err := putFileWithClient(context.Background(), "http://example.invalid", path, 1, okClient, func(string) error { return nil }); err == nil {
|
||||
t.Fatal("oversize file accepted")
|
||||
}
|
||||
|
||||
t.Run("wrapper", func(t *testing.T) {
|
||||
redirect := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
http.Redirect(w, request, "http://example.invalid/redirected", http.StatusFound)
|
||||
}))
|
||||
defer redirect.Close()
|
||||
testseam.Swap(t, &newUploadHTTPClient, redirect.Client)
|
||||
testseam.Swap(t, &validateUploadURL, func(string) error { return nil })
|
||||
if _, err := PutFile(context.Background(), redirect.URL, path, 0); err == nil {
|
||||
t.Fatal("PutFile followed redirect")
|
||||
}
|
||||
})
|
||||
t.Run("open", func(t *testing.T) {
|
||||
testseam.Swap(t, &openUploadFile, func(string) (*os.File, error) { return nil, errors.New("open") })
|
||||
if _, err := putFileWithClient(context.Background(), "http://example.invalid", path, 10, okClient, func(string) error { return nil }); err == nil {
|
||||
t.Fatal("open error ignored")
|
||||
}
|
||||
})
|
||||
t.Run("opened file stat", func(t *testing.T) {
|
||||
testseam.Swap(t, &openUploadFile, func(candidate string) (*os.File, error) {
|
||||
file, err := os.Open(candidate)
|
||||
if err == nil {
|
||||
_ = file.Close()
|
||||
}
|
||||
return file, err
|
||||
})
|
||||
if _, err := putFileWithClient(context.Background(), "http://example.invalid", path, 10, okClient, func(string) error { return nil }); err == nil {
|
||||
t.Fatal("opened file stat error ignored")
|
||||
}
|
||||
})
|
||||
t.Run("request", func(t *testing.T) {
|
||||
testseam.Swap(t, &newUploadRequest, func(context.Context, string, string, io.Reader) (*http.Request, error) {
|
||||
return nil, errors.New("request")
|
||||
})
|
||||
if _, err := putFileWithClient(context.Background(), "http://example.invalid", path, 10, okClient, func(string) error { return nil }); err == nil {
|
||||
t.Fatal("request error ignored")
|
||||
}
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
client *http.Client
|
||||
}{
|
||||
{name: "client", client: &http.Client{Transport: uploadRoundTripFunc(func(*http.Request) (*http.Response, error) { return nil, errors.New("call") })}},
|
||||
{name: "server", client: &http.Client{Transport: uploadRoundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: http.StatusServiceUnavailable, Body: io.NopCloser(strings.NewReader("retry"))}, nil
|
||||
})}},
|
||||
{name: "close", client: &http.Client{Transport: uploadRoundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: uploadCloseErrorBody{Reader: strings.NewReader("ok")}}, nil
|
||||
})}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := putFileWithClient(context.Background(), "http://example.invalid", path, 10, tc.client, func(string) error { return nil }); err == nil {
|
||||
t.Fatalf("%s retries unexpectedly succeeded", tc.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
cancelled, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
retryClient := &http.Client{Transport: uploadRoundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: http.StatusServiceUnavailable, Body: io.NopCloser(strings.NewReader("retry"))}, nil
|
||||
})}
|
||||
if _, err := putFileWithClient(cancelled, "http://example.invalid", path, 10, retryClient, func(string) error { return nil }); err == nil {
|
||||
t.Fatal("cancelled retry succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePutFileRejectsRedirectAndInvalidFileE2E(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "audio.wav")
|
||||
if err := os.WriteFile(path, []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }))
|
||||
defer target.Close()
|
||||
redirect := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, target.URL, http.StatusFound) }))
|
||||
defer redirect.Close()
|
||||
client := redirect.Client()
|
||||
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
|
||||
if _, err := putFileWithClient(context.Background(), redirect.URL, path, 100, client, func(string) error { return nil }); err == nil {
|
||||
t.Fatal("redirect accepted")
|
||||
}
|
||||
if _, err := putFileWithClient(context.Background(), redirect.URL, filepath.Join(t.TempDir(), "missing"), 100, client, func(string) error { return nil }); err == nil {
|
||||
t.Fatal("missing file accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package builtin_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageDriveSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) {
|
||||
raw, err := os.ReadFile("../semantic_catalog_drive.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var source chatSemanticCatalogFixture
|
||||
if err := json.Unmarshal(raw, &source); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if source.Service != "drive" {
|
||||
t.Fatalf("semantic catalog service = %q", source.Service)
|
||||
}
|
||||
registered := map[string]shortcut.Shortcut{}
|
||||
for _, item := range shortcut.All() {
|
||||
if item.Service != "drive" {
|
||||
continue
|
||||
}
|
||||
if _, duplicate := registered[item.Command]; duplicate {
|
||||
t.Fatalf("duplicate registered Drive Shortcut %s", item.Command)
|
||||
}
|
||||
registered[item.Command] = item
|
||||
}
|
||||
if len(registered) != 29 || len(source.Shortcuts) != 29 {
|
||||
t.Fatalf("registered/catalog = %d/%d, want 29/29", len(registered), len(source.Shortcuts))
|
||||
}
|
||||
var missing, stale []string
|
||||
for command, item := range registered {
|
||||
record, ok := source.Shortcuts[command]
|
||||
if !ok {
|
||||
missing = append(missing, command)
|
||||
continue
|
||||
}
|
||||
if !record.Reviewed || !item.SemanticReviewed {
|
||||
t.Errorf("%s: reviewed delivery mismatch", command)
|
||||
}
|
||||
if got := shortcut.InPublicCatalog("drive", command); got != record.Public || item.Hidden == record.Public {
|
||||
t.Errorf("%s: public/hidden mismatch: catalog=%v runtimeHidden=%v", command, record.Public, item.Hidden)
|
||||
}
|
||||
if strings.TrimSpace(record.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta || item.Disposition != record.Disposition {
|
||||
t.Errorf("%s: semantic catalog facts drifted", command)
|
||||
}
|
||||
if item.Risk != record.Risk {
|
||||
t.Errorf("%s: risk = %q, want %q", command, item.Risk, record.Risk)
|
||||
}
|
||||
if item.Contract.Empty() || strings.TrimSpace(item.Safety.Effect) == "" || strings.TrimSpace(item.Safety.Confirmation) == "" {
|
||||
t.Errorf("%s: incomplete explicit contract/safety", command)
|
||||
}
|
||||
if command != "+find-file" && record.Public {
|
||||
if item.OutputRollout != output.RolloutUnifiedActive {
|
||||
t.Errorf("%s: output rollout = %q", command, item.OutputRollout)
|
||||
}
|
||||
if item.Contract.Result == nil {
|
||||
t.Errorf("%s: public Drive shortcut lacks Result declaration", command)
|
||||
}
|
||||
}
|
||||
}
|
||||
for command := range source.Shortcuts {
|
||||
if _, ok := registered[command]; !ok {
|
||||
stale = append(stale, command)
|
||||
}
|
||||
}
|
||||
sort.Strings(missing)
|
||||
sort.Strings(stale)
|
||||
if len(missing) > 0 || len(stale) > 0 {
|
||||
t.Fatalf("semantic catalog mismatch: missing=%v stale=%v", missing, stale)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package builtin_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageMinutesSemanticCatalogMatchesRegistryE2E(t *testing.T) {
|
||||
raw, err := os.ReadFile("../semantic_catalog_minutes.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var catalog struct {
|
||||
Shortcuts map[string]struct {
|
||||
Public bool `json:"public"`
|
||||
Reviewed bool `json:"reviewed"`
|
||||
Delta string `json:"semantic_delta"`
|
||||
} `json:"shortcuts"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &catalog); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registered := map[string]shortcut.Shortcut{}
|
||||
for _, item := range shortcut.All() {
|
||||
if item.Service == "minutes" {
|
||||
registered[item.Command] = item
|
||||
}
|
||||
}
|
||||
if len(registered) != len(catalog.Shortcuts) {
|
||||
t.Fatalf("minutes registry/catalog count = %d/%d", len(registered), len(catalog.Shortcuts))
|
||||
}
|
||||
for command, record := range catalog.Shortcuts {
|
||||
item, ok := registered[command]
|
||||
if !ok {
|
||||
t.Errorf("catalog command %s is not registered", command)
|
||||
continue
|
||||
}
|
||||
if !record.Reviewed || record.Delta == "" || item.Hidden == record.Public || !item.SemanticReviewed || item.Contract.Empty() {
|
||||
t.Errorf("minutes %s review/public/contract mismatch: record=%#v item=%#v", command, record, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -313,17 +313,17 @@ var ConversationList = shortcut.Shortcut{
|
||||
Examples: []string{"dws chat +conversation-list --limit 50"},
|
||||
},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
Flags: append([]shortcut.Flag{
|
||||
{Name: "limit", Type: shortcut.FlagInt, Default: "100", Desc: "每页数量;--limit 必须在 1-100"},
|
||||
{Name: "cursor", Type: shortcut.FlagInt, Desc: "分页游标(首次不传或 0)"},
|
||||
{Name: "exclude-muted", Type: shortcut.FlagBool, Desc: "排除已免打扰会话"},
|
||||
{Name: "page-all", Type: shortcut.FlagBool, Desc: "自动读取全部分页;--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||
{Name: "page-all", Type: shortcut.FlagBool, Desc: "自动读取全部分页;--page-limit 仅与 --page-all 一起使用且范围 1-500;--max-items/--page-delay 仅与 --page-all 一起使用;值必须大于等于 0"},
|
||||
{Name: "page-limit", Type: shortcut.FlagInt, Default: "50", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
}, shortcut.AutoPageControlFlags()...),
|
||||
Constraints: append([]shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-100"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-limit"}, Description: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||
},
|
||||
}, shortcut.AutoPageControlConstraints()...),
|
||||
Tips: []string{
|
||||
`dws chat +conversation-list --limit 50`,
|
||||
`dws chat +conversation-list --page-all --limit 100`,
|
||||
@@ -338,6 +338,9 @@ var ConversationList = shortcut.Shortcut{
|
||||
if pageLimit := rt.Int("page-limit"); pageLimit < 1 || pageLimit > 500 {
|
||||
return apperrors.NewValidation("--page-limit 必须在 1-500 之间")
|
||||
}
|
||||
if err := shortcut.ValidateAutoPageControls(rt); err != nil {
|
||||
return apperrors.NewValidation(err.Error())
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
@@ -353,9 +356,20 @@ var ConversationList = shortcut.Shortcut{
|
||||
complete := false
|
||||
hasMore := false
|
||||
nextCursor := int64(0)
|
||||
stopReason := "source_complete"
|
||||
truncatedByPageLimit := false
|
||||
truncatedByResultLimit := false
|
||||
unsafeResultContinuation := false
|
||||
failures := make([]map[string]any, 0)
|
||||
for pagesFetched < pageLimit {
|
||||
params := map[string]any{"limit": rt.Int("limit")}
|
||||
if pagesFetched > 0 {
|
||||
if err := shortcut.WaitAutoPageDelay(rt); err != nil {
|
||||
failures = append(failures, map[string]any{"stage": "conversation-page-delay", "cursor": cursor, "error": err.Error()})
|
||||
stopReason = "delay_interrupted"
|
||||
break
|
||||
}
|
||||
}
|
||||
params := map[string]any{"limit": shortcut.AutoPageRequestSize(rt, rt.Int("limit"), len(convs))}
|
||||
if cursor > 0 {
|
||||
params["cursor"] = cursor
|
||||
}
|
||||
@@ -371,6 +385,7 @@ var ConversationList = shortcut.Shortcut{
|
||||
break
|
||||
}
|
||||
pagesFetched++
|
||||
overflowOnPage := false
|
||||
for _, conversation := range conversationListProject(data) {
|
||||
id := strings.TrimSpace(fmt.Sprint(conversation["openConversationId"]))
|
||||
if id != "" && id != "<nil>" {
|
||||
@@ -379,6 +394,11 @@ var ConversationList = shortcut.Shortcut{
|
||||
}
|
||||
seenConversations[id] = true
|
||||
}
|
||||
if maxItems := rt.Int("max-items"); maxItems > 0 && len(convs) >= maxItems {
|
||||
truncatedByResultLimit = true
|
||||
overflowOnPage = true
|
||||
continue
|
||||
}
|
||||
convs = append(convs, conversation)
|
||||
}
|
||||
page := chatmsg.Pagination(data)
|
||||
@@ -388,6 +408,14 @@ var ConversationList = shortcut.Shortcut{
|
||||
failures = append(failures, map[string]any{"stage": "conversation-pagination", "error": "下层未返回 hasMore,无法证明结果完整"})
|
||||
break
|
||||
}
|
||||
if overflowOnPage {
|
||||
hasMore = true
|
||||
nextCursor = 0
|
||||
unsafeResultContinuation = true
|
||||
failures = append(failures, map[string]any{"stage": "conversation-pagination", "error": "下层返回条数超过请求的剩余额度,无法生成不跳项的安全续页游标"})
|
||||
stopReason = "pagination_error"
|
||||
break
|
||||
}
|
||||
if !hasMore {
|
||||
complete = true
|
||||
break
|
||||
@@ -398,27 +426,56 @@ var ConversationList = shortcut.Shortcut{
|
||||
break
|
||||
}
|
||||
if !rt.Bool("page-all") {
|
||||
stopReason = "single_page"
|
||||
break
|
||||
}
|
||||
if maxItems := rt.Int("max-items"); maxItems > 0 && len(convs) >= maxItems {
|
||||
truncatedByResultLimit = true
|
||||
stopReason = "result_limit"
|
||||
break
|
||||
}
|
||||
seenCursors[nextCursor] = true
|
||||
cursor = nextCursor
|
||||
}
|
||||
if rt.Bool("page-all") && hasMore && pagesFetched == pageLimit {
|
||||
failures = append(failures, map[string]any{"stage": "conversation-page-limit", "error": fmt.Sprintf("达到 --page-limit=%d,仍有更多会话", pageLimit)})
|
||||
if rt.Bool("page-all") && hasMore && pagesFetched == pageLimit && !truncatedByResultLimit {
|
||||
truncatedByPageLimit = true
|
||||
stopReason = "page_limit"
|
||||
}
|
||||
payload := map[string]any{
|
||||
"count": len(convs),
|
||||
"conversations": convs,
|
||||
"pagesFetched": pagesFetched,
|
||||
"complete": complete,
|
||||
"hasMore": hasMore,
|
||||
"nextCursor": nextCursor,
|
||||
"paginationKnown": len(failures) == 0 || hasMore,
|
||||
"failedCount": len(failures),
|
||||
"failures": failures,
|
||||
"partial": len(failures) > 0,
|
||||
"count": len(convs),
|
||||
"conversations": convs,
|
||||
"pagesFetched": pagesFetched,
|
||||
"complete": complete,
|
||||
"hasMore": hasMore,
|
||||
"nextCursor": nextCursor,
|
||||
"paginationKnown": len(failures) == 0 || hasMore,
|
||||
"stopReason": stopReason,
|
||||
"truncatedByPageLimit": truncatedByPageLimit,
|
||||
"truncatedByResultLimit": truncatedByResultLimit,
|
||||
"failedCount": len(failures),
|
||||
"failures": failures,
|
||||
"partial": len(failures) > 0,
|
||||
}
|
||||
return rt.Output(payload)
|
||||
chatmsg.ApplyTruncation(payload)
|
||||
if err := rt.Output(payload); err != nil {
|
||||
return err
|
||||
}
|
||||
if stopReason == "delay_interrupted" && rt.Command().Context().Err() != nil {
|
||||
return rt.Command().Context().Err()
|
||||
}
|
||||
if unsafeResultContinuation {
|
||||
return apperrors.NewAPI(
|
||||
fmt.Sprintf("会话列表分页未完成:成功读取 %d 页,存在 %d 个失败项", pagesFetched, len(failures)),
|
||||
apperrors.WithOperation("im/list_all_conversations"),
|
||||
apperrors.WithReason("conversation_list_incomplete"),
|
||||
apperrors.WithOrigin("mcp_gateway"),
|
||||
apperrors.WithFailureStage("pagination"),
|
||||
apperrors.WithExecutionStarted(true),
|
||||
apperrors.WithRetryable(true),
|
||||
apperrors.WithHint("请根据 failures 和 nextCursor 重试"),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,9 @@ package chat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
@@ -148,6 +150,96 @@ func TestCrossPlatformCoverageConversationListSinglePagePreservesTypedCursor(t *
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageConversationListMaxItemsPublishesStableTruncation(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/list_all_conversations": `{"result":{"conversationList":[{"openConversationId":"cid-1"}],"hasMore":true,"nextCursor":2}}`,
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{"chat", "+conversation-list", "--page-all", "--max-items", "1", "--page-delay", "0"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["count"] != float64(1) || payload["truncated"] != true ||
|
||||
payload["truncatedByResultLimit"] != true || payload["stopReason"] != "result_limit" {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
if len(fake.calls) != 1 || fake.calls[0].args["limit"] != 1 || payload["nextCursor"] != float64(2) {
|
||||
t.Fatalf("unsafe continuation: calls=%#v payload=%#v", fake.calls, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageConversationListRejectsOversizedLimitPage(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/list_all_conversations": `{"result":{"conversationList":[{"openConversationId":"cid-1"},{"openConversationId":"cid-2"}],"hasMore":true,"nextCursor":2}}`,
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{"chat", "+conversation-list", "--page-all", "--max-items", "1"})
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatal("oversized lower page unexpectedly published a safe continuation")
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["stopReason"] != "pagination_error" || payload["failedCount"] != float64(1) || payload["nextCursor"] != float64(0) {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageConversationListPropagatesDelayCancellation(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/list_all_conversations": `{"result":{"conversationList":[{"openConversationId":"cid-1"}],"hasMore":true,"nextCursor":2}}`,
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
root.SetContext(ctx)
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{"chat", "+conversation-list", "--page-all", "--page-delay", "1"})
|
||||
if err := root.Execute(); err == nil || err != context.Canceled {
|
||||
t.Fatalf("delay cancellation error = %v, want context.Canceled", err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["stopReason"] != "delay_interrupted" || payload["failedCount"] != float64(1) {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageConversationListAutoPageValidationAndOutputFailure(t *testing.T) {
|
||||
helpers.InitDeps(&larkAlignmentCaller{})
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs([]string{"chat", "+conversation-list", "--max-items", "1"})
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatal("max-items without page-all unexpectedly succeeded")
|
||||
}
|
||||
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/list_all_conversations": `{"result":{"conversationList":[],"hasMore":false}}`,
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root = newPlatformCoverageRoot()
|
||||
root.SetOut(chatOutputErrorWriter{err: errors.New("fixture output")})
|
||||
root.SetArgs([]string{"chat", "+conversation-list"})
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatal("output error was swallowed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageConversationListDeduplicatesStableIDs(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/list_all_conversations": `{"result":{"conversationList":[{"openConversationId":"cid-1","title":"一"},{"openConversationId":"cid-1","title":"重复"}],"hasMore":false}}`,
|
||||
|
||||
@@ -878,16 +878,16 @@ var ChatListAll = shortcut.Shortcut{
|
||||
Examples: []string{"dws chat +chat-list-all --limit 50"},
|
||||
},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
Flags: append([]shortcut.Flag{
|
||||
{Name: "limit", Type: shortcut.FlagInt, Default: "100", Desc: "每页返回数量;--limit 必须在 1-200 之间"},
|
||||
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,翻页传 nextCursor"},
|
||||
{Name: "page-all", Type: shortcut.FlagBool, Desc: "沿 nextCursor 自动读取全部已加入群;--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||
{Name: "page-all", Type: shortcut.FlagBool, Desc: "沿 nextCursor 自动读取全部已加入群;--page-limit 仅与 --page-all 一起使用且范围 1-500;--max-items/--page-delay 仅与 --page-all 一起使用;值必须大于等于 0"},
|
||||
{Name: "page-limit", Type: shortcut.FlagInt, Default: "50", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
}, shortcut.AutoPageControlFlags()...),
|
||||
Constraints: append([]shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-200 之间"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-limit"}, Description: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||
},
|
||||
}, shortcut.AutoPageControlConstraints()...),
|
||||
Tips: []string{
|
||||
`dws chat +chat-list-all --limit 50`,
|
||||
`dws chat +chat-list-all --limit 200 --page-all --page-limit 50`,
|
||||
@@ -908,6 +908,9 @@ func validateChatListAll(rt *shortcut.RuntimeContext) error {
|
||||
return apperrors.NewValidation("--page-limit 必须在 1-500 之间")
|
||||
}
|
||||
}
|
||||
if err := shortcut.ValidateAutoPageControls(rt); err != nil {
|
||||
return apperrors.NewValidation(err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -958,10 +961,21 @@ func readAllChatListAll(rt *shortcut.RuntimeContext, baseParams map[string]any)
|
||||
hasMore := false
|
||||
stopReason := "source_complete"
|
||||
truncatedByPageLimit := false
|
||||
truncatedByResultLimit := false
|
||||
var nextCursor any
|
||||
|
||||
for pagesFetched < pageLimit {
|
||||
params := map[string]any{"limit": baseParams["limit"]}
|
||||
if pagesFetched > 0 {
|
||||
if err := shortcut.WaitAutoPageDelay(rt); err != nil {
|
||||
failures = append(failures, map[string]any{
|
||||
"page": pagesFetched + 1, "stage": "delay", "cursor": cursorKey, "error": err.Error(),
|
||||
})
|
||||
stopReason = "delay_interrupted"
|
||||
break
|
||||
}
|
||||
}
|
||||
pageSize, _ := baseParams["limit"].(int)
|
||||
params := map[string]any{"limit": shortcut.AutoPageRequestSize(rt, pageSize, len(allGroups))}
|
||||
if cursorKey != "0" {
|
||||
params["cursor"] = cursorValue
|
||||
}
|
||||
@@ -978,6 +992,7 @@ func readAllChatListAll(rt *shortcut.RuntimeContext, baseParams map[string]any)
|
||||
}
|
||||
pagesFetched++
|
||||
pageGroups := chatListAllProject(data)
|
||||
overflowOnPage := false
|
||||
for _, group := range pageGroups {
|
||||
id := strings.TrimSpace(fmt.Sprint(group["openConversationId"]))
|
||||
if id == "<nil>" {
|
||||
@@ -989,6 +1004,11 @@ func readAllChatListAll(rt *shortcut.RuntimeContext, baseParams map[string]any)
|
||||
if id != "" {
|
||||
seenGroups[id] = true
|
||||
}
|
||||
if maxItems := rt.Int("max-items"); maxItems > 0 && len(allGroups) >= maxItems {
|
||||
truncatedByResultLimit = true
|
||||
overflowOnPage = true
|
||||
continue
|
||||
}
|
||||
allGroups = append(allGroups, group)
|
||||
}
|
||||
|
||||
@@ -1003,6 +1023,16 @@ func readAllChatListAll(rt *shortcut.RuntimeContext, baseParams map[string]any)
|
||||
break
|
||||
}
|
||||
hasMore = pageHasMore
|
||||
if overflowOnPage {
|
||||
hasMore = true
|
||||
nextCursor = nil
|
||||
failures = append(failures, map[string]any{
|
||||
"page": pagesFetched, "stage": "pagination",
|
||||
"error": "已加入群列表下层返回条数超过请求的剩余额度,无法生成不跳项的安全续页游标",
|
||||
})
|
||||
stopReason = "pagination_error"
|
||||
break
|
||||
}
|
||||
if !hasMore {
|
||||
complete = true
|
||||
nextCursor = nil
|
||||
@@ -1022,8 +1052,13 @@ func readAllChatListAll(rt *shortcut.RuntimeContext, baseParams map[string]any)
|
||||
seenCursors[nextKey] = true
|
||||
cursorKey = nextKey
|
||||
cursorValue = nextCursor
|
||||
if maxItems := rt.Int("max-items"); maxItems > 0 && len(allGroups) >= maxItems {
|
||||
truncatedByResultLimit = true
|
||||
stopReason = "result_limit"
|
||||
break
|
||||
}
|
||||
}
|
||||
if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit {
|
||||
if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit && !truncatedByResultLimit {
|
||||
truncatedByPageLimit = true
|
||||
stopReason = "page_limit"
|
||||
}
|
||||
@@ -1033,9 +1068,11 @@ func readAllChatListAll(rt *shortcut.RuntimeContext, baseParams map[string]any)
|
||||
"pagesFetched": pagesFetched, "paginationKnown": true,
|
||||
"complete": complete && len(failures) == 0, "hasMore": hasMore,
|
||||
"stopReason": stopReason, "truncatedByPageLimit": truncatedByPageLimit,
|
||||
"failedCount": len(failures), "failures": failures,
|
||||
"truncatedByResultLimit": truncatedByResultLimit,
|
||||
"failedCount": len(failures), "failures": failures,
|
||||
"partial": len(failures) > 0 && len(allGroups) > 0,
|
||||
}
|
||||
chatmsg.ApplyTruncation(payload)
|
||||
if hasMore && nextCursor != nil {
|
||||
payload["nextCursor"] = nextCursor
|
||||
}
|
||||
|
||||
@@ -634,6 +634,7 @@ func readAllDirectMessages(rt *shortcut.RuntimeContext, params map[string]any) (
|
||||
payload["hasMore"] = hasMore
|
||||
payload["stopReason"] = stopReason
|
||||
payload["truncatedByPageLimit"] = truncatedByPageLimit
|
||||
chatmsg.ApplyTruncation(payload)
|
||||
payload["failedCount"] = len(failures)
|
||||
payload["failures"] = failures
|
||||
payload["partial"] = len(failures) > 0 && len(messages) > 0
|
||||
|
||||
@@ -5,6 +5,7 @@ package chat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
@@ -73,11 +74,86 @@ func TestCrossPlatformCoverageFlagListPageTokenAndPageLimit(t *testing.T) {
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["complete"] != false || payload["nextCursor"] != float64(9) || payload["truncatedByPageLimit"] != true || payload["stopReason"] != "page_limit" {
|
||||
if payload["complete"] != false || payload["nextCursor"] != float64(9) || payload["truncated"] != true || payload["truncatedByPageLimit"] != true || payload["stopReason"] != "page_limit" {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFlagListMaxItemsPublishesStableTruncation(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/list_message_favorites": `{"result":{"items":[{"openMessageId":"msg-1"}],"hasMore":true,"nextCursor":9}}`,
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{"chat", "+flag-list", "--page-all", "--max-items", "1", "--page-delay", "0"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["count"] != float64(1) || payload["truncated"] != true ||
|
||||
payload["truncatedByResultLimit"] != true || payload["stopReason"] != "result_limit" {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
if len(fake.calls) != 1 || fake.calls[0].args["size"] != "1" || payload["nextCursor"] != float64(9) {
|
||||
t.Fatalf("unsafe continuation: calls=%#v payload=%#v", fake.calls, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFlagListFailsClosedOnOversizeAndCanceledDelay(t *testing.T) {
|
||||
t.Run("oversized lower page", func(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/list_message_favorites": `{"result":{"items":[{"openMessageId":"m1"},{"openMessageId":"m2"}],"hasMore":true,"nextCursor":9}}`,
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{"chat", "+flag-list", "--page-all", "--max-items", "1"})
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatal("oversized lower page unexpectedly published a continuation")
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["stopReason"] != "pagination_error" || payload["failedCount"] != float64(1) || payload["nextCursor"] != float64(0) {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
if len(fake.calls) != 1 || fake.calls[0].args["size"] != "1" {
|
||||
t.Fatalf("calls = %#v", fake.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("canceled delay", func(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/list_message_favorites": `{"result":{"items":[{"openMessageId":"m1"}],"hasMore":true,"nextCursor":9}}`,
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
root.SetContext(ctx)
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{"chat", "+flag-list", "--page-all", "--page-delay", "1"})
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatal("canceled delay unexpectedly succeeded")
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["stopReason"] != "delay_interrupted" || payload["failedCount"] != float64(1) {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFlagListFailureModes(t *testing.T) {
|
||||
t.Run("later read failure keeps partial result", func(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{
|
||||
@@ -137,7 +213,11 @@ func TestCrossPlatformCoverageFlagListPaginationValidation(t *testing.T) {
|
||||
{"--size", "31"},
|
||||
{"--page-token", "not-a-number"},
|
||||
{"--page-limit", "2"},
|
||||
{"--max-items", "1"},
|
||||
{"--page-delay", "1"},
|
||||
{"--page-all", "--page-limit", "0"},
|
||||
{"--page-all", "--max-items", "-1"},
|
||||
{"--page-all", "--page-delay", "-1"},
|
||||
{"--page-all", "--page-limit", "501"},
|
||||
{"--cursor", "1", "--page-token", "2"},
|
||||
} {
|
||||
|
||||
@@ -1049,14 +1049,6 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test
|
||||
},
|
||||
wantError: "biz-preserved",
|
||||
},
|
||||
{
|
||||
name: "unverified update preserves id",
|
||||
fake: &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/create_and_send_card": `{"bizId":"biz-unverified"}`,
|
||||
"im/update_streaming_card": `{"success":true,"errorCode":null}`,
|
||||
}},
|
||||
wantError: "biz-unverified",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
helpers.InitDeps(tc.fake)
|
||||
@@ -1074,6 +1066,27 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("success acknowledgement completes composite update", func(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/create_and_send_card": `{"bizId":"biz-acknowledged"}`,
|
||||
"im/update_streaming_card": `{"success":true,"errorCode":null}`,
|
||||
}}
|
||||
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].tool != "update_streaming_card" {
|
||||
t.Fatalf("calls = %#v", fake.calls)
|
||||
}
|
||||
})
|
||||
|
||||
for _, args := range [][]string{
|
||||
{"--group", "cid", "--content", "x", "--flow-status", "6"},
|
||||
{"--group", "cid", "--flow-status", "2"},
|
||||
@@ -1091,7 +1104,7 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMessagesUpdateCardRejectsFalseSuccess(t *testing.T) {
|
||||
func TestCrossPlatformCoverageMessagesUpdateCardVerifiesSuccess(t *testing.T) {
|
||||
t.Run("agent shortcut owns confirmation boundary", func(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/update_streaming_card": `{"result":{"bizId":"biz-confirm","updated":true}}`,
|
||||
@@ -1115,7 +1128,7 @@ func TestCrossPlatformCoverageMessagesUpdateCardRejectsFalseSuccess(t *testing.T
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("generic success is unverified", func(t *testing.T) {
|
||||
t.Run("success acknowledgement is verified", func(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/update_streaming_card": `{"success":true,"errorCode":null}`,
|
||||
}}
|
||||
@@ -1128,10 +1141,8 @@ func TestCrossPlatformCoverageMessagesUpdateCardRejectsFalseSuccess(t *testing.T
|
||||
"--flow-status", "3",
|
||||
"--yes",
|
||||
})
|
||||
err := root.Execute()
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != "streaming_card_update_unverified" {
|
||||
t.Fatalf("error = %#v, want streaming_card_update_unverified", err)
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fake.calls) != 1 || fake.calls[0].tool != "update_streaming_card" {
|
||||
t.Fatalf("calls = %#v", fake.calls)
|
||||
|
||||
@@ -510,21 +510,21 @@ var FlagList = shortcut.Shortcut{
|
||||
Description: "分页查询当前用户收藏的消息,支持有界自动翻页",
|
||||
Intent: "当你要查看当前用户的 DingTalk message favorite 列表时使用;默认读取一页,明确要求全部收藏时加 --page-all,并用 --page-limit 保持有界。底层实际使用数字 cursor,结果按 openMessageId 去重并公开 complete、hasMore、nextCursor、stopReason 和 failures;它不把 message favorite 与 Pin、会话置顶或 Lark feed-layer thread flag 混为一谈。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Flags: []shortcut.Flag{
|
||||
Flags: append([]shortcut.Flag{
|
||||
{Name: "page-size", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量;下游真实上限为 30,显式页大小必须在 1-30 之间"},
|
||||
{Name: "size", Type: shortcut.FlagInt, Default: "20", Desc: "--page-size 的兼容别名;下游真实上限为 30,显式页大小必须在 1-30 之间"},
|
||||
{Name: "page-token", Type: shortcut.FlagString, Desc: "Lark 对齐的起始分页参数;起始 cursor 必须是非负整数"},
|
||||
{Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "钉钉数字分页游标;起始 cursor 必须是非负整数"},
|
||||
{Name: "page-all", Type: shortcut.FlagBool, Desc: "自动读取全部收藏分页;--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||
{Name: "page-all", Type: shortcut.FlagBool, Desc: "自动读取全部收藏分页;--page-limit 仅与 --page-all 一起使用且范围 1-500;--max-items/--page-delay 仅与 --page-all 一起使用;值必须大于等于 0"},
|
||||
{Name: "page-limit", Type: shortcut.FlagInt, Default: "20", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
}, shortcut.AutoPageControlFlags()...),
|
||||
Constraints: append([]shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"page-size", "size"}},
|
||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"page-token", "cursor"}},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-size", "size"}, Description: "显式页大小必须在 1-30 之间"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-token", "cursor"}, Description: "起始 cursor 必须是非负整数"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-limit"}, Description: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||
},
|
||||
}, shortcut.AutoPageControlConstraints()...),
|
||||
Tips: []string{
|
||||
`dws chat +flag-list --cursor 0 --page-size 20`,
|
||||
`dws chat +flag-list --page-size 30 --page-all --page-limit 20`,
|
||||
@@ -548,6 +548,9 @@ func validateFlagList(rt *shortcut.RuntimeContext) error {
|
||||
return apperrors.NewValidation("--page-limit 必须在 1-500 之间")
|
||||
}
|
||||
}
|
||||
if err := shortcut.ValidateAutoPageControls(rt); err != nil {
|
||||
return apperrors.NewValidation(err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -598,9 +601,20 @@ func executeFlagList(rt *shortcut.RuntimeContext) error {
|
||||
var cursorErr error
|
||||
stopReason := "source_complete"
|
||||
truncatedByPageLimit := false
|
||||
truncatedByResultLimit := false
|
||||
|
||||
for pagesFetched < pageLimit {
|
||||
data, callErr := rt.CallMCPData("im", "list_message_favorites", flagListRequestParams(cursor, pageSize))
|
||||
if pagesFetched > 0 {
|
||||
if err := shortcut.WaitAutoPageDelay(rt); err != nil {
|
||||
failures = append(failures, map[string]any{
|
||||
"page": pagesFetched + 1, "stage": "delay", "cursor": cursor, "error": err.Error(),
|
||||
})
|
||||
stopReason = "delay_interrupted"
|
||||
break
|
||||
}
|
||||
}
|
||||
requestPageSize := shortcut.AutoPageRequestSize(rt, pageSize, len(items))
|
||||
data, callErr := rt.CallMCPData("im", "list_message_favorites", flagListRequestParams(cursor, requestPageSize))
|
||||
if callErr != nil {
|
||||
if pagesFetched == 0 {
|
||||
return callErr
|
||||
@@ -613,6 +627,7 @@ func executeFlagList(rt *shortcut.RuntimeContext) error {
|
||||
}
|
||||
pagesFetched++
|
||||
pageItems := flagListItems(data)
|
||||
overflowOnPage := false
|
||||
for _, item := range pageItems {
|
||||
messageID := firstNonEmptyMapString(item, "openMessageId", "messageId", "itemId", "id")
|
||||
if messageID != "" && seenMessages[messageID] {
|
||||
@@ -621,6 +636,11 @@ func executeFlagList(rt *shortcut.RuntimeContext) error {
|
||||
if messageID != "" {
|
||||
seenMessages[messageID] = true
|
||||
}
|
||||
if maxItems := rt.Int("max-items"); maxItems > 0 && len(items) >= maxItems {
|
||||
truncatedByResultLimit = true
|
||||
overflowOnPage = true
|
||||
continue
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
@@ -649,6 +669,16 @@ func executeFlagList(rt *shortcut.RuntimeContext) error {
|
||||
}
|
||||
}
|
||||
hasMore = pageHasMore
|
||||
if overflowOnPage {
|
||||
hasMore = true
|
||||
nextCursor = 0
|
||||
failures = append(failures, map[string]any{
|
||||
"page": pagesFetched, "stage": "pagination",
|
||||
"error": "收藏列表下层返回条数超过请求的剩余额度,无法生成不跳项的安全续页游标",
|
||||
})
|
||||
stopReason = "pagination_error"
|
||||
break
|
||||
}
|
||||
if !hasMore {
|
||||
complete = true
|
||||
nextCursor = 0
|
||||
@@ -669,28 +699,35 @@ func executeFlagList(rt *shortcut.RuntimeContext) error {
|
||||
}
|
||||
seenCursors[nextCursor] = true
|
||||
cursor = nextCursor
|
||||
if maxItems := rt.Int("max-items"); maxItems > 0 && len(items) >= maxItems {
|
||||
truncatedByResultLimit = true
|
||||
stopReason = "result_limit"
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit {
|
||||
if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit && !truncatedByResultLimit {
|
||||
truncatedByPageLimit = rt.Bool("page-all")
|
||||
if truncatedByPageLimit {
|
||||
stopReason = "page_limit"
|
||||
}
|
||||
}
|
||||
payload := map[string]any{
|
||||
"count": len(items),
|
||||
"items": items,
|
||||
"pagesFetched": pagesFetched,
|
||||
"paginationKnown": paginationKnown,
|
||||
"complete": complete && len(failures) == 0,
|
||||
"hasMore": hasMore,
|
||||
"nextCursor": nextCursor,
|
||||
"stopReason": stopReason,
|
||||
"truncatedByPageLimit": truncatedByPageLimit,
|
||||
"failedCount": len(failures),
|
||||
"failures": failures,
|
||||
"partial": len(failures) > 0 && len(items) > 0,
|
||||
"count": len(items),
|
||||
"items": items,
|
||||
"pagesFetched": pagesFetched,
|
||||
"paginationKnown": paginationKnown,
|
||||
"complete": complete && len(failures) == 0,
|
||||
"hasMore": hasMore,
|
||||
"nextCursor": nextCursor,
|
||||
"stopReason": stopReason,
|
||||
"truncatedByPageLimit": truncatedByPageLimit,
|
||||
"truncatedByResultLimit": truncatedByResultLimit,
|
||||
"failedCount": len(failures),
|
||||
"failures": failures,
|
||||
"partial": len(failures) > 0 && len(items) > 0,
|
||||
}
|
||||
chatmsg.ApplyTruncation(payload)
|
||||
if outputErr := rt.Output(payload); outputErr != nil {
|
||||
return outputErr
|
||||
}
|
||||
|
||||
@@ -5,12 +5,14 @@ package chat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageChatListAllPageAllUsesNumericCursorAndDeduplicates(t *testing.T) {
|
||||
@@ -95,11 +97,86 @@ func TestCrossPlatformCoverageDirectMessagesPageLimitPublishesExecutableContinua
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["complete"] != false || payload["hasMore"] != true ||
|
||||
payload["truncatedByPageLimit"] != true || payload["stopReason"] != "page_limit" || payload["nextPage"] == nil {
|
||||
payload["truncated"] != true || payload["truncatedByPageLimit"] != true || payload["stopReason"] != "page_limit" || payload["nextPage"] == nil {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatListAllMaxItemsPublishesStableTruncation(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/list_my_groups_pagination": `{"result":{"groups":[{"openConversationId":"g1"}],"hasMore":true,"nextCursor":88}}`,
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{"chat", "+chat-list-all", "--page-all", "--max-items", "1", "--page-delay", "0"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["count"] != float64(1) || payload["truncated"] != true ||
|
||||
payload["truncatedByResultLimit"] != true || payload["stopReason"] != "result_limit" {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
if len(fake.calls) != 1 || fake.calls[0].args["limit"] != 1 || payload["nextCursor"] != float64(88) {
|
||||
t.Fatalf("unsafe continuation: calls=%#v payload=%#v", fake.calls, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatListAllFailsClosedOnOversizeAndCanceledDelay(t *testing.T) {
|
||||
t.Run("oversized lower page", func(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/list_my_groups_pagination": `{"result":{"groups":[{"openConversationId":"g1"},{"openConversationId":"g2"}],"hasMore":true,"nextCursor":88}}`,
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{"chat", "+chat-list-all", "--page-all", "--max-items", "1"})
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatal("oversized lower page unexpectedly published a continuation")
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["stopReason"] != "pagination_error" || payload["failedCount"] != float64(1) {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
if len(fake.calls) != 1 || fake.calls[0].args["limit"] != 1 || payload["nextCursor"] != nil {
|
||||
t.Fatalf("unsafe continuation: calls=%#v payload=%#v", fake.calls, payload)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("canceled delay", func(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/list_my_groups_pagination": `{"result":{"groups":[{"openConversationId":"g1"}],"hasMore":true,"nextCursor":88}}`,
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
root.SetContext(ctx)
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{"chat", "+chat-list-all", "--page-all", "--page-delay", "1"})
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatal("canceled delay unexpectedly succeeded")
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["stopReason"] != "delay_interrupted" || payload["failedCount"] != float64(1) {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDirectMessagesPageAllFailsClosedWithoutMillisecondCursor(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"chat/list_individual_chat_message": `{"result":{"messages":[{"openMessageId":"m1"}],"hasMore":true}}`,
|
||||
@@ -163,6 +240,17 @@ func TestCrossPlatformCoverageChatRemainingPaginationValidation(t *testing.T) {
|
||||
t.Fatalf("invalid args succeeded: %v", args)
|
||||
}
|
||||
}
|
||||
root := newPlatformCoverageRoot()
|
||||
cmd, _, err := root.Find([]string{"chat", "+chat-list-all"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.Flags().Set("max-items", "1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := validateChatListAll(shortcut.RuntimeContextForTest(cmd, ChatListAll)); err == nil {
|
||||
t.Fatal("direct auto-page validation unexpectedly succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatListAllAdditionalEdges(t *testing.T) {
|
||||
|
||||
@@ -59,9 +59,8 @@ func isCardBizIDPlaceholder(value string) bool {
|
||||
}
|
||||
|
||||
// VerifyStreamingCardUpdate requires affirmative evidence that the requested
|
||||
// write took effect. A transport-level {success:true,errorCode:null} only says
|
||||
// that the RPC returned normally and is deliberately not accepted as proof of
|
||||
// a card update.
|
||||
// write took effect. update_streaming_card may acknowledge an applied write
|
||||
// with success=true without returning an updated flag or affected count.
|
||||
func VerifyStreamingCardUpdate(requestedBizID string, response map[string]any) (string, error) {
|
||||
requestedBizID = strings.TrimSpace(requestedBizID)
|
||||
observation := cardUpdateObservation{bizIDs: map[string]struct{}{}}
|
||||
@@ -132,6 +131,23 @@ func observeCardUpdateMap(value map[string]any, observation *cardUpdateObservati
|
||||
}
|
||||
}
|
||||
}
|
||||
errorCode, hasErrorCode := value["errorCode"]
|
||||
errorCodeEmpty := hasErrorCode && cardUpdateErrorCodeEmpty(errorCode)
|
||||
if hasErrorCode && !errorCodeEmpty {
|
||||
setNegativeCardUpdateEvidence(observation, "errorCode=non-empty")
|
||||
}
|
||||
if success, ok := value["success"].(bool); ok {
|
||||
if success {
|
||||
// Record success=true only when the same response envelope explicitly
|
||||
// includes its business-error field. A non-empty code is already
|
||||
// negative evidence above, so the two signals reject the conflict.
|
||||
if hasErrorCode {
|
||||
setPositiveCardUpdateEvidence(observation, "success=true")
|
||||
}
|
||||
} else {
|
||||
setNegativeCardUpdateEvidence(observation, "success=false")
|
||||
}
|
||||
}
|
||||
|
||||
// Only documented response envelopes are traversed. This prevents an
|
||||
// unrelated extension field containing "updated":true from proving the
|
||||
@@ -143,6 +159,14 @@ func observeCardUpdateMap(value map[string]any, observation *cardUpdateObservati
|
||||
}
|
||||
}
|
||||
|
||||
func cardUpdateErrorCodeEmpty(value any) bool {
|
||||
if value == nil {
|
||||
return true
|
||||
}
|
||||
code, ok := value.(string)
|
||||
return ok && strings.TrimSpace(code) == ""
|
||||
}
|
||||
|
||||
func setPositiveCardUpdateEvidence(observation *cardUpdateObservation, evidence string) {
|
||||
if observation.positiveEvidence == "" {
|
||||
observation.positiveEvidence = evidence
|
||||
|
||||
@@ -62,7 +62,13 @@ func TestCrossPlatformCoverageVerifyStreamingCardUpdate(t *testing.T) {
|
||||
{name: "matching id", response: map[string]any{"result": map[string]any{"bizId": "biz-1", "applied": true}}, wantProof: "applied=true"},
|
||||
{name: "conflicting evidence", response: map[string]any{"updated": true, "applied": false}, wantErrIs: ErrCardUpdateUnverified},
|
||||
{name: "zero affected", response: map[string]any{"affectedCount": 0}, wantErrIs: ErrCardUpdateNotApplied},
|
||||
{name: "false success has no write proof", response: map[string]any{"success": true, "errorCode": nil}, wantErrIs: ErrCardUpdateUnverified},
|
||||
{name: "success acknowledgement", response: map[string]any{"success": true, "errorCode": nil}, wantProof: "success=true"},
|
||||
{name: "success acknowledgement with empty error code", response: map[string]any{"success": true, "errorCode": " "}, wantProof: "success=true"},
|
||||
{name: "success without explicit error code", response: map[string]any{"success": true}, wantErrIs: ErrCardUpdateUnverified},
|
||||
{name: "success conflicts with error code", response: map[string]any{"success": true, "errorCode": "InternalError"}, wantErrIs: ErrCardUpdateUnverified},
|
||||
{name: "success conflicts with numeric error code", response: map[string]any{"success": true, "errorCode": float64(500)}, wantErrIs: ErrCardUpdateUnverified},
|
||||
{name: "error code without success", response: map[string]any{"errorCode": "InternalError"}, wantErrIs: ErrCardUpdateNotApplied},
|
||||
{name: "failed acknowledgement", response: map[string]any{"success": false}, wantErrIs: ErrCardUpdateNotApplied},
|
||||
{name: "explicitly not updated", response: map[string]any{"result": map[string]any{"updated": false}}, wantErrIs: ErrCardUpdateNotApplied},
|
||||
{name: "mismatched id", response: map[string]any{"result": map[string]any{"bizId": "biz-2", "updated": true}}, wantErrIs: ErrCardUpdateBizIDDrift},
|
||||
{name: "unrelated extension ignored", response: map[string]any{"extension": map[string]any{"updated": true}}, wantErrIs: ErrCardUpdateUnverified},
|
||||
|
||||
@@ -86,6 +86,7 @@ var messageResultContractV1 = MessageResultContract{
|
||||
"hasMore",
|
||||
"nextPage",
|
||||
"stopReason",
|
||||
"truncated",
|
||||
"truncatedByPageLimit",
|
||||
"truncatedByResultLimit",
|
||||
"failedCount",
|
||||
@@ -122,9 +123,21 @@ func NewMessageListPayload(messages []map[string]any) map[string]any {
|
||||
"failedCount": 0,
|
||||
"failures": []map[string]any{},
|
||||
"partial": false,
|
||||
"truncated": false,
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyTruncation publishes the stable aggregate bit while preserving the
|
||||
// established reason-specific fields for compatibility and diagnosis.
|
||||
func ApplyTruncation(payload map[string]any) {
|
||||
if payload == nil {
|
||||
return
|
||||
}
|
||||
byPage, _ := payload["truncatedByPageLimit"].(bool)
|
||||
byItems, _ := payload["truncatedByResultLimit"].(bool)
|
||||
payload["truncated"] = byPage || byItems
|
||||
}
|
||||
|
||||
// ListMessageItems returns message rows from the common list response envelopes.
|
||||
func ListMessageItems(data map[string]any) []map[string]any {
|
||||
if data == nil {
|
||||
|
||||
@@ -441,6 +441,7 @@ func TestCrossPlatformCoverageReactionsNormalizesEmotionReplyList(t *testing.T)
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageApplyPaginationReadsNestedEnvelope(t *testing.T) {
|
||||
ApplyTruncation(nil)
|
||||
payload := map[string]any{"count": 98}
|
||||
ApplyPagination(payload, map[string]any{
|
||||
"result": map[string]any{
|
||||
|
||||
@@ -0,0 +1,524 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
var uploadDriveFile = helpers.UploadDriveFileData
|
||||
|
||||
var driveRestoreWait = time.Sleep
|
||||
|
||||
var (
|
||||
driveGetwd = os.Getwd
|
||||
driveEvalSymlinks = filepath.EvalSymlinks
|
||||
driveRel = filepath.Rel
|
||||
driveStat = os.Stat
|
||||
)
|
||||
|
||||
var RecycleList = shortcut.Shortcut{
|
||||
Service: "drive", Command: "+recycle-list", Product: "drive",
|
||||
Description: "严格分页列出钉盘回收站",
|
||||
Intent: "查找可恢复的回收项并获取 recycleItemId 时使用。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
|
||||
Contract: driveContract(
|
||||
"+recycle-list", "严格分页列出钉盘回收站",
|
||||
"查找可恢复的回收项并获取 recycleItemId 时使用。",
|
||||
[]string{"恢复前必须先确认具体回收项;普通目录浏览使用 drive +list"},
|
||||
[]string{`dws drive +recycle-list --limit 20`},
|
||||
driveCollectionResult("items", "严格校验的回收站条目页"), driveCursorPagination(),
|
||||
contract.ParamDecl{Name: "cursor", Property: "nextCursor"},
|
||||
),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "space-id", Type: shortcut.FlagString, Desc: "钉盘空间 ID"},
|
||||
{Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量"},
|
||||
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标"},
|
||||
},
|
||||
Tips: []string{`dws drive +recycle-list --limit 20`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{"maxResults": rt.Int("limit")}
|
||||
if rt.Str("space-id") != "" {
|
||||
params["spaceId"] = rt.Str("space-id")
|
||||
}
|
||||
if rt.Str("cursor") != "" {
|
||||
params["nextCursor"] = rt.Str("cursor")
|
||||
}
|
||||
data, err := rt.CallMCPData("drive", "list_recycle_items", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, page, err := requireDriveCollection(data, "drive/list_recycle_items", "recycleItems")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows := projectDriveRows(items, map[string][]string{
|
||||
"recycleItemId": {"recycleItemId", "id"},
|
||||
"originalName": {"originalName", "name", "fileName", "title"},
|
||||
"originalPath": {"originalPath", "path"},
|
||||
"type": {"type", "fileType", "nodeType", "contentType"},
|
||||
"deleteTime": {"operatorTime", "deleteTime", "deletedTime", "recycleTime"},
|
||||
"fileSize": {"fileSize", "size"},
|
||||
})
|
||||
out := map[string]any{"count": len(rows), "items": rows}
|
||||
addDrivePagination(out, page)
|
||||
return rt.Output(out)
|
||||
},
|
||||
}
|
||||
|
||||
var RecycleRestore = shortcut.Shortcut{
|
||||
Service: "drive", Command: "+recycle-restore", Product: "drive",
|
||||
Description: "恢复已确认的回收站条目并读回节点",
|
||||
Intent: "已经通过 drive +recycle-list 确认回收项,并明确要求恢复时使用。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
|
||||
Contract: driveContract(
|
||||
"+recycle-restore", "恢复已确认的回收站条目并读回节点",
|
||||
"已经通过 drive +recycle-list 确认回收项,并明确要求恢复时使用。",
|
||||
[]string{"尚未确认回收项时先列表;不要把原节点 ID 当 recycleItemId"},
|
||||
[]string{`dws drive +recycle-restore --id <recycleItemId>`},
|
||||
driveObjectResult("恢复并读回验证后的节点"), nil,
|
||||
contract.ParamDecl{Name: "id", Property: "recycleItemId"},
|
||||
),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "id", Type: shortcut.FlagString, Desc: "回收项 ID", Required: true},
|
||||
},
|
||||
Tips: []string{`dws drive +recycle-restore --id <recycleItemId>`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
recycleItem, err := findDriveRecycleItem(rt, rt.Str("id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
written, err := rt.CallMCPWriteDataStrict("drive", "restore_recycle_item", map[string]any{"recycleItemId": rt.Str("id")})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := requireDriveWrite(written, "drive/restore_recycle_item"); err != nil {
|
||||
return err
|
||||
}
|
||||
nodeID := nestedString(written, "fileId", "nodeId", "dentryUuid", "id")
|
||||
if nodeID == "" {
|
||||
nodeID, err = findRestoredDriveNode(rt, recycleItem)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
verified, err := rt.CallMCPData("drive", "get_file_info", map[string]any{"fileId": nodeID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
verified, err = requireDriveObject(verified, "drive/get_file_info")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"success": true, "nodeId": nodeID, "file": verified})
|
||||
},
|
||||
}
|
||||
|
||||
func findDriveRecycleItem(rt *shortcut.RuntimeContext, recycleItemID string) (map[string]any, error) {
|
||||
params := map[string]any{"maxResults": 50}
|
||||
for pageNumber := 0; pageNumber < 20; pageNumber++ {
|
||||
data, err := rt.CallMCPData("drive", "list_recycle_items", params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items, page, err := requireDriveCollection(data, "drive/list_recycle_items", "recycleItems")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, item := range items {
|
||||
entry := item.(map[string]any)
|
||||
if nestedString(entry, "recycleItemId", "id") == recycleItemID {
|
||||
return entry, nil
|
||||
}
|
||||
}
|
||||
nextCursor := nestedString(page, "nextCursor", "nextToken", "nextPageToken")
|
||||
hasMore, hasMorePresent := boolField(page, "hasMore")
|
||||
if nextCursor == "" || (hasMorePresent && !hasMore) {
|
||||
break
|
||||
}
|
||||
params["nextCursor"] = nextCursor
|
||||
}
|
||||
return nil, driveResponseError("drive/list_recycle_items", "recycle_item_not_found", "回收站中没有找到指定 recycleItemId;未执行恢复")
|
||||
}
|
||||
|
||||
func findRestoredDriveNode(rt *shortcut.RuntimeContext, recycleItem map[string]any) (string, error) {
|
||||
name := nestedString(recycleItem, "originalName", "name", "fileName", "title")
|
||||
if name == "" {
|
||||
return "", driveResponseError("drive/restore_recycle_item", "missing_restored_identity", "恢复响应没有节点 ID,且恢复前回收项没有名称,无法安全读回终态")
|
||||
}
|
||||
searchName := name
|
||||
if extension := filepath.Ext(name); extension != "" {
|
||||
searchName = strings.TrimSuffix(name, extension)
|
||||
}
|
||||
originalPath := nestedString(recycleItem, "originalPath", "path")
|
||||
for attempt := 0; attempt < 4; attempt++ {
|
||||
if attempt > 0 {
|
||||
driveRestoreWait(750 * time.Millisecond)
|
||||
}
|
||||
if originalPath != "" {
|
||||
if nodeID, found, err := findRestoredDriveNodeAtOriginalPath(rt, originalPath, name); err != nil {
|
||||
return "", err
|
||||
} else if found {
|
||||
return nodeID, nil
|
||||
}
|
||||
}
|
||||
data, err := rt.CallMCPData("drive", "search_files", map[string]any{"keyword": searchName, "pageSize": 30})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
items, _, err := requireDriveCollection(data, "drive/search_files", "items", "files", "dentries", "entries", "nodes", "list")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
candidates := make([]string, 0, 1)
|
||||
for _, item := range items {
|
||||
entry := item.(map[string]any)
|
||||
entryName := nestedString(entry, "name", "fileName", "dentryName", "title")
|
||||
entryExtension := strings.TrimPrefix(nestedString(entry, "extension", "fileExtension", "ext"), ".")
|
||||
fullEntryName := entryName
|
||||
if entryExtension != "" && filepath.Ext(entryName) == "" {
|
||||
fullEntryName += "." + entryExtension
|
||||
}
|
||||
if entryName != name && fullEntryName != name && entryName != searchName {
|
||||
continue
|
||||
}
|
||||
if originalPath != "" {
|
||||
path := nestedString(entry, "path", "originalPath")
|
||||
if path != "" && path != originalPath {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if id := nestedString(entry, "fileId", "dentryUuid", "nodeId", "id"); id != "" {
|
||||
candidates = append(candidates, id)
|
||||
}
|
||||
}
|
||||
if len(candidates) == 1 {
|
||||
return candidates[0], nil
|
||||
}
|
||||
if len(candidates) > 1 {
|
||||
return "", driveResponseError("drive/restore_recycle_item", "restored_node_ambiguous", "服务端已接受恢复,但按原名称找到多个节点,无法唯一确认恢复终态;请用 +list 核对")
|
||||
}
|
||||
}
|
||||
return "", driveResponseError("drive/restore_recycle_item", "restored_node_not_found", "服务端已接受恢复,但没有返回节点 ID,且在有界等待后仍按原名称搜索不到恢复后的节点;远端效果未知,请先用 +list 确认")
|
||||
}
|
||||
|
||||
func findRestoredDriveNodeAtOriginalPath(rt *shortcut.RuntimeContext, originalPath, name string) (string, bool, error) {
|
||||
parentName := filepath.Base(filepath.Dir(originalPath))
|
||||
if parentName == "." || parentName == string(filepath.Separator) || parentName == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
data, err := rt.CallMCPData("drive", "search_files", map[string]any{"keyword": parentName, "pageSize": 30})
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
items, _, err := requireDriveCollection(data, "drive/search_files", "items", "files", "dentries", "entries", "nodes", "list")
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
parentIDs := make([]string, 0, 1)
|
||||
for _, item := range items {
|
||||
entry := item.(map[string]any)
|
||||
if nestedString(entry, "name", "fileName", "dentryName", "title") != parentName {
|
||||
continue
|
||||
}
|
||||
if id := nestedString(entry, "fileId", "dentryUuid", "nodeId", "id"); id != "" {
|
||||
parentIDs = append(parentIDs, id)
|
||||
}
|
||||
}
|
||||
if len(parentIDs) != 1 {
|
||||
return "", false, nil
|
||||
}
|
||||
data, err = rt.CallMCPData("drive", "list_files", map[string]any{"parentId": parentIDs[0], "maxResults": 50})
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
items, _, err = requireDriveCollection(data, "drive/list_files", "items", "files", "dentries", "entries", "nodes", "list")
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
var nodeID string
|
||||
for _, item := range items {
|
||||
entry := item.(map[string]any)
|
||||
if nestedString(entry, "name", "fileName", "dentryName", "title") != name {
|
||||
continue
|
||||
}
|
||||
if nodeID != "" {
|
||||
return "", false, driveResponseError("drive/list_files", "restored_node_ambiguous", "恢复后的原目录中存在多个同名节点,无法唯一确认终态")
|
||||
}
|
||||
nodeID = nestedString(entry, "fileId", "dentryUuid", "nodeId", "id")
|
||||
}
|
||||
return nodeID, nodeID != "", nil
|
||||
}
|
||||
|
||||
var StarList = shortcut.Shortcut{
|
||||
Service: "drive", Command: "+star-list", Product: "drive",
|
||||
Description: "严格分页列出当前用户收藏",
|
||||
Intent: "浏览当前用户收藏并获取后续可操作节点 ID 时使用。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
|
||||
Contract: driveContract(
|
||||
"+star-list", "严格分页列出当前用户收藏",
|
||||
"浏览当前用户收藏并获取后续可操作节点 ID 时使用。",
|
||||
[]string{"最近访问使用 drive +recent;目录浏览使用 drive +list"},
|
||||
[]string{`dws drive +star-list --limit 20`},
|
||||
driveCollectionResult("items", "严格校验的收藏条目页"), driveCursorPagination(),
|
||||
contract.ParamDecl{Name: "cursor", Property: "cursor"},
|
||||
),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量"},
|
||||
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标"},
|
||||
{Name: "content-types", Type: shortcut.FlagStringSlice, Desc: "内容类型过滤"},
|
||||
},
|
||||
Tips: []string{`dws drive +star-list --limit 20`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{"limit": rt.Int("limit")}
|
||||
if rt.Str("cursor") != "" {
|
||||
params["cursor"] = rt.Str("cursor")
|
||||
}
|
||||
if values := rt.StrSlice("content-types"); len(values) > 0 {
|
||||
params["contentTypes"] = values
|
||||
}
|
||||
data, err := rt.CallMCPData("drive", "get_star_list", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, page, err := requireDriveCollection(data, "drive/get_star_list", "starList")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows := projectDriveRows(items, map[string][]string{
|
||||
"nodeId": {"nodeId", "fileId", "dentryUuid", "id"},
|
||||
"name": {"name", "fileName", "title"},
|
||||
"type": {"type", "contentType", "nodeType"},
|
||||
"createTime": {"createTime", "starTime"},
|
||||
})
|
||||
out := map[string]any{"count": len(rows), "items": rows}
|
||||
addDrivePagination(out, page)
|
||||
return rt.Output(out)
|
||||
},
|
||||
}
|
||||
|
||||
var StarAdd = starMutationShortcut("+star-add", "收藏指定节点", "mark_star", "将指定文件或文档加入当前用户收藏。")
|
||||
var StarRemove = starMutationShortcut("+star-remove", "取消收藏指定节点", "unmark_star", "将指定文件或文档从当前用户收藏移除。")
|
||||
|
||||
func starMutationShortcut(command, description, tool, useWhen string) shortcut.Shortcut {
|
||||
return shortcut.Shortcut{
|
||||
Service: "drive", Command: command, Product: "drive", Description: description, Intent: useWhen,
|
||||
Risk: shortcut.RiskWrite, Safety: contract.SafetySpec{Effect: "write", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
|
||||
Contract: driveContract(command, description, useWhen,
|
||||
[]string{"查看收藏状态和列表使用 drive +star-list"},
|
||||
[]string{fmt.Sprintf("dws drive %s --node <dentryUuid>", command)},
|
||||
driveObjectResult(description+"的终态证据"), nil, contract.ParamDecl{Name: "node", Property: "nodeId"}),
|
||||
Flags: []shortcut.Flag{{Name: "node", Type: shortcut.FlagString, Desc: "节点 ID", Required: true}},
|
||||
Tips: []string{fmt.Sprintf("dws drive %s --node <dentryUuid>", command)},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
written, err := rt.CallMCPWriteDataStrict("drive", tool, map[string]any{"nodeId": rt.Str("node")})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
written, err = requireDriveWrite(written, "drive/"+tool)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"success": true, "nodeId": rt.Str("node"), "result": written})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
var PublishGet = shortcut.Shortcut{
|
||||
Service: "drive", Command: "+publish-get", Product: "drive",
|
||||
Description: "查询文件互联网公开状态",
|
||||
Intent: "查询文件当前互联网公开状态和权限时使用。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
|
||||
Contract: driveContract(
|
||||
"+publish-get", "查询文件互联网公开状态", "查询文件当前互联网公开状态和权限时使用。",
|
||||
[]string{"开启或关闭公开分别使用 drive +publish-set / +publish-unset"},
|
||||
[]string{`dws drive +publish-get --node <dentryUuid>`}, driveObjectResult("互联网公开状态"), nil,
|
||||
contract.ParamDecl{Name: "node", Property: "fileId"},
|
||||
),
|
||||
Flags: []shortcut.Flag{{Name: "node", Type: shortcut.FlagString, Desc: "文件 ID", Required: true}},
|
||||
Tips: []string{`dws drive +publish-get --node <dentryUuid>`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
data, err := rt.CallMCPData("drive", "get_file_publish_status", map[string]any{"fileId": rt.Str("node")})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err = requireDriveObject(data, "drive/get_file_publish_status")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(data)
|
||||
},
|
||||
}
|
||||
|
||||
var PublishSet = publishMutationShortcut("+publish-set", true)
|
||||
var PublishUnset = publishMutationShortcut("+publish-unset", false)
|
||||
|
||||
func publishMutationShortcut(command string, published bool) shortcut.Shortcut {
|
||||
description := "开启文件互联网公开发布"
|
||||
useWhen := "用户明确同意任何持链接者可访问,并已确认公开权限时使用。"
|
||||
if !published {
|
||||
description = "关闭文件互联网公开发布"
|
||||
useWhen = "用户明确要求让现有互联网公开链接失效时使用。"
|
||||
}
|
||||
flags := []shortcut.Flag{{Name: "node", Type: shortcut.FlagString, Desc: "文件 ID", Required: true}}
|
||||
if published {
|
||||
flags = append(flags, shortcut.Flag{Name: "permission", Type: shortcut.FlagString, Default: "DOWNLOADER", Desc: "公开权限", Enum: []string{"READER", "DOWNLOADER", "EDITOR"}})
|
||||
}
|
||||
return shortcut.Shortcut{
|
||||
Service: "drive", Command: command, Product: "drive", Description: description, Intent: useWhen,
|
||||
Risk: shortcut.RiskHighWrite, Safety: contract.SafetySpec{Effect: "write", Risk: "high", Confirmation: "user_required", Idempotency: "unknown"},
|
||||
Contract: driveContract(command, description, useWhen,
|
||||
[]string{"只查询状态使用 drive +publish-get;企业内部协作者权限使用 doc +access-grant"},
|
||||
[]string{fmt.Sprintf("dws drive %s --node <dentryUuid>", command)},
|
||||
driveObjectResult(description+"并读回验证的状态"), nil, contract.ParamDecl{Name: "node", Property: "fileId"}),
|
||||
Flags: flags,
|
||||
Tips: []string{fmt.Sprintf("dws drive %s --node <dentryUuid>", command)},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{"fileId": rt.Str("node"), "published": published}
|
||||
if published {
|
||||
params["publishPermission"] = rt.Str("permission")
|
||||
}
|
||||
written, err := rt.CallMCPWriteDataStrict("drive", "set_file_publish", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := requireDriveWrite(written, "drive/set_file_publish"); err != nil {
|
||||
return err
|
||||
}
|
||||
verified, err := rt.CallMCPData("drive", "get_file_publish_status", map[string]any{"fileId": rt.Str("node")})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
verified, err = requireDriveObject(verified, "drive/get_file_publish_status")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
actual, ok := boolField(verified, "published", "isPublished", "publish")
|
||||
if !ok || actual != published {
|
||||
return driveResponseError("drive/set_file_publish", "readback_mismatch", "公开状态写入后读回不一致")
|
||||
}
|
||||
return rt.Output(map[string]any{"success": true, "nodeId": rt.Str("node"), "publish": verified})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func boolField(data map[string]any, keys ...string) (bool, bool) {
|
||||
for _, key := range keys {
|
||||
if value, ok := data[key].(bool); ok {
|
||||
return value, true
|
||||
}
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
|
||||
var Upload = shortcut.Shortcut{
|
||||
Service: "drive", Command: "+upload", Product: "drive",
|
||||
Description: "从工作目录上传本地文件并读回验证",
|
||||
Intent: "把工作目录内普通文件上传到钉盘,并要求验证远端文件 ID、名称和大小时使用。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
|
||||
Contract: driveContract(
|
||||
"+upload", "从工作目录上传本地文件并读回验证",
|
||||
"把工作目录内普通文件上传到钉盘,并要求验证远端文件 ID、名称和大小时使用。",
|
||||
[]string{"在线文档导入转换使用 doc +import;作为正文附件使用 doc +media-insert;覆盖已有文件必须显式 --node"},
|
||||
[]string{`dws drive +upload --file report.pdf`, `dws drive +upload --file report.pdf --folder <dentryUuid>`},
|
||||
driveObjectResult("上传并读回验证后的远端文件"), nil,
|
||||
contract.ParamDecl{Name: "folder", Property: "parentId"},
|
||||
contract.ParamDecl{Name: "node", Property: "overwriteFileId"},
|
||||
),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "file", Type: shortcut.FlagString, Desc: "工作目录内的相对文件路径", Required: true},
|
||||
{Name: "file-name", Type: shortcut.FlagString, Desc: "远端显示名称,默认使用本地文件名"},
|
||||
{Name: "mime-type", Type: shortcut.FlagString, Desc: "MIME 类型"},
|
||||
{Name: "space-id", Type: shortcut.FlagString, Desc: "钉盘空间 ID"},
|
||||
{Name: "folder", Type: shortcut.FlagString, Desc: "父文件夹 ID"},
|
||||
{Name: "node", Type: shortcut.FlagString, Desc: "覆盖目标文件 ID"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"folder", "node"}}},
|
||||
Tips: []string{`dws drive +upload --file report.pdf`, `dws drive +upload --file report.pdf --folder <dentryUuid>`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
path, info, err := resolveDriveUploadInput(rt.Str("file"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := rt.Str("file-name")
|
||||
if name == "" {
|
||||
name = info.Name()
|
||||
}
|
||||
if rt.DryRun() {
|
||||
return rt.Output(map[string]any{"dry_run": true, "executed": false, "operation": "drive.upload", "file": rt.Str("file"), "fileName": name, "sizeBytes": info.Size()})
|
||||
}
|
||||
committed, err := uploadDriveFile(rt.Command().Context(), helpers.DriveUploadRequest{
|
||||
FilePath: path, FileName: name, FileSize: info.Size(), SpaceID: rt.Str("space-id"), ParentID: rt.Str("folder"), OverwriteFile: rt.Str("node"), MIMEType: rt.Str("mime-type"),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
committed, err = requireDriveWrite(committed, "drive/commit_upload")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nodeID := rt.Str("node")
|
||||
if nodeID == "" {
|
||||
nodeID = nestedString(committed, "fileId", "dentryUuid", "nodeId", "id")
|
||||
}
|
||||
if nodeID == "" {
|
||||
return driveResponseError("drive/commit_upload", "missing_created_id", "上传提交没有返回文件 ID;远端效果未知")
|
||||
}
|
||||
verified, err := rt.CallMCPData("drive", "get_file_info", map[string]any{"fileId": nodeID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
verified, err = requireDriveObject(verified, "drive/get_file_info")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if remoteName := firstString(verified, "name", "fileName"); remoteName == "" || !strings.HasPrefix(remoteName, strings.TrimSuffix(name, filepath.Ext(name))) {
|
||||
return driveResponseError("drive/commit_upload", "readback_mismatch", fmt.Sprintf("上传后读回名称 %q 与请求 %q 不一致", remoteName, name))
|
||||
}
|
||||
return rt.Output(map[string]any{"success": true, "nodeId": nodeID, "sizeBytes": info.Size(), "file": verified})
|
||||
},
|
||||
}
|
||||
|
||||
func resolveDriveUploadInput(raw string) (string, os.FileInfo, error) {
|
||||
if strings.TrimSpace(raw) == "" || filepath.IsAbs(raw) {
|
||||
return "", nil, fmt.Errorf("--file 只接受工作目录内的相对文件路径")
|
||||
}
|
||||
cwd, err := driveGetwd()
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
base, err := driveEvalSymlinks(cwd)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
path, err := driveEvalSymlinks(filepath.Join(base, filepath.Clean(raw)))
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("读取上传文件失败: %w", err)
|
||||
}
|
||||
rel, err := driveRel(base, path)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(filepath.ToSlash(rel), "../") {
|
||||
return "", nil, fmt.Errorf("--file 不能逃逸工作目录")
|
||||
}
|
||||
info, err := driveStat(path)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if info.IsDir() || info.Size() <= 0 {
|
||||
return "", nil, fmt.Errorf("--file 必须是非空普通文件")
|
||||
}
|
||||
return path, info, nil
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"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"
|
||||
)
|
||||
|
||||
const driveCompositeInterfaceReason = "Reviewed Drive Shortcut composite: the executable CLI owns strict response validation, optional multi-step orchestration, local I/O, read-back verification, output projection, and confirmation; no single MCP interface represents the complete command contract."
|
||||
|
||||
func driveContract(command, description, useWhen string, avoidWhen, examples []string, result *contract.ResultSpec, pagination *contract.PaginationSpec, params ...contract.ParamDecl) corecmd.ContractDecl {
|
||||
name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_")
|
||||
cliPath := "drive " + command
|
||||
return corecmd.ContractDecl{
|
||||
Description: description,
|
||||
Parameters: params,
|
||||
Result: result,
|
||||
Pagination: pagination,
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: contract.InterfaceModeComposite,
|
||||
Availability: contract.InterfaceAvailable,
|
||||
Reason: driveCompositeInterfaceReason,
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: description,
|
||||
UseWhen: []string{useWhen},
|
||||
AvoidWhen: avoidWhen,
|
||||
Examples: examples,
|
||||
},
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "drive",
|
||||
Name: name,
|
||||
CanonicalPath: "drive." + name,
|
||||
CLIPath: cliPath,
|
||||
PrimaryCLIPath: cliPath,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func driveObjectResult(description string) *contract.ResultSpec {
|
||||
return &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess},
|
||||
DataSchema: json.RawMessage(fmt.Sprintf(
|
||||
`{"type":"object","description":%q,"additionalProperties":true}`,
|
||||
description,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
func driveCollectionResult(collection, description string) *contract.ResultSpec {
|
||||
return &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess},
|
||||
DataSchema: json.RawMessage(fmt.Sprintf(
|
||||
`{"type":"object","description":%q,"properties":{"count":{"type":"integer","description":"本页有效结果数量"},%q:{"type":"array","description":%q,"items":{"type":"object","description":"Drive 资源条目","additionalProperties":true}},"nextCursor":{"type":"string","description":"下一页游标"},"hasMore":{"type":"boolean","description":"服务端是否仍有下一页"}},"required":["count",%q],"additionalProperties":true}`,
|
||||
description, collection, description, collection,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
func driveCursorPagination() *contract.PaginationSpec {
|
||||
return &contract.PaginationSpec{
|
||||
Kind: contract.PaginationKindCursor,
|
||||
CursorParameter: "cursor",
|
||||
MetaPath: contract.PaginationMetaPath,
|
||||
EndpointExhaustedPath: contract.PaginationExhaustedPath,
|
||||
NextTokenPath: contract.PaginationNextTokenPath,
|
||||
}
|
||||
}
|
||||
|
||||
func requireDriveResponse(data map[string]any, operation string) (map[string]any, error) {
|
||||
if len(data) == 0 {
|
||||
return nil, driveResponseError(operation, "empty_tool_response", "服务返回空响应,无法证明操作成功")
|
||||
}
|
||||
if success, present := data["success"]; present {
|
||||
value, ok := success.(bool)
|
||||
if !ok {
|
||||
return nil, driveResponseError(operation, "malformed_success", "响应 success 字段不是布尔值")
|
||||
}
|
||||
if !value {
|
||||
message := firstString(data, "errorMsg", "message", "error")
|
||||
if message == "" {
|
||||
message = "服务明确返回 success=false"
|
||||
}
|
||||
return nil, driveResponseError(operation, "remote_failure", message)
|
||||
}
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func requireDriveObject(data map[string]any, operation string) (map[string]any, error) {
|
||||
data, err := requireDriveResponse(data, operation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if value, present := data["result"]; present {
|
||||
result, ok := value.(map[string]any)
|
||||
if !ok || len(result) == 0 {
|
||||
return nil, driveResponseError(operation, "malformed_result", "响应 result 缺失有效对象")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
if value, present := data["data"]; present {
|
||||
result, ok := value.(map[string]any)
|
||||
if !ok || len(result) == 0 {
|
||||
return nil, driveResponseError(operation, "malformed_data", "响应 data 缺失有效对象")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
if len(data) == 1 {
|
||||
return nil, driveResponseError(operation, "missing_business_result", "响应没有可验证的业务对象")
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func requireDriveWrite(data map[string]any, operation string) (map[string]any, error) {
|
||||
data, err := requireDriveResponse(data, operation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
success, ok := data["success"].(bool)
|
||||
if !ok || !success {
|
||||
return nil, driveResponseError(operation, "missing_terminal_success", "写操作响应没有 success=true 终态证据")
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func requireDriveCollection(data map[string]any, operation string, keys ...string) ([]any, map[string]any, error) {
|
||||
data, err := requireDriveResponse(data, operation)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
containers := []map[string]any{data}
|
||||
for _, wrapper := range []string{"result", "data"} {
|
||||
if value, present := data[wrapper]; present {
|
||||
inner, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return nil, nil, driveResponseError(operation, "malformed_envelope", fmt.Sprintf("响应 %s 字段不是对象", wrapper))
|
||||
}
|
||||
containers = append(containers, inner)
|
||||
}
|
||||
}
|
||||
for _, container := range containers {
|
||||
for _, key := range keys {
|
||||
value, present := container[key]
|
||||
if !present {
|
||||
continue
|
||||
}
|
||||
items, ok := value.([]any)
|
||||
if !ok {
|
||||
return nil, nil, driveResponseError(operation, "malformed_collection", fmt.Sprintf("响应 %s 字段不是数组", key))
|
||||
}
|
||||
for index, item := range items {
|
||||
if _, ok := item.(map[string]any); !ok {
|
||||
return nil, nil, driveResponseError(operation, "malformed_collection_item", fmt.Sprintf("响应 %s[%d] 不是对象", key, index))
|
||||
}
|
||||
}
|
||||
return items, container, nil
|
||||
}
|
||||
}
|
||||
return nil, nil, driveResponseError(operation, "missing_collection", "响应缺少声明的业务数组;不能把缺字段投影成空结果")
|
||||
}
|
||||
|
||||
func projectDriveRows(items []any, aliases map[string][]string) []map[string]any {
|
||||
rows := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
source := item.(map[string]any)
|
||||
row := make(map[string]any)
|
||||
for canonical, candidates := range aliases {
|
||||
for _, candidate := range candidates {
|
||||
if value, ok := source[candidate]; ok && value != nil {
|
||||
row[canonical] = value
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func addDrivePagination(out map[string]any, container map[string]any) {
|
||||
for _, pair := range [][2]string{{"nextCursor", "nextCursor"}, {"nextToken", "nextCursor"}, {"nextPageToken", "nextCursor"}, {"hasMore", "hasMore"}} {
|
||||
if value, ok := container[pair[0]]; ok && value != nil {
|
||||
out[pair[1]] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func firstString(data map[string]any, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value, ok := data[key].(string); ok && strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func nestedString(data map[string]any, keys ...string) string {
|
||||
if value := firstString(data, keys...); value != "" {
|
||||
return value
|
||||
}
|
||||
for _, wrapper := range []string{"result", "data"} {
|
||||
if inner, ok := data[wrapper].(map[string]any); ok {
|
||||
if value := firstString(inner, keys...); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func driveResponseError(operation, reason, message string) error {
|
||||
return apperrors.NewAPI(message,
|
||||
apperrors.WithOperation(operation),
|
||||
apperrors.WithOrigin("mcp"),
|
||||
apperrors.WithFailureStage("response_validation"),
|
||||
apperrors.WithRetryable(false),
|
||||
apperrors.WithReason(reason),
|
||||
)
|
||||
}
|
||||
+235
-239
@@ -20,11 +20,17 @@
|
||||
package drive
|
||||
|
||||
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/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
var driveDownload = localio.Download
|
||||
|
||||
// ── 钉盘文件(drive MCP server)────────────────────────────────
|
||||
|
||||
// List → list_files
|
||||
@@ -32,9 +38,20 @@ var List = shortcut.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+list",
|
||||
Product: "drive",
|
||||
Description: "列出钉盘文件/文件夹",
|
||||
Intent: "当你想浏览钉盘某个空间或文件夹下有哪些文件和子文件夹、需要拿到文件的 dentryUuid 以便后续下载/移动/删除时使用;可指定 space-id、folder 逐层进入,支持分页和按创建/修改时间、名称排序,返回文件列表(含 ID、名称、类型等)。",
|
||||
Description: "严格分页列出钉盘文件和文件夹",
|
||||
Intent: "浏览钉盘根目录或已知文件夹时使用;服务端明确空数组才表示空目录,缺字段、坏元素或空响应都会失败。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
|
||||
Contract: driveContract(
|
||||
"+list", "严格分页列出钉盘文件和文件夹",
|
||||
"浏览钉盘根目录或已知文件夹时使用;服务端明确空数组才表示空目录,缺字段、坏元素或空响应都会失败。",
|
||||
[]string{"按关键词定位文件改用 drive +search;查看单个节点详情改用 drive +inspect"},
|
||||
[]string{`dws drive +list --limit 20`, `dws drive +list --folder <dentryUuid> --limit 20`},
|
||||
driveCollectionResult("files", "严格校验并投影的钉盘目录页"), driveCursorPagination(),
|
||||
contract.ParamDecl{Name: "space-id", Property: "spaceId"},
|
||||
contract.ParamDecl{Name: "folder", Property: "parentId"},
|
||||
contract.ParamDecl{Name: "cursor", Property: "nextToken"},
|
||||
),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "space-id", Type: shortcut.FlagString, Desc: "钉盘空间 ID (纯数字),不传则使用「我的文件」"},
|
||||
{Name: "folder", Type: shortcut.FlagString, Desc: "父节点 ID (dentryUuid),不传则列出空间根目录"},
|
||||
@@ -72,68 +89,23 @@ var List = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
files := listFilesProject(data)
|
||||
return rt.Output(map[string]any{"count": len(files), "files": files})
|
||||
items, page, err := requireDriveCollection(data, "drive/list_files", "items", "files", "dentries", "entries", "nodes", "list")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
files := projectDriveRows(items, map[string][]string{
|
||||
"name": {"name", "fileName", "dentryName", "title"},
|
||||
"type": {"type", "dentryType", "fileType", "spaceType"},
|
||||
"nodeId": {"fileId", "dentryUuid", "nodeId", "id"},
|
||||
"dentryId": {"dentryId"},
|
||||
"fileSize": {"fileSize", "size", "byteSize", "length"},
|
||||
})
|
||||
out := map[string]any{"count": len(files), "files": files}
|
||||
addDrivePagination(out, page)
|
||||
return rt.Output(out)
|
||||
},
|
||||
}
|
||||
|
||||
// listFilesProject reshapes the raw list_files response into a clean,
|
||||
// stable 钉盘 file/folder list ({name,type,dentryId,fileSize}) — the
|
||||
// output-projection fidelity applied to every list shortcut. Both the list
|
||||
// container and each field are probed defensively across candidate keys, so a
|
||||
// missing container or unknown alias simply yields an empty list rather than a
|
||||
// fabricated value.
|
||||
func listFilesProject(data map[string]any) []map[string]any {
|
||||
raw := listFilesContainer(data)
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
row := map[string]any{}
|
||||
listFilesPick(row, m, "name", "name", "fileName", "dentryName", "title")
|
||||
listFilesPick(row, m, "type", "type", "dentryType", "fileType", "spaceType")
|
||||
listFilesPick(row, m, "dentryId", "dentryId", "dentryUuid", "id", "fileId", "nodeId")
|
||||
listFilesPick(row, m, "fileSize", "fileSize", "size", "byteSize", "length")
|
||||
if len(row) > 0 {
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// listFilesContainer locates the file list array inside the response by trying
|
||||
// the common container keys emitted across drive backends; the payload itself
|
||||
// may also already be the array.
|
||||
func listFilesContainer(data map[string]any) []any {
|
||||
for _, k := range []string{"result", "data", "list", "items", "files", "dentries", "entries", "nodes"} {
|
||||
if v, ok := data[k].([]any); ok {
|
||||
return v
|
||||
}
|
||||
// The container may be nested one level (e.g. {"data":{"list":[...]}}).
|
||||
if inner, ok := data[k].(map[string]any); ok {
|
||||
for _, ik := range []string{"list", "items", "files", "dentries", "entries", "nodes", "result"} {
|
||||
if v, ok := inner[ik].([]any); ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// listFilesPick copies the first matching alias from src into dst under the
|
||||
// canonical key, leaving dst untouched when no alias is present.
|
||||
func listFilesPick(dst, src map[string]any, canonical string, aliases ...string) {
|
||||
for _, a := range aliases {
|
||||
if v, ok := src[a]; ok {
|
||||
dst[canonical] = v
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Info → get_file_info
|
||||
var Info = shortcut.Shortcut{
|
||||
Service: "drive",
|
||||
@@ -177,7 +149,15 @@ var Info = shortcut.Shortcut{
|
||||
if rt.Changed("space-id") {
|
||||
params["spaceId"] = rt.Str("space-id")
|
||||
}
|
||||
return rt.CallMCP("get_file_info", params)
|
||||
data, err := rt.CallMCPData("drive", "get_file_info", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := requireDriveObject(data, "drive/get_file_info")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(result)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -186,20 +166,55 @@ var Download = shortcut.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+download",
|
||||
Product: "drive",
|
||||
Description: "获取钉盘文件下载链接",
|
||||
Intent: "当你需要把钉盘里某个文件下载到本地或转给他人时使用;输入文件的 dentryUuid,返回带签名的临时下载 URL 和请求头,用它去真正拉取文件内容(本命令本身只取链接、不落盘)。",
|
||||
Description: "安全下载钉盘文件到工作目录",
|
||||
Intent: "下载普通钉盘文件并要求验证本地字节产物时使用;不是只返回临时 URL。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
|
||||
Contract: driveContract(
|
||||
"+download", "安全下载钉盘文件到工作目录",
|
||||
"下载普通钉盘文件并要求验证本地字节产物时使用;不是只返回临时 URL。",
|
||||
[]string{"在线文档导出为 docx/pdf 使用 doc +export;只查元数据使用 drive +inspect"},
|
||||
[]string{`dws drive +download --node <dentryUuid> --output downloads/report.pdf`},
|
||||
driveObjectResult("已验证的本地下载产物"), nil,
|
||||
contract.ParamDecl{Name: "node", Property: "fileId"},
|
||||
),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "node", Type: shortcut.FlagString, Desc: "文件 ID (dentryUuid)", Required: true},
|
||||
{Name: "space-id", Type: shortcut.FlagString, Desc: "文件所属空间 ID"},
|
||||
{Name: "output", Shorthand: "o", Type: shortcut.FlagString, Desc: "工作目录内的相对输出路径", Required: true},
|
||||
},
|
||||
Tips: []string{`dws drive +download --node <dentryUuid>`},
|
||||
Tips: []string{`dws drive +download --node <dentryUuid> --output downloads/report.pdf`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{"fileId": rt.Str("node")}
|
||||
if rt.Changed("space-id") {
|
||||
params["spaceId"] = rt.Str("space-id")
|
||||
}
|
||||
return rt.CallMCP("download_file", params)
|
||||
data, err := rt.CallMCPData("drive", "download_file", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload, err := requireDriveObject(data, "drive/download_file")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
url, preferredName, headers, err := driveDownloadPayload(payload, "drive/download_file")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cwd, err := driveGetwd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
artifact, err := driveDownload(rt.Command().Context(), url, localio.DownloadOptions{
|
||||
BaseDir: cwd, Output: rt.Str("output"), PreferredName: preferredName, Headers: headers,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if artifact.SizeBytes <= 0 {
|
||||
return driveResponseError("drive/download_file", "empty_download_artifact", "下载完成但本地产物为 0 字节")
|
||||
}
|
||||
return rt.Output(map[string]any{"success": true, "nodeId": rt.Str("node"), "savedPath": artifact.RelativePath, "sizeBytes": artifact.SizeBytes})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -296,68 +311,24 @@ var Search = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
files := searchFilesProject(data)
|
||||
return rt.Output(map[string]any{"count": len(files), "files": files})
|
||||
items, page, err := requireDriveCollection(data, "drive/search_files", "items", "files", "dentries", "entries", "nodes", "list")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
files := projectDriveRows(items, map[string][]string{
|
||||
"name": {"name", "fileName", "dentryName", "title"},
|
||||
"type": {"type", "dentryType", "fileType", "spaceType"},
|
||||
"nodeId": {"fileId", "dentryUuid", "nodeId", "id"},
|
||||
"dentryId": {"dentryId"},
|
||||
"fileSize": {"fileSize", "size", "byteSize", "length"},
|
||||
"creatorId": {"creatorId", "creatorUserId", "creator", "creatorUid"},
|
||||
})
|
||||
out := map[string]any{"count": len(files), "files": files}
|
||||
addDrivePagination(out, page)
|
||||
return rt.Output(out)
|
||||
},
|
||||
}
|
||||
|
||||
// searchFilesProject reshapes the raw search_files response into a clean,
|
||||
// stable 钉盘 file list ({name,type,dentryId,fileSize,creatorId}) — the
|
||||
// output-projection fidelity applied to every list/search shortcut. Both the
|
||||
// list container and each field are probed defensively across candidate keys,
|
||||
// so a missing container or unknown alias yields an empty list rather than a
|
||||
// fabricated value.
|
||||
func searchFilesProject(data map[string]any) []map[string]any {
|
||||
raw := searchFilesContainer(data)
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
row := map[string]any{}
|
||||
searchFilesPick(row, m, "name", "name", "fileName", "dentryName", "title")
|
||||
searchFilesPick(row, m, "type", "type", "dentryType", "fileType", "spaceType")
|
||||
searchFilesPick(row, m, "dentryId", "dentryId", "dentryUuid", "id", "fileId", "nodeId")
|
||||
searchFilesPick(row, m, "fileSize", "fileSize", "size", "byteSize", "length")
|
||||
searchFilesPick(row, m, "creatorId", "creatorId", "creatorUserId", "creator", "creatorUid")
|
||||
if len(row) > 0 {
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// searchFilesContainer locates the file list array inside the response by
|
||||
// trying the common container keys emitted across drive backends; the payload
|
||||
// itself may also already wrap the array one level deeper.
|
||||
func searchFilesContainer(data map[string]any) []any {
|
||||
for _, k := range []string{"result", "data", "list", "items", "files", "dentries", "entries", "nodes"} {
|
||||
if v, ok := data[k].([]any); ok {
|
||||
return v
|
||||
}
|
||||
if inner, ok := data[k].(map[string]any); ok {
|
||||
for _, ik := range []string{"list", "items", "files", "dentries", "entries", "nodes", "result"} {
|
||||
if v, ok := inner[ik].([]any); ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// searchFilesPick copies the first matching alias from src into dst under the
|
||||
// canonical key, leaving dst untouched when no alias is present.
|
||||
func searchFilesPick(dst, src map[string]any, canonical string, aliases ...string) {
|
||||
for _, a := range aliases {
|
||||
if v, ok := src[a]; ok {
|
||||
dst[canonical] = v
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RecycleList → list_recycle_items
|
||||
// RecycleRestore → restore_recycle_item
|
||||
// PublishSet → set_file_publish (published=true)
|
||||
@@ -413,66 +384,22 @@ var SearchDocs = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
docs := searchDocsProject(data)
|
||||
return rt.Output(map[string]any{"count": len(docs), "docs": docs})
|
||||
items, page, err := requireDriveCollection(data, "doc/search_documents", "documents", "docs", "nodes", "items", "list")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
docs := projectDriveRows(items, map[string][]string{
|
||||
"name": {"name", "title", "docName", "nodeName", "fileName"},
|
||||
"nodeId": {"nodeId", "id", "docId", "dentryUuid", "fileId"},
|
||||
"type": {"type", "docType", "nodeType", "fileType"},
|
||||
"url": {"url", "docUrl", "link", "webUrl"},
|
||||
})
|
||||
out := map[string]any{"count": len(docs), "docs": docs}
|
||||
addDrivePagination(out, page)
|
||||
return rt.Output(out)
|
||||
},
|
||||
}
|
||||
|
||||
// searchDocsProject reshapes the raw search_documents response into a clean,
|
||||
// stable document list ({name,nodeId,type,url}) — the output-projection
|
||||
// fidelity applied to every list/search shortcut. Both the list container and
|
||||
// each field are probed defensively across candidate keys, so a missing
|
||||
// container or unknown alias yields an empty list rather than fabricated data.
|
||||
func searchDocsProject(data map[string]any) []map[string]any {
|
||||
raw := searchDocsContainer(data)
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
row := map[string]any{}
|
||||
searchDocsPick(row, m, "name", "name", "title", "docName", "nodeName", "fileName")
|
||||
searchDocsPick(row, m, "nodeId", "nodeId", "id", "docId", "dentryUuid", "fileId")
|
||||
searchDocsPick(row, m, "type", "type", "docType", "nodeType", "fileType")
|
||||
searchDocsPick(row, m, "url", "url", "docUrl", "link", "webUrl")
|
||||
if len(row) > 0 {
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// searchDocsContainer locates the document list array inside the response by
|
||||
// trying the common container keys; the payload may also wrap the array one
|
||||
// level deeper under a common envelope.
|
||||
func searchDocsContainer(data map[string]any) []any {
|
||||
for _, k := range []string{"result", "data", "list", "items", "documents", "docs", "nodes"} {
|
||||
if v, ok := data[k].([]any); ok {
|
||||
return v
|
||||
}
|
||||
if inner, ok := data[k].(map[string]any); ok {
|
||||
for _, ik := range []string{"list", "items", "documents", "docs", "nodes", "result"} {
|
||||
if v, ok := inner[ik].([]any); ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// searchDocsPick copies the first matching alias from src into dst under the
|
||||
// canonical key, leaving dst untouched when no alias is present.
|
||||
func searchDocsPick(dst, src map[string]any, canonical string, aliases ...string) {
|
||||
for _, a := range aliases {
|
||||
if v, ok := src[a]; ok {
|
||||
dst[canonical] = v
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete → delete_document (doc)
|
||||
// Copy → copy_document (doc)
|
||||
var Copy = shortcut.Shortcut{
|
||||
@@ -514,6 +441,17 @@ var Copy = shortcut.Shortcut{
|
||||
},
|
||||
Tips: []string{`dws drive +copy --node <nodeId> --folder <targetFolderId>`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
preflight, err := rt.CallMCPData("doc", "get_document_info", map[string]any{"nodeId": rt.Str("node")})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
preflight, err = requireDriveObject(preflight, "doc/get_document_info")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !isOnlineDriveObject(preflight) {
|
||||
return driveResponseError("doc/copy_document", "ordinary_file_copy_unsupported", "普通钉盘文件当前没有独立副本接口;doc/copy_document 会生成 .dlink 快捷方式。需要快捷入口请用 +create-shortcut;需要独立副本请先 +download 再 +upload")
|
||||
}
|
||||
params := map[string]any{"nodeId": rt.Str("node")}
|
||||
if rt.Changed("folder") {
|
||||
params["targetFolderId"] = rt.Str("folder")
|
||||
@@ -521,7 +459,27 @@ var Copy = shortcut.Shortcut{
|
||||
if rt.Changed("workspace") {
|
||||
params["workspaceId"] = rt.Str("workspace")
|
||||
}
|
||||
return rt.CallMCP("copy_document", params)
|
||||
written, err := rt.CallMCPWriteDataStrict("doc", "copy_document", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
written, err = requireDriveWrite(written, "doc/copy_document")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
createdID := nestedString(written, "nodeId", "fileId", "dentryUuid", "id")
|
||||
if createdID == "" {
|
||||
return driveResponseError("doc/copy_document", "missing_created_id", "复制响应没有新节点 ID;远端效果未知")
|
||||
}
|
||||
verified, err := rt.CallMCPData("doc", "get_document_info", map[string]any{"nodeId": createdID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
verified, err = requireDriveObject(verified, "doc/get_document_info")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"success": true, "sourceNodeId": rt.Str("node"), "nodeId": createdID, "copy": verified})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -572,7 +530,23 @@ var Move = shortcut.Shortcut{
|
||||
if rt.Changed("workspace") {
|
||||
params["workspaceId"] = rt.Str("workspace")
|
||||
}
|
||||
return rt.CallMCP("move_document", params)
|
||||
written, err := rt.CallMCPWriteDataStrict("doc", "move_document", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
written, err = requireDriveWrite(written, "doc/move_document")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
verified, err := rt.CallMCPData("doc", "get_document_info", map[string]any{"nodeId": rt.Str("node")})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
verified, err = requireDriveObject(verified, "doc/get_document_info")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"success": true, "nodeId": rt.Str("node"), "file": verified})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -647,65 +621,57 @@ var Recent = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(recentListProject(data))
|
||||
items, page, err := requireDriveCollection(data, "doc/get_recent_list", "recentItems")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows := projectDriveRows(items, map[string][]string{
|
||||
"name": {"name"},
|
||||
"nodeType": {"nodeType"},
|
||||
"contentType": {"contentType"},
|
||||
"accessTime": {"accessTime"},
|
||||
"docUrl": {"docUrl"},
|
||||
"nodeId": {"nodeId"},
|
||||
})
|
||||
out := map[string]any{"count": len(rows), "items": rows}
|
||||
addDrivePagination(out, page)
|
||||
return rt.Output(out)
|
||||
},
|
||||
}
|
||||
|
||||
// recentListProject reshapes a get_recent_list response into a clean paginated
|
||||
// document list, dropping transport noise (logId) while keeping the pagination
|
||||
// cursor.
|
||||
func recentListProject(data map[string]any) map[string]any {
|
||||
// get_recent_list nests its payload under result.recentItems; earlier this
|
||||
// read data["recentItems"] at the top level only, so the whole list silently
|
||||
// projected to empty. Resolve the payload container (top-level or one level
|
||||
// under result/data) before reading recentItems / pagination fields.
|
||||
payload := data
|
||||
if inner, ok := recentListPayload(data); ok {
|
||||
payload = inner
|
||||
}
|
||||
items := []map[string]any{}
|
||||
raw, _ := payload["recentItems"].([]any)
|
||||
for _, it := range raw {
|
||||
m, ok := it.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
items = append(items, map[string]any{
|
||||
"name": m["name"],
|
||||
"nodeType": m["nodeType"],
|
||||
"contentType": m["contentType"],
|
||||
"accessTime": m["accessTime"],
|
||||
"docUrl": m["docUrl"],
|
||||
"nodeId": m["nodeId"],
|
||||
})
|
||||
}
|
||||
out := map[string]any{"count": len(items), "items": items}
|
||||
if nc, ok := payload["nextCursor"]; ok && nc != nil {
|
||||
out["nextCursor"] = nc
|
||||
}
|
||||
if hm, ok := payload["hasMore"]; ok {
|
||||
out["hasMore"] = hm
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// recentListPayload returns the map that actually holds recentItems, tolerating
|
||||
// a {result|data:{recentItems:[...]}} envelope as well as a bare top-level shape.
|
||||
func recentListPayload(data map[string]any) (map[string]any, bool) {
|
||||
if _, ok := data["recentItems"]; ok {
|
||||
return data, true
|
||||
}
|
||||
for _, k := range []string{"result", "data"} {
|
||||
if inner, ok := data[k].(map[string]any); ok {
|
||||
if _, ok := inner["recentItems"]; ok {
|
||||
return inner, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func init() {
|
||||
Copy.Description = "复制在线文档到指定位置并读回验证"
|
||||
Copy.Intent = "复制钉钉在线文档到新位置并保留原件时使用;先验证对象类型,普通文件不伪装成复制成功。"
|
||||
Copy.Contract = driveContract(
|
||||
"+copy", Copy.Description,
|
||||
"复制钉钉在线文档到新位置并保留原件时使用;先验证对象类型,普通文件不伪装成复制成功。",
|
||||
[]string{"普通钉盘文件独立复制当前无等价接口:需要快捷入口用 +create-shortcut,需要独立字节副本用 +download 后 +upload;移动原件用 +move"},
|
||||
[]string{`dws drive +copy --node <ONLINE_DOC_ID> --folder <TARGET_FOLDER_ID>`},
|
||||
driveObjectResult("在线文档复制并读回后的新节点"), nil,
|
||||
// Preserve the historical public Schema properties. Execute translates
|
||||
// these CLI concepts to nodeId/targetFolderId/workspaceId for the RPC.
|
||||
contract.ParamDecl{Name: "node", Property: "node"},
|
||||
contract.ParamDecl{Name: "folder", Property: "folder"},
|
||||
contract.ParamDecl{Name: "workspace", Property: "workspace"},
|
||||
)
|
||||
Copy.Tips = []string{`dws drive +copy --node <ONLINE_DOC_ID> --folder <TARGET_FOLDER_ID>`}
|
||||
Info.Contract.Result = driveObjectResult("钉盘节点元数据")
|
||||
Search.Contract.Result = driveCollectionResult("files", "严格校验并投影的钉盘搜索结果页")
|
||||
Search.Contract.Pagination = driveCursorPagination()
|
||||
SearchDocs.Contract.Result = driveCollectionResult("docs", "兼容入口的在线文档搜索结果页")
|
||||
Copy.Contract.Result = driveObjectResult("复制操作的终态证据")
|
||||
Move.Contract.Result = driveObjectResult("移动操作的终态证据")
|
||||
Recent.Contract.Result = driveCollectionResult("items", "严格校验的最近访问或编辑结果页")
|
||||
Recent.Contract.Pagination = driveCursorPagination()
|
||||
for _, declaration := range []*shortcut.Shortcut{
|
||||
&List, &Info, &Download, &Search, &SearchDocs, &Copy, &Move, &Recent,
|
||||
&Inspect, &Upload, &CreateFolder, &CreateShortcut, &Rename, &Delete, &Stats,
|
||||
&RecycleList, &RecycleRestore, &StarList, &StarAdd, &StarRemove,
|
||||
&PublishGet, &PublishSet, &PublishUnset, &Cover,
|
||||
&VersionHistory, &VersionGet, &VersionDownload, &VersionRevert,
|
||||
} {
|
||||
declaration.OutputRollout = output.RolloutUnifiedActive
|
||||
}
|
||||
shortcut.Register(
|
||||
List,
|
||||
Info,
|
||||
@@ -715,5 +681,35 @@ func init() {
|
||||
Copy,
|
||||
Move,
|
||||
Recent,
|
||||
Inspect,
|
||||
Upload,
|
||||
CreateFolder,
|
||||
CreateShortcut,
|
||||
Rename,
|
||||
Delete,
|
||||
Stats,
|
||||
RecycleList,
|
||||
RecycleRestore,
|
||||
StarList,
|
||||
StarAdd,
|
||||
StarRemove,
|
||||
PublishGet,
|
||||
PublishSet,
|
||||
PublishUnset,
|
||||
Cover,
|
||||
VersionHistory,
|
||||
VersionGet,
|
||||
VersionDownload,
|
||||
VersionRevert,
|
||||
)
|
||||
}
|
||||
|
||||
func isOnlineDriveObject(info map[string]any) bool {
|
||||
extension := strings.ToLower(firstString(info, "extension", "fileExtension", "ext"))
|
||||
switch extension {
|
||||
case "adoc", "axls", "able", "amind", "adraw":
|
||||
return true
|
||||
}
|
||||
contentType := strings.ToUpper(firstString(info, "contentType", "docType"))
|
||||
return contentType == "DOC" || contentType == "SHEET" || contentType == "TABLE" || contentType == "MIND" || contentType == "DRAW"
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,335 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"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 Inspect = shortcut.Shortcut{
|
||||
Service: "drive", Command: "+inspect", Product: "drive",
|
||||
Description: "聚合检查节点元数据及可选统计、公开状态和封面",
|
||||
Intent: "已知节点 ID,需要一次确认名称、类型、路径,并可附带统计、公开状态或封面时使用。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
|
||||
Contract: driveContract(
|
||||
"+inspect", "聚合检查节点元数据及可选统计、公开状态和封面",
|
||||
"已知节点 ID,需要一次确认名称、类型、路径,并可附带统计、公开状态或封面时使用。",
|
||||
[]string{"读取在线文档正文使用 doc +fetch;浏览目录使用 drive +list"},
|
||||
[]string{`dws drive +inspect --node <dentryUuid>`, `dws drive +inspect --node <dentryUuid> --include-stats --include-publish`},
|
||||
driveObjectResult("Drive 节点聚合检查结果"), nil,
|
||||
contract.ParamDecl{Name: "node", Property: "fileId"},
|
||||
),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "node", Type: shortcut.FlagString, Desc: "节点 ID", Required: true},
|
||||
{Name: "space-id", Type: shortcut.FlagString, Desc: "钉盘空间 ID"},
|
||||
{Name: "include-stats", Type: shortcut.FlagBool, Desc: "附带阅读、编辑、下载等统计"},
|
||||
{Name: "include-publish", Type: shortcut.FlagBool, Desc: "附带互联网公开状态"},
|
||||
{Name: "include-cover", Type: shortcut.FlagBool, Desc: "附带封面或缩略图地址"},
|
||||
},
|
||||
Tips: []string{`dws drive +inspect --node <dentryUuid>`, `dws drive +inspect --node <dentryUuid> --include-stats --include-publish`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
node := rt.Str("node")
|
||||
params := map[string]any{"fileId": node}
|
||||
if rt.Str("space-id") != "" {
|
||||
params["spaceId"] = rt.Str("space-id")
|
||||
}
|
||||
data, err := rt.CallMCPData("drive", "get_file_info", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := requireDriveObject(data, "drive/get_file_info")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result := map[string]any{"file": info}
|
||||
steps := []map[string]any{{"tool": "get_file_info", "status": "success"}}
|
||||
reads := []struct {
|
||||
flag, key, tool string
|
||||
params map[string]any
|
||||
}{
|
||||
{"include-stats", "stats", "get_node_stats", map[string]any{"nodeId": node}},
|
||||
{"include-publish", "publish", "get_file_publish_status", map[string]any{"fileId": node}},
|
||||
{"include-cover", "cover", "get_cover", map[string]any{"nodeId": node}},
|
||||
}
|
||||
failures := []map[string]any{}
|
||||
for _, read := range reads {
|
||||
if !rt.Bool(read.flag) {
|
||||
continue
|
||||
}
|
||||
value, callErr := rt.CallMCPReadData("drive", read.tool, read.params)
|
||||
if callErr == nil {
|
||||
value, callErr = requireDriveObject(value, "drive/"+read.tool)
|
||||
}
|
||||
if callErr != nil {
|
||||
steps = append(steps, map[string]any{"tool": read.tool, "status": "failed"})
|
||||
failures = append(failures, map[string]any{"tool": read.tool, "error": callErr.Error()})
|
||||
continue
|
||||
}
|
||||
result[read.key] = value
|
||||
steps = append(steps, map[string]any{"tool": read.tool, "status": "success"})
|
||||
}
|
||||
if len(failures) > 0 {
|
||||
return apperrors.NewAPI("Drive 聚合检查只完成了部分读取",
|
||||
apperrors.WithOperation("drive.inspect"),
|
||||
apperrors.WithReason("drive_inspect_partial"),
|
||||
apperrors.WithFailureStage("optional_reads"),
|
||||
apperrors.WithExecutionStarted(false),
|
||||
apperrors.WithRetryable(true),
|
||||
apperrors.WithDetails(map[string]any{"status": "partial_success", "complete": false, "data": result, "steps": steps, "failures": failures}),
|
||||
)
|
||||
}
|
||||
return rt.Output(map[string]any{"status": "success", "complete": true, "data": result, "steps": steps})
|
||||
},
|
||||
}
|
||||
|
||||
var CreateFolder = shortcut.Shortcut{
|
||||
Service: "drive", Command: "+create-folder", Product: "drive",
|
||||
Description: "创建钉盘文件夹并读回验证",
|
||||
Intent: "在钉盘中创建普通文件夹并需要拿到经过读回验证的 fileId 时使用。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown"},
|
||||
Contract: driveContract(
|
||||
"+create-folder", "创建钉盘文件夹并读回验证",
|
||||
"在钉盘中创建普通文件夹并需要拿到经过读回验证的 fileId 时使用。",
|
||||
[]string{"知识库目录使用 wiki node create;创建在线文档使用 doc +create"},
|
||||
[]string{`dws drive +create-folder --name "项目资料"`, `dws drive +create-folder --name "子目录" --folder <dentryUuid>`},
|
||||
driveObjectResult("创建并验证后的文件夹"), nil,
|
||||
contract.ParamDecl{Name: "folder", Property: "parentId"},
|
||||
),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "name", Type: shortcut.FlagString, Desc: "文件夹名称", Required: true},
|
||||
{Name: "space-id", Type: shortcut.FlagString, Desc: "钉盘空间 ID"},
|
||||
{Name: "folder", Type: shortcut.FlagString, Desc: "父文件夹 ID"},
|
||||
},
|
||||
Tips: []string{`dws drive +create-folder --name "项目资料"`, `dws drive +create-folder --name "子目录" --folder <dentryUuid>`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{"name": rt.Str("name")}
|
||||
if rt.Str("space-id") != "" {
|
||||
params["spaceId"] = rt.Str("space-id")
|
||||
}
|
||||
if rt.Str("folder") != "" {
|
||||
params["parentId"] = rt.Str("folder")
|
||||
}
|
||||
created, err := rt.CallMCPWriteDataStrict("drive", "create_folder", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
created, err = requireDriveWrite(created, "drive/create_folder")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nodeID := nestedString(created, "fileId", "dentryUuid", "nodeId", "id")
|
||||
if nodeID == "" {
|
||||
return driveResponseError("drive/create_folder", "missing_created_id", "创建响应没有文件夹 fileId;远端效果未知")
|
||||
}
|
||||
verified, err := rt.CallMCPData("drive", "get_file_info", map[string]any{"fileId": nodeID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
verified, err = requireDriveObject(verified, "drive/get_file_info")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if name := firstString(verified, "name", "fileName"); name != rt.Str("name") {
|
||||
return driveResponseError("drive/create_folder", "readback_mismatch", fmt.Sprintf("创建后读回名称 %q 与请求 %q 不一致", name, rt.Str("name")))
|
||||
}
|
||||
return rt.Output(map[string]any{"success": true, "nodeId": nodeID, "folder": verified})
|
||||
},
|
||||
}
|
||||
|
||||
var CreateShortcut = shortcut.Shortcut{
|
||||
Service: "drive", Command: "+create-shortcut", Product: "drive",
|
||||
Description: "为已有节点创建快捷方式并验证新节点",
|
||||
Intent: "给已有文件或文档创建快捷入口,并需要区分 shortcut 与独立副本时使用。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "non_idempotent"},
|
||||
Contract: driveContract(
|
||||
"+create-shortcut", "为已有节点创建快捷方式并验证新节点",
|
||||
"给已有文件或文档创建快捷入口,并需要区分 shortcut 与独立副本时使用。",
|
||||
[]string{"需要独立副本使用 drive +copy;需要迁移原节点使用 drive +move"},
|
||||
[]string{`dws drive +create-shortcut --node <SOURCE_NODE> --folder <TARGET_FOLDER>`},
|
||||
driveObjectResult("创建并验证后的快捷方式节点"), nil,
|
||||
contract.ParamDecl{Name: "node", Property: "nodeId"},
|
||||
contract.ParamDecl{Name: "folder", Property: "targetFolderId"},
|
||||
contract.ParamDecl{Name: "workspace", Property: "workspaceId"},
|
||||
),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "node", Type: shortcut.FlagString, Desc: "源节点 ID", Required: true},
|
||||
{Name: "folder", Type: shortcut.FlagString, Desc: "目标文件夹 ID"},
|
||||
{Name: "workspace", Type: shortcut.FlagString, Desc: "目标知识库 ID"},
|
||||
},
|
||||
Tips: []string{`dws drive +create-shortcut --node <SOURCE_NODE> --folder <TARGET_FOLDER>`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{"nodeId": rt.Str("node")}
|
||||
if rt.Str("folder") != "" {
|
||||
params["targetFolderId"] = rt.Str("folder")
|
||||
}
|
||||
if rt.Str("workspace") != "" {
|
||||
params["workspaceId"] = rt.Str("workspace")
|
||||
}
|
||||
created, err := rt.CallMCPWriteDataStrict("drive", "create_shortcut", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
created, err = requireDriveWrite(created, "drive/create_shortcut")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nodeID := nestedString(created, "fileId", "dentryUuid", "nodeId", "id")
|
||||
if nodeID == "" {
|
||||
return driveResponseError("drive/create_shortcut", "missing_created_id", "创建快捷方式未返回新节点 ID")
|
||||
}
|
||||
verified, err := rt.CallMCPData("drive", "get_file_info", map[string]any{"fileId": nodeID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
verified, err = requireDriveObject(verified, "drive/get_file_info")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"success": true, "nodeId": nodeID, "shortcut": verified})
|
||||
},
|
||||
}
|
||||
|
||||
var Rename = shortcut.Shortcut{
|
||||
Service: "drive", Command: "+rename", Product: "doc",
|
||||
Description: "重命名文件或文件夹并读回验证",
|
||||
Intent: "改变已有节点名称并要求验证最终名称时使用。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
|
||||
Contract: driveContract(
|
||||
"+rename", "重命名文件或文件夹并读回验证",
|
||||
"改变已有节点名称并要求验证最终名称时使用。",
|
||||
[]string{"改变位置使用 drive +move;替换文件内容使用 drive +upload --node"},
|
||||
[]string{`dws drive +rename --node <dentryUuid> --name "新名称"`},
|
||||
driveObjectResult("重命名后的节点元数据"), nil,
|
||||
contract.ParamDecl{Name: "node", Property: "nodeId"},
|
||||
contract.ParamDecl{Name: "name", Property: "newName"},
|
||||
),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "node", Type: shortcut.FlagString, Desc: "节点 ID", Required: true},
|
||||
{Name: "name", Type: shortcut.FlagString, Desc: "新名称", Required: true},
|
||||
},
|
||||
Tips: []string{`dws drive +rename --node <dentryUuid> --name "新名称"`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
preflight, err := rt.CallMCPData("drive", "get_file_info", map[string]any{"fileId": rt.Str("node")})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
preflight, err = requireDriveObject(preflight, "drive/get_file_info")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
requestName, expectedNames := normalizedDriveRename(rt.Str("name"), preflight)
|
||||
written, err := rt.CallMCPWriteDataStrict("doc", "rename_document", map[string]any{"nodeId": rt.Str("node"), "newName": requestName})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := requireDriveWrite(written, "doc/rename_document"); err != nil {
|
||||
return err
|
||||
}
|
||||
verified, err := rt.CallMCPData("drive", "get_file_info", map[string]any{"fileId": rt.Str("node")})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
verified, err = requireDriveObject(verified, "drive/get_file_info")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := firstString(verified, "name", "fileName")
|
||||
if name == "" || !expectedNames[name] {
|
||||
return driveResponseError("doc/rename_document", "readback_mismatch", fmt.Sprintf("重命名读回名称 %q 与请求 %q 不一致", name, rt.Str("name")))
|
||||
}
|
||||
return rt.Output(map[string]any{"success": true, "nodeId": rt.Str("node"), "file": verified})
|
||||
},
|
||||
}
|
||||
|
||||
func normalizedDriveRename(name string, info map[string]any) (string, map[string]bool) {
|
||||
name = strings.TrimSpace(name)
|
||||
expected := map[string]bool{name: true}
|
||||
nodeType := strings.ToLower(firstString(info, "type", "nodeType", "fileType"))
|
||||
if nodeType == "folder" || nodeType == "dir" || nodeType == "directory" {
|
||||
return name, expected
|
||||
}
|
||||
extension := strings.TrimLeft(strings.ToLower(firstString(info, "extension", "fileExtension", "ext")), ".")
|
||||
if extension == "" {
|
||||
return name, expected
|
||||
}
|
||||
suffix := "." + extension
|
||||
if len(name) > len(suffix) && strings.EqualFold(name[len(name)-len(suffix):], suffix) {
|
||||
return name[:len(name)-len(suffix)], expected
|
||||
}
|
||||
expected[name+suffix] = true
|
||||
return name, expected
|
||||
}
|
||||
|
||||
var Delete = shortcut.Shortcut{
|
||||
Service: "drive", Command: "+delete", Product: "doc",
|
||||
Description: "将已确认节点移入回收站",
|
||||
Intent: "用户明确要求删除已确认钉盘节点,并理解它会进入回收站时使用。",
|
||||
Risk: shortcut.RiskHighWrite,
|
||||
Safety: contract.SafetySpec{Effect: "destructive", Risk: "high", Confirmation: "user_required", Idempotency: "unknown"},
|
||||
Contract: driveContract(
|
||||
"+delete", "将已确认节点移入回收站",
|
||||
"用户明确要求删除已确认钉盘节点,并理解它会进入回收站时使用。",
|
||||
[]string{"只是调整位置使用 drive +move;目标不明确时先 drive +inspect"},
|
||||
[]string{`dws drive +delete --node <dentryUuid>`},
|
||||
driveObjectResult("删除到回收站的终态证据"), nil,
|
||||
contract.ParamDecl{Name: "node", Property: "nodeId"},
|
||||
),
|
||||
Flags: []shortcut.Flag{{Name: "node", Type: shortcut.FlagString, Desc: "节点 ID", Required: true}},
|
||||
Tips: []string{`dws drive +delete --node <dentryUuid>`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
written, err := rt.CallMCPWriteDataStrict("doc", "delete_document", map[string]any{"nodeId": rt.Str("node")})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
written, err = requireDriveWrite(written, "doc/delete_document")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"success": true, "nodeId": rt.Str("node"), "result": written})
|
||||
},
|
||||
}
|
||||
|
||||
var Stats = driveReadObjectShortcut(
|
||||
"+stats", "读取节点访问和协作统计", "get_node_stats", "nodeId",
|
||||
"用户要查看指定节点阅读、编辑、评论、点赞、预览或下载统计时使用。",
|
||||
[]string{"只要文件名称和类型使用 drive +inspect"},
|
||||
)
|
||||
|
||||
var Cover = driveReadObjectShortcut(
|
||||
"+cover", "读取节点封面或缩略图地址", "get_cover", "nodeId",
|
||||
"用户需要节点封面、首图或缩略图 URL 时使用。",
|
||||
[]string{"这是封面资源,不等于 Lark 服务端多格式预览转换"},
|
||||
)
|
||||
|
||||
func driveReadObjectShortcut(command, description, tool, property, useWhen string, avoidWhen []string) shortcut.Shortcut {
|
||||
return shortcut.Shortcut{
|
||||
Service: "drive", Command: command, Product: "drive", Description: description, Intent: useWhen,
|
||||
Risk: shortcut.RiskRead, Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
|
||||
Contract: driveContract(command, description, useWhen, avoidWhen,
|
||||
[]string{fmt.Sprintf("dws drive %s --node <dentryUuid>", command)},
|
||||
driveObjectResult(description), nil, contract.ParamDecl{Name: "node", Property: property}),
|
||||
Flags: []shortcut.Flag{{Name: "node", Type: shortcut.FlagString, Desc: "节点 ID", Required: true}},
|
||||
Tips: []string{fmt.Sprintf("dws drive %s --node <dentryUuid>", command)},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
data, err := rt.CallMCPData("drive", tool, map[string]any{property: rt.Str("node")})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err = requireDriveObject(data, "drive/"+tool)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(data)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestRecentListProjectResultWrapper guards against projection-data-loss:
|
||||
// get_recent_list nests its payload under result.recentItems; the projection
|
||||
// must descend into result or +recent silently returns empty despite backend
|
||||
// records.
|
||||
func TestRecentListProjectResultWrapper(t *testing.T) {
|
||||
const raw = `{"result":{"hasMore":true,"nextCursor":"c2","recentItems":[
|
||||
{"name":"weekly report","nodeType":"doc","nodeId":"n1","docUrl":"https://x/1"},
|
||||
{"name":"budget sheet","nodeType":"sheet","nodeId":"n2","docUrl":"https://x/2"}
|
||||
]}}`
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||
t.Fatalf("unmarshal fixture: %v", err)
|
||||
}
|
||||
out := recentListProject(data)
|
||||
if got, _ := out["count"].(int); got != 2 {
|
||||
t.Fatalf("lower/upper mismatch: result.recentItems has 2 entries, projection count=%v (%v)", out["count"], out)
|
||||
}
|
||||
if out["hasMore"] != true || out["nextCursor"] != "c2" {
|
||||
t.Fatalf("pagination fields lost from result wrapper: %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecentListProjectNoItems covers the no-recentItems branch (payload not
|
||||
// found under any wrapper), which must yield an empty list, not a panic.
|
||||
func TestRecentListProjectNoItems(t *testing.T) {
|
||||
const raw = `{"result":{"totalCount":0},"success":true}`
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||
t.Fatalf("unmarshal fixture: %v", err)
|
||||
}
|
||||
if out := recentListProject(data); out["count"].(int) != 0 {
|
||||
t.Fatalf("no recentItems: want count 0, got %v", out["count"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecentListProjectTopLevel covers the already-unwrapped shape.
|
||||
func TestRecentListProjectTopLevel(t *testing.T) {
|
||||
const raw = `{"recentItems":[{"name":"weekly report","nodeId":"n1"}],"hasMore":false}`
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||
t.Fatalf("unmarshal fixture: %v", err)
|
||||
}
|
||||
if out := recentListProject(data); out["count"].(int) != 1 {
|
||||
t.Fatalf("top-level recentItems: want count 1, got %v", out["count"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
var VersionHistory = shortcut.Shortcut{
|
||||
Service: "drive", Command: "+version-history", Product: "drive",
|
||||
Description: "严格分页列出普通文件历史版本",
|
||||
Intent: "普通文件需要浏览历史版本并获取明确版本号时使用。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
|
||||
Contract: driveContract(
|
||||
"+version-history", "严格分页列出普通文件历史版本",
|
||||
"普通文件需要浏览历史版本并获取明确版本号时使用。",
|
||||
[]string{"在线文档版本使用 doc +version-list;在线表格版本使用 sheet version"},
|
||||
[]string{`dws drive +version-history --node <dentryUuid> --limit 20`},
|
||||
driveCollectionResult("versions", "严格校验的普通文件历史版本页"), driveCursorPagination(),
|
||||
contract.ParamDecl{Name: "node", Property: "nodeId"},
|
||||
contract.ParamDecl{Name: "cursor", Property: "nextCursor"},
|
||||
),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "node", Type: shortcut.FlagString, Desc: "普通文件节点 ID", Required: true},
|
||||
{Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量"},
|
||||
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标"},
|
||||
},
|
||||
Tips: []string{`dws drive +version-history --node <dentryUuid> --limit 20`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
page, err := versionPage(rt, rt.Str("node"), rt.Int("limit"), rt.Str("cursor"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(page)
|
||||
},
|
||||
}
|
||||
|
||||
var VersionGet = shortcut.Shortcut{
|
||||
Service: "drive", Command: "+version-get", Product: "drive",
|
||||
Description: "按版本号精确读取普通文件版本元数据",
|
||||
Intent: "已知普通文件版本号,需要确认该版本存在并读取其元数据时使用。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
|
||||
Contract: driveContract(
|
||||
"+version-get", "按版本号精确读取普通文件版本元数据",
|
||||
"已知普通文件版本号,需要确认该版本存在并读取其元数据时使用。",
|
||||
[]string{"尚未确定版本号时使用 drive +version-history;需要版本字节使用 drive +version-download"},
|
||||
[]string{`dws drive +version-get --node <dentryUuid> --version 3`},
|
||||
driveObjectResult("精确匹配的普通文件版本元数据"), nil,
|
||||
contract.ParamDecl{Name: "node", Property: "nodeId"},
|
||||
),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "node", Type: shortcut.FlagString, Desc: "普通文件节点 ID", Required: true},
|
||||
{Name: "version", Type: shortcut.FlagInt, Desc: "版本号;--version 必须为正整数", Required: true},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"version"}, Description: "--version 必须为正整数"}},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
if rt.Int("version") <= 0 {
|
||||
return fmt.Errorf("--version 必须为正整数")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Tips: []string{`dws drive +version-get --node <dentryUuid> --version 3`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
version, err := findVersion(rt, rt.Str("node"), rt.Int("version"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"nodeId": rt.Str("node"), "version": version})
|
||||
},
|
||||
}
|
||||
|
||||
var VersionDownload = shortcut.Shortcut{
|
||||
Service: "drive", Command: "+version-download", Product: "drive",
|
||||
Description: "安全下载普通文件指定历史版本",
|
||||
Intent: "已确认普通文件版本号,需要安全落盘并验证实际字节时使用。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
|
||||
Contract: driveContract(
|
||||
"+version-download", "安全下载普通文件指定历史版本",
|
||||
"已确认普通文件版本号,需要安全落盘并验证实际字节时使用。",
|
||||
[]string{"下载最新版本使用 drive +download;在线文档版本使用 doc +version-list/+export"},
|
||||
[]string{`dws drive +version-download --node <dentryUuid> --version 3 --output downloads/report-v3.pdf`},
|
||||
driveObjectResult("已验证的历史版本本地产物"), nil,
|
||||
contract.ParamDecl{Name: "node", Property: "nodeId"},
|
||||
),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "node", Type: shortcut.FlagString, Desc: "普通文件节点 ID", Required: true},
|
||||
{Name: "version", Type: shortcut.FlagInt, Desc: "版本号;--version 必须为正整数", Required: true},
|
||||
{Name: "output", Type: shortcut.FlagString, Desc: "工作目录内相对输出路径", Required: true},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"version"}, Description: "--version 必须为正整数"}},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
if rt.Int("version") <= 0 {
|
||||
return fmt.Errorf("--version 必须为正整数")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Tips: []string{`dws drive +version-download --node <dentryUuid> --version 3 --output downloads/report-v3.pdf`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
if _, err := findVersion(rt, rt.Str("node"), rt.Int("version")); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := rt.CallMCPData("drive", "download_file_version", map[string]any{"nodeId": rt.Str("node"), "version": rt.Int("version")})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload, err := requireDriveObject(data, "drive/download_file_version")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
url, name, headers, err := driveDownloadPayload(payload, "drive/download_file_version")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cwd, err := driveGetwd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
artifact, err := driveDownload(rt.Command().Context(), url, localio.DownloadOptions{BaseDir: cwd, Output: rt.Str("output"), PreferredName: name, Headers: headers})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if artifact.SizeBytes <= 0 {
|
||||
return driveResponseError("drive/download_file_version", "empty_download_artifact", "历史版本下载产物为 0 字节")
|
||||
}
|
||||
return rt.Output(map[string]any{"success": true, "nodeId": rt.Str("node"), "version": rt.Int("version"), "savedPath": artifact.RelativePath, "sizeBytes": artifact.SizeBytes})
|
||||
},
|
||||
}
|
||||
|
||||
var VersionRevert = shortcut.Shortcut{
|
||||
Service: "drive", Command: "+version-revert", Product: "drive",
|
||||
Description: "预检并回滚普通文件到指定历史版本",
|
||||
Intent: "用户明确选定普通文件历史版本并要求回滚时使用。",
|
||||
Risk: shortcut.RiskHighWrite,
|
||||
Safety: contract.SafetySpec{Effect: "write", Risk: "high", Confirmation: "user_required", Idempotency: "non_idempotent"},
|
||||
Contract: driveContract(
|
||||
"+version-revert", "预检并回滚普通文件到指定历史版本",
|
||||
"用户明确选定普通文件历史版本并要求回滚时使用。",
|
||||
[]string{"尚未确认版本号时先 drive +version-history;在线文档使用 doc +version-revert"},
|
||||
[]string{`dws drive +version-revert --node <dentryUuid> --version 3`},
|
||||
driveObjectResult("版本回滚终态及节点读回"), nil,
|
||||
contract.ParamDecl{Name: "node", Property: "nodeId"},
|
||||
),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "node", Type: shortcut.FlagString, Desc: "普通文件节点 ID", Required: true},
|
||||
{Name: "version", Type: shortcut.FlagInt, Desc: "版本号;--version 必须为正整数", Required: true},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"version"}, Description: "--version 必须为正整数"}},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
if rt.Int("version") <= 0 {
|
||||
return fmt.Errorf("--version 必须为正整数")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Tips: []string{`dws drive +version-revert --node <dentryUuid> --version 3`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
if _, err := findVersion(rt, rt.Str("node"), rt.Int("version")); err != nil {
|
||||
return err
|
||||
}
|
||||
written, err := rt.CallMCPWriteDataStrict("drive", "revert_file_version", map[string]any{"nodeId": rt.Str("node"), "version": rt.Int("version")})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := requireDriveWrite(written, "drive/revert_file_version"); err != nil {
|
||||
return err
|
||||
}
|
||||
verified, err := rt.CallMCPData("drive", "get_file_info", map[string]any{"fileId": rt.Str("node")})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
verified, err = requireDriveObject(verified, "drive/get_file_info")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"success": true, "nodeId": rt.Str("node"), "revertedTo": rt.Int("version"), "file": verified})
|
||||
},
|
||||
}
|
||||
|
||||
func versionPage(rt *shortcut.RuntimeContext, nodeID string, limit int, cursor string) (map[string]any, error) {
|
||||
params := map[string]any{"nodeId": nodeID, "maxResults": limit}
|
||||
if cursor != "" {
|
||||
params["nextCursor"] = cursor
|
||||
}
|
||||
data, err := rt.CallMCPData("drive", "list_file_versions", params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items, page, err := requireDriveCollection(data, "drive/list_file_versions", "versions", "versionList", "items")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
versions := projectDriveRows(items, map[string][]string{
|
||||
"version": {"version", "versionNumber", "versionNum"},
|
||||
"createTime": {"createTime", "createdTime"},
|
||||
"creatorId": {"creatorId", "creatorUserId", "operatorId"},
|
||||
"fileSize": {"fileSize", "size"},
|
||||
"name": {"name", "fileName"},
|
||||
})
|
||||
out := map[string]any{"count": len(versions), "versions": versions}
|
||||
addDrivePagination(out, page)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func findVersion(rt *shortcut.RuntimeContext, nodeID string, target int) (map[string]any, error) {
|
||||
const maxPages = 20
|
||||
cursor := ""
|
||||
seenCursors := map[string]bool{}
|
||||
for pageNumber := 1; pageNumber <= maxPages; pageNumber++ {
|
||||
page, err := versionPage(rt, nodeID, 50, cursor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, version := range page["versions"].([]map[string]any) {
|
||||
if number, ok := versionNumber(version); ok && number == target {
|
||||
return version, nil
|
||||
}
|
||||
}
|
||||
|
||||
nextCursor := strings.TrimSpace(nestedString(page, "nextCursor", "nextToken", "nextPageToken"))
|
||||
hasMore, hasMoreKnown := boolField(page, "hasMore")
|
||||
if hasMoreKnown && !hasMore {
|
||||
return nil, driveResponseError("drive/list_file_versions", "version_not_found", fmt.Sprintf("完整版本历史中不存在版本 %d", target))
|
||||
}
|
||||
if nextCursor == "" {
|
||||
if hasMoreKnown && hasMore {
|
||||
return nil, versionPaginationError("missing_next_cursor", pageNumber, cursor)
|
||||
}
|
||||
return nil, driveResponseError("drive/list_file_versions", "version_not_found", fmt.Sprintf("完整版本历史中不存在版本 %d", target))
|
||||
}
|
||||
if seenCursors[nextCursor] {
|
||||
return nil, versionPaginationError("stalled_cursor", pageNumber, nextCursor)
|
||||
}
|
||||
seenCursors[nextCursor] = true
|
||||
cursor = nextCursor
|
||||
}
|
||||
return nil, versionPaginationError("max_pages", maxPages, cursor)
|
||||
}
|
||||
|
||||
func versionPaginationError(reason string, page int, cursor string) error {
|
||||
return driveResponseError(
|
||||
"drive/list_file_versions",
|
||||
"version_pagination_"+reason,
|
||||
fmt.Sprintf("版本预检无法证明分页已经完整,已停止操作(page=%d, cursor=%q)", page, cursor),
|
||||
)
|
||||
}
|
||||
|
||||
func versionNumber(version map[string]any) (int, bool) {
|
||||
switch value := version["version"].(type) {
|
||||
case float64:
|
||||
return int(value), value == float64(int(value))
|
||||
case int:
|
||||
return value, true
|
||||
case string:
|
||||
var number int
|
||||
if _, err := fmt.Sscanf(value, "%d", &number); err == nil {
|
||||
return number, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func driveDownloadPayload(payload map[string]any, operation string) (string, string, map[string]string, error) {
|
||||
url := firstString(payload, "downloadUrl", "resourceUrl", "url")
|
||||
if url == "" {
|
||||
if resources, ok := payload["resourceUrls"].([]any); ok && len(resources) > 0 {
|
||||
if first, ok := resources[0].(map[string]any); ok {
|
||||
url = firstString(first, "url", "downloadUrl", "resourceUrl")
|
||||
payload = first
|
||||
}
|
||||
}
|
||||
}
|
||||
if url == "" {
|
||||
return "", "", nil, driveResponseError(operation, "missing_download_url", "下载响应没有有效 URL")
|
||||
}
|
||||
headers := map[string]string{}
|
||||
if raw, ok := payload["headers"].(map[string]any); ok {
|
||||
for key, value := range raw {
|
||||
if text, ok := value.(string); ok {
|
||||
headers[key] = text
|
||||
}
|
||||
}
|
||||
}
|
||||
return url, firstString(payload, "fileName", "name"), headers, nil
|
||||
}
|
||||
@@ -0,0 +1,758 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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/localio"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/minutesdata"
|
||||
)
|
||||
|
||||
var (
|
||||
minutesPutFile = localio.PutFile
|
||||
minutesDownload = localio.Download
|
||||
)
|
||||
|
||||
var Search = shortcut.Shortcut{
|
||||
Service: "minutes", Command: "+search", Product: "minutes",
|
||||
Description: "按范围、标题关键词和时间搜索听记,支持安全全量翻页",
|
||||
Intent: "需要按标题关键词或时间范围搜索自己创建、他人共享或全部可访问听记时使用;对后端返回再做确定性标题匹配,返回稳定 taskUuid 投影与完整性信息。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
|
||||
Contract: minutesContract("+search", "按范围、标题关键词和时间搜索听记,支持安全全量翻页",
|
||||
"需要按标题关键词或时间范围搜索自己创建、他人共享或全部可访问听记,并拿到可继续操作的 taskUuid 时使用",
|
||||
[]string{"只想无条件浏览一页时可用 +list-mine/+list-shared/+list-all", "DWS 后端不支持 owner/participant 精确过滤,不要伪造该条件"},
|
||||
[]string{`dws minutes +search --query "周会" --scope all`, `dws minutes +search --start "2026-08-01T00:00:00+08:00" --scope mine --page-all`}),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "query", Type: shortcut.FlagString, Desc: "标题关键词;shortcut 会对后端结果再次精确包含过滤"},
|
||||
{Name: "scope", Type: shortcut.FlagString, Default: "all", Desc: "搜索范围", Enum: []string{"mine", "shared", "all"}},
|
||||
{Name: "start", Type: shortcut.FlagString, Desc: "开始时间 RFC3339"},
|
||||
{Name: "end", Type: shortcut.FlagString, Desc: "结束时间 RFC3339"},
|
||||
{Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "每页条数"},
|
||||
{Name: "cursor", Type: shortcut.FlagString, Desc: "起始 nextToken"},
|
||||
{Name: "page-all", Type: shortcut.FlagBool, Desc: "自动读取全部页"},
|
||||
{Name: "page-limit", Type: shortcut.FlagInt, Default: "100", Desc: "自动翻页安全上限"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"query", "start", "end"}},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit", "page-limit"}, Description: "--limit/--page-limit 必须大于 0"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"start", "end"}, Description: "时间必须是 RFC3339 且开始时间不能晚于结束时间"},
|
||||
},
|
||||
Tips: []string{`dws minutes +search --query "周会" --scope all`, `dws minutes +search --start "2026-08-01T00:00:00+08:00" --scope mine --page-all`},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
if rt.Int("limit") <= 0 || rt.Int("page-limit") <= 0 {
|
||||
return apperrors.NewValidation("--limit 和 --page-limit 必须大于 0")
|
||||
}
|
||||
_, _, err := minutesTimeRange(rt.Str("start"), rt.Str("end"))
|
||||
return err
|
||||
},
|
||||
Execute: executeMinutesSearch,
|
||||
}
|
||||
|
||||
var Download = shortcut.Shortcut{
|
||||
Service: "minutes", Command: "+download", Product: "minutes",
|
||||
Description: "批量取得听记音视频地址并安全下载到本地",
|
||||
Intent: "已知一个或多个 taskUuid,需要取得音视频 OSS 地址或以 no-clobber、原子发布方式保存到工作目录时使用。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
|
||||
Contract: minutesContract("+download", "批量取得听记音视频地址并安全下载到本地",
|
||||
"已知一个或多个听记 taskUuid,需要下载其音频/视频或显式获取临时 URL 时使用",
|
||||
[]string{"只要查看摘要或逐字稿时不要下载媒体", "媒体未就绪、已过期或无痕模式时会明确失败,不返回空成功"},
|
||||
[]string{`dws minutes +download --id <taskUuid> --output ./meeting-media`, `dws minutes +download --ids <uuid1,uuid2> --output-dir ./minutes-downloads`}),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "id", Type: shortcut.FlagString, Desc: "单个 taskUuid"},
|
||||
{Name: "ids", Type: shortcut.FlagStringSlice, Desc: "多个 taskUuid,最多 50 个"},
|
||||
{Name: "url-only", Type: shortcut.FlagBool, Desc: "只返回临时签名 URL,不下载"},
|
||||
{Name: "output", Type: shortcut.FlagString, Desc: "单个目标的工作目录内相对输出路径"},
|
||||
{Name: "output-dir", Type: shortcut.FlagString, Default: "minutes-downloads", Desc: "批量下载目录"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"id", "ids"}},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"id", "ids"}, Description: "去重后 taskUuid 数量必须为 1..50"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"ids", "output"}, Description: "批量下载不能使用 --output"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"output", "output-dir"}, Description: "输出必须是安全的工作目录相对路径"},
|
||||
},
|
||||
Tips: []string{`dws minutes +download --id <taskUuid> --output ./meeting-media`, `dws minutes +download --ids <uuid1,uuid2> --output-dir ./minutes-downloads`},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
ids := minutesIDs(rt)
|
||||
if len(ids) == 0 || len(ids) > 50 {
|
||||
return apperrors.NewValidation("taskUuid 数量必须为 1..50")
|
||||
}
|
||||
if len(ids) > 1 && rt.Changed("output") {
|
||||
return apperrors.NewValidation("批量下载不能使用 --output,请使用 --output-dir")
|
||||
}
|
||||
if rt.Changed("output") {
|
||||
return localio.ValidateOutput(rt.Str("output"))
|
||||
}
|
||||
return localio.ValidateOutput(rt.Str("output-dir"))
|
||||
},
|
||||
Execute: executeMinutesDownload,
|
||||
}
|
||||
|
||||
var Upload = shortcut.Shortcut{
|
||||
Service: "minutes", Command: "+upload", Product: "minutes",
|
||||
Description: "把本地音视频完整上传并创建听记,失败时取消会话",
|
||||
Intent: "需要从本地音视频直接创建听记时使用;完成 create、预签名 PUT、complete 和最终详情读回,不需要中转 Drive。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
|
||||
Contract: withMinutesDryRun(minutesContract("+upload", "把本地音视频完整上传并创建听记,失败时取消会话",
|
||||
"用户有本地音视频,希望直接上传生成听记并取得可读回的 taskUuid 时使用",
|
||||
[]string{"只需要管理已有 upload session 时使用原子 upload create/complete/cancel", "文件为空、过大或不希望创建远端听记时不要执行"},
|
||||
[]string{`dws minutes +upload --file ./meeting.mp3 --title "项目周会"`, `dws minutes +upload --file ./meeting.mp4 --input-language zh`}), contract.DryRunPreviewPlan, false),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "file", Type: shortcut.FlagString, Desc: "本地音视频文件", Required: true},
|
||||
{Name: "title", Type: shortcut.FlagString, Desc: "听记标题"},
|
||||
{Name: "template-id", Type: shortcut.FlagString, Desc: "纪要模板 ID"},
|
||||
{Name: "input-language", Type: shortcut.FlagString, Desc: "ASR 输入语言"},
|
||||
{Name: "enable-message-card", Type: shortcut.FlagBool, Desc: "生成后推送闪记卡片"},
|
||||
{Name: "complete-timeout", Type: shortcut.FlagInt, Default: "90", Desc: "等待服务端确认上传完成的秒数"},
|
||||
{Name: "poll-interval", Type: shortcut.FlagInt, Default: "2", Desc: "complete 重试间隔秒数"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"complete-timeout", "poll-interval"}, Description: "--complete-timeout 和 --poll-interval 必须大于 0"}},
|
||||
Tips: []string{`dws minutes +upload --file ./meeting.mp3 --title "项目周会"`, `dws minutes +upload --file ./meeting.mp4 --input-language zh`},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
if rt.Int("complete-timeout") <= 0 || rt.Int("poll-interval") <= 0 {
|
||||
return apperrors.NewValidation("--complete-timeout 和 --poll-interval 必须大于 0")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Execute: executeMinutesUpload,
|
||||
}
|
||||
|
||||
var Update = shortcut.Shortcut{
|
||||
Service: "minutes", Command: "+update", Product: "minutes",
|
||||
Description: "读取现状、预览差异、更新听记标题并读回验证",
|
||||
Intent: "已知 taskUuid,需要安全重命名听记标题并确认远端最终值时使用。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "idempotent"},
|
||||
Contract: withMinutesDryRun(minutesContract("+update", "读取现状、更新听记标题并读回验证;dry-run 输出不访问远端的执行计划",
|
||||
"需要修改一条听记的标题,并通过最终读回证明修改生效时使用",
|
||||
[]string{"要修改纪要正文时使用 +summary"},
|
||||
[]string{`dws minutes +update --id <taskUuid> --title "Q3 项目复盘"`}), contract.DryRunPreviewPlan, false),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid", Required: true},
|
||||
{Name: "title", Type: shortcut.FlagString, Desc: "新标题", Required: true},
|
||||
},
|
||||
Tips: []string{`dws minutes +update --id <taskUuid> --title "Q3 项目复盘"`},
|
||||
Execute: executeMinutesUpdate,
|
||||
}
|
||||
|
||||
var ApplyPermission = shortcut.Shortcut{
|
||||
Service: "minutes", Command: "+apply-permission", Product: "minutes",
|
||||
Description: "为当前用户申请语义化的听记访问权限",
|
||||
Intent: "打开听记无权限时,用 view/download/edit 语义申请对应权限,不需要记 policy 数字。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
|
||||
Contract: withMinutesDryRun(minutesContract("+apply-permission", "为当前用户申请语义化的听记访问权限",
|
||||
"当前用户访问某条听记被拒绝,需要向所有者申请查看、下载或编辑权限时使用",
|
||||
[]string{"所有者给其他成员授权时使用 +share", "要移除权限时使用 +unshare"},
|
||||
[]string{`dws minutes +apply-permission --id <taskUuid> --permission view`, `dws minutes +apply-permission --id <taskUuid> --permission edit`}), contract.DryRunPreviewPlan, false),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid", Required: true},
|
||||
{Name: "permission", Type: shortcut.FlagString, Desc: "申请权限", Required: true, Enum: []string{"view", "download", "edit"}},
|
||||
},
|
||||
Tips: []string{`dws minutes +apply-permission --id <taskUuid> --permission view`, `dws minutes +apply-permission --id <taskUuid> --permission edit`},
|
||||
Execute: executeMinutesApplyPermission,
|
||||
}
|
||||
|
||||
var Summary = shortcut.Shortcut{
|
||||
Service: "minutes", Command: "+summary", Product: "minutes",
|
||||
Description: "读取当前纪要、校验图片引用、全量覆盖并读回验证",
|
||||
Intent: "需要安全编辑听记纪要正文时使用;支持字面量、@file 和 stdin,保护现有 Markdown 图片并验证最终全文。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "idempotent"},
|
||||
Contract: withMinutesDryRun(minutesContract("+summary", "校验本地纪要输入、全量覆盖并读回验证;dry-run 不访问远端",
|
||||
"需要用确认后的完整 Markdown 覆盖一条听记纪要,并保护已有图片引用时使用",
|
||||
[]string{"只改标题时使用 +update", "没有完整目标内容时不要执行全量覆盖"},
|
||||
[]string{`dws minutes +summary --id <taskUuid> --content "项目周会纪要"`}), contract.DryRunPreviewPlan, false),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid", Required: true},
|
||||
{Name: "content", Type: shortcut.FlagString, Desc: "完整纪要字面量、@相对文件或 - 表示 stdin", Required: true},
|
||||
},
|
||||
Tips: []string{`dws minutes +summary --id <taskUuid> --content @summary.md`, `dws minutes +summary --id <taskUuid> --content -`},
|
||||
Execute: executeMinutesSummary,
|
||||
}
|
||||
|
||||
var SpeakerReplace = shortcut.Shortcut{
|
||||
Service: "minutes", Command: "+speaker-replace", Product: "minutes",
|
||||
Description: "预检逐字稿中的发言人昵称,替换后重新读回验证",
|
||||
Intent: "需要把听记中的一个发言人昵称替换为目标昵称/UID 时使用;这是昵称替换,不是 Lark speaker_id 身份重绑。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "idempotent"},
|
||||
Contract: withMinutesDryRun(minutesContract("+speaker-replace", "预检逐字稿中的发言人昵称,替换后重新读回验证",
|
||||
"逐字稿已经生成且用户明确要把一个源发言人昵称批量替换为目标昵称时使用",
|
||||
[]string{"DWS 不是 speaker_id 到用户 open_id 的身份重绑;需要该语义时当前无法对齐", "源昵称不存在或目标身份不确定时不要写"},
|
||||
[]string{`dws minutes +speaker-replace --id <taskUuid> --from "发言人1" --to "张三"`}), contract.DryRunPreviewPlan, false),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid", Required: true},
|
||||
{Name: "from", Type: shortcut.FlagString, Desc: "源发言人昵称", Required: true},
|
||||
{Name: "to", Type: shortcut.FlagString, Desc: "目标发言人昵称", Required: true},
|
||||
{Name: "target-uid", Type: shortcut.FlagString, Desc: "目标钉钉 UID"},
|
||||
{Name: "page-limit", Type: shortcut.FlagInt, Default: "100", Desc: "逐字稿预检翻页上限"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"from", "to"}, Description: "--from/--to 去空白后不能相同"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-limit"}, Description: "--page-limit 必须大于 0"},
|
||||
},
|
||||
Tips: []string{`dws minutes +speaker-replace --id <taskUuid> --from "发言人1" --to "张三"`},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
if strings.TrimSpace(rt.Str("from")) == strings.TrimSpace(rt.Str("to")) {
|
||||
return apperrors.NewValidation("--from 与 --to 不能相同")
|
||||
}
|
||||
if rt.Int("page-limit") <= 0 {
|
||||
return apperrors.NewValidation("--page-limit 必须大于 0")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Execute: executeMinutesSpeakerReplace,
|
||||
}
|
||||
|
||||
func executeMinutesSearch(rt *shortcut.RuntimeContext) error {
|
||||
start, end, err := minutesTimeRange(rt.Str("start"), rt.Str("end"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
belonging := map[string]string{"mine": "createdByMe", "shared": "sharedToMe", "all": "noLimit"}[rt.Str("scope")]
|
||||
token := rt.Str("cursor")
|
||||
seenTokens := map[string]bool{}
|
||||
seenIDs := map[string]bool{}
|
||||
rows := make([]map[string]any, 0)
|
||||
pages := 0
|
||||
complete := false
|
||||
nextToken := ""
|
||||
for {
|
||||
if seenTokens[token] {
|
||||
return fmt.Errorf("minutes search cursor stalled or cycled")
|
||||
}
|
||||
seenTokens[token] = true
|
||||
params := map[string]any{"belongingConditionId": belonging, "maxResults": rt.Int("limit")}
|
||||
if query := strings.TrimSpace(rt.Str("query")); query != "" {
|
||||
params["keyword"] = query
|
||||
}
|
||||
if start != 0 {
|
||||
params["createTimeStart"] = float64(start)
|
||||
}
|
||||
if end != 0 {
|
||||
params["createTimeEnd"] = float64(end)
|
||||
}
|
||||
if token != "" {
|
||||
params["nextToken"] = token
|
||||
}
|
||||
data, err := rt.CallMCPData("minutes", "list_by_keyword_and_time_range", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
page, err := minutesdata.ParseListPage(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
projected, err := minutesdata.ProjectList(page)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, row := range projected {
|
||||
id := row["taskUuid"].(string)
|
||||
if seenIDs[id] {
|
||||
continue
|
||||
}
|
||||
seenIDs[id] = true
|
||||
rows = append(rows, row)
|
||||
}
|
||||
pages++
|
||||
nextToken = page.NextToken
|
||||
if !page.HasMore {
|
||||
complete = true
|
||||
nextToken = ""
|
||||
break
|
||||
}
|
||||
if !rt.Bool("page-all") {
|
||||
break
|
||||
}
|
||||
if pages >= rt.Int("page-limit") {
|
||||
return fmt.Errorf("minutes search exceeded page safety limit %d", rt.Int("page-limit"))
|
||||
}
|
||||
token = page.NextToken
|
||||
}
|
||||
scanned := len(rows)
|
||||
if query := strings.TrimSpace(rt.Str("query")); query != "" {
|
||||
needle := strings.ToLower(query)
|
||||
filtered := make([]map[string]any, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
title, _ := row["title"].(string)
|
||||
if strings.Contains(strings.ToLower(title), needle) {
|
||||
filtered = append(filtered, row)
|
||||
}
|
||||
}
|
||||
rows = filtered
|
||||
}
|
||||
payload := map[string]any{"count": len(rows), "scannedCount": scanned, "minutes": rows, "pages": pages, "complete": complete}
|
||||
if nextToken != "" {
|
||||
payload["nextToken"] = nextToken
|
||||
}
|
||||
return rt.Output(payload)
|
||||
}
|
||||
|
||||
func executeMinutesDownload(rt *shortcut.RuntimeContext) error {
|
||||
ids := minutesIDs(rt)
|
||||
results := make([]map[string]any, 0, len(ids))
|
||||
failures := make([]map[string]any, 0)
|
||||
for _, id := range ids {
|
||||
data, err := rt.CallMCPData("minutes", "query_minutes_audio_url", map[string]any{"taskUuid": id})
|
||||
if err != nil {
|
||||
failures = append(failures, map[string]any{"taskUuid": id, "error": err.Error()})
|
||||
continue
|
||||
}
|
||||
mediaURL, err := minutesdata.MediaURL(data)
|
||||
if err != nil {
|
||||
failures = append(failures, map[string]any{"taskUuid": id, "error": err.Error()})
|
||||
continue
|
||||
}
|
||||
if rt.Bool("url-only") {
|
||||
results = append(results, map[string]any{"taskUuid": id, "url": mediaURL})
|
||||
continue
|
||||
}
|
||||
output := rt.Str("output-dir") + "/"
|
||||
if len(ids) == 1 && rt.Changed("output") {
|
||||
output = rt.Str("output")
|
||||
}
|
||||
preferred := id + mediaExtension(mediaURL)
|
||||
download, err := minutesDownload(rt.Command().Context(), mediaURL, localio.DownloadOptions{Output: output, PreferredName: preferred})
|
||||
if err != nil {
|
||||
failures = append(failures, map[string]any{"taskUuid": id, "error": err.Error()})
|
||||
continue
|
||||
}
|
||||
results = append(results, map[string]any{"taskUuid": id, "path": download.RelativePath, "sizeBytes": download.SizeBytes})
|
||||
}
|
||||
payload := map[string]any{"ok": len(failures) == 0, "requested": len(ids), "succeeded": len(results), "failed": len(failures), "results": results}
|
||||
if len(failures) > 0 {
|
||||
payload["failures"] = failures
|
||||
}
|
||||
if err := rt.Output(payload); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(failures) > 0 {
|
||||
return minutesCompositeError("minutes_download_incomplete", "download", payload)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeMinutesUpload(rt *shortcut.RuntimeContext) error {
|
||||
payload, err := performMinutesUpload(rt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(payload)
|
||||
}
|
||||
|
||||
func performMinutesUpload(rt *shortcut.RuntimeContext) (map[string]any, error) {
|
||||
info, err := os.Stat(rt.Str("file"))
|
||||
if err != nil || !info.Mode().IsRegular() || info.Size() <= 0 {
|
||||
return nil, apperrors.NewValidation("--file 必须是非空普通文件")
|
||||
}
|
||||
plan := map[string]any{"operation": "minutes.upload", "fileName": info.Name(), "sizeBytes": info.Size(), "title": rt.Str("title")}
|
||||
if rt.DryRun() {
|
||||
return minutesDryRunPayload(contract.DryRunPreviewPlan, "minutes.upload", plan), nil
|
||||
}
|
||||
params := map[string]any{"fileName": info.Name(), "fileSize": info.Size()}
|
||||
if value := strings.TrimSpace(rt.Str("title")); value != "" {
|
||||
params["title"] = value
|
||||
}
|
||||
option := map[string]any{}
|
||||
if value := strings.TrimSpace(rt.Str("template-id")); value != "" {
|
||||
option["templateId"] = value
|
||||
}
|
||||
if value := strings.TrimSpace(rt.Str("input-language")); value != "" {
|
||||
option["inputLanguage"] = value
|
||||
}
|
||||
if rt.Changed("enable-message-card") {
|
||||
option["enableMessageCard"] = rt.Bool("enable-message-card")
|
||||
}
|
||||
if len(option) > 0 {
|
||||
params["minutesOption"] = option
|
||||
}
|
||||
created, err := rt.CallMCPWriteDataStrict("minutes", "create_upload_session", params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sessionID, presignedURL, err := minutesdata.UploadSession(created)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
transfer, err := minutesPutFile(rt.Command().Context(), presignedURL, rt.Str("file"), 0)
|
||||
if err != nil {
|
||||
cancelled, cancelErr := cancelMinutesUpload(rt, sessionID)
|
||||
return nil, minutesUploadFailure("put", sessionID, cancelled, cancelErr, err)
|
||||
}
|
||||
completed, completeAttempts, err := completeMinutesUpload(rt, sessionID, time.Duration(rt.Int("complete-timeout"))*time.Second, time.Duration(rt.Int("poll-interval"))*time.Second)
|
||||
if err != nil {
|
||||
return nil, minutesUploadCompletionUnknown(sessionID, completeAttempts, err)
|
||||
}
|
||||
taskUUID, err := minutesdata.CompletedTaskUUID(completed)
|
||||
if err != nil {
|
||||
return nil, minutesUploadFailure("complete_response", sessionID, false, nil, err)
|
||||
}
|
||||
readBack, err := rt.CallMCPData("minutes", "get_minutes_basic_info", map[string]any{"taskUuid": taskUUID})
|
||||
if err != nil {
|
||||
return nil, minutesUploadFailure("verify", sessionID, false, nil, err)
|
||||
}
|
||||
if _, err := minutesdata.Basic(taskUUID, readBack); err != nil {
|
||||
return nil, minutesUploadFailure("verify", sessionID, false, nil, err)
|
||||
}
|
||||
return map[string]any{"operation": "minutes.upload", "complete": true, "sessionId": sessionID, "taskUuid": taskUUID, "sizeBytes": transfer.SizeBytes, "uploadAttempts": transfer.Attempts, "completeAttempts": completeAttempts, "verified": true}, nil
|
||||
}
|
||||
|
||||
func executeMinutesUpdate(rt *shortcut.RuntimeContext) error {
|
||||
id, title := rt.Str("id"), strings.TrimSpace(rt.Str("title"))
|
||||
if rt.DryRun() {
|
||||
return rt.Output(minutesDryRunPayload(contract.DryRunPreviewPlan, "minutes.update", map[string]any{
|
||||
"taskUuid": id, "after": title, "remotePreconditions": []string{"read current title", "write title", "read back exact title"},
|
||||
}))
|
||||
}
|
||||
beforeData, err := rt.CallMCPData("minutes", "get_minutes_basic_info", map[string]any{"taskUuid": id})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
before, err := minutesdata.Basic(id, beforeData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
oldTitle, _ := before["title"].(string)
|
||||
if oldTitle == title {
|
||||
return rt.Output(map[string]any{"taskUuid": id, "changed": false, "verified": true, "title": title})
|
||||
}
|
||||
if _, err := rt.CallMCPWriteDataStrict("minutes", "update_minutes_title", map[string]any{"taskUuid": id, "title": title}); err != nil {
|
||||
return err
|
||||
}
|
||||
afterData, err := rt.CallMCPData("minutes", "get_minutes_basic_info", map[string]any{"taskUuid": id})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
after, err := minutesdata.Basic(id, afterData)
|
||||
if err != nil || after["title"] != title {
|
||||
return minutesCompositeError("minutes_title_verification_failed", "verify", map[string]any{"taskUuid": id, "expectedTitle": title})
|
||||
}
|
||||
return rt.Output(map[string]any{"taskUuid": id, "changed": true, "verified": true, "before": oldTitle, "after": title})
|
||||
}
|
||||
|
||||
func executeMinutesApplyPermission(rt *shortcut.RuntimeContext) error {
|
||||
policy := map[string]float64{"edit": 2, "download": 3, "view": 4}[rt.Str("permission")]
|
||||
plan := map[string]any{"taskUuid": rt.Str("id"), "permission": rt.Str("permission"), "policyId": policy}
|
||||
if rt.DryRun() {
|
||||
return rt.Output(minutesDryRunPayload(contract.DryRunPreviewPlan, "minutes.apply_permission", plan))
|
||||
}
|
||||
data, err := rt.CallMCPWriteDataStrict("minutes", "apply_minutes_permission", map[string]any{"taskUuid": rt.Str("id"), "policyId": policy})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := minutesdata.RequireWriteAcknowledgement("apply permission", data); err != nil {
|
||||
return err
|
||||
}
|
||||
plan["requested"], plan["result"] = true, data["result"]
|
||||
return rt.Output(plan)
|
||||
}
|
||||
|
||||
func executeMinutesSummary(rt *shortcut.RuntimeContext) error {
|
||||
id := rt.Str("id")
|
||||
after, err := localio.ReadTextInput(rt.Str("content"), rt.Command().InOrStdin(), 8<<20)
|
||||
if err != nil {
|
||||
return apperrors.NewValidation(err.Error())
|
||||
}
|
||||
if strings.TrimSpace(after) == "" {
|
||||
return apperrors.NewValidation("纪要内容不能为空")
|
||||
}
|
||||
if rt.DryRun() {
|
||||
return rt.Output(minutesDryRunPayload(contract.DryRunPreviewPlan, "minutes.summary", map[string]any{
|
||||
"taskUuid": id, "afterBytes": len(after), "remotePreconditions": []string{"read current summary", "preserve existing Markdown images", "write summary", "read back exact summary"},
|
||||
}))
|
||||
}
|
||||
beforeData, err := rt.CallMCPData("minutes", "get_minutes_ai_summary", map[string]any{"taskUuid": id})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
before, err := minutesdata.SummaryText(beforeData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if missing := missingMarkdownImages(before, after); len(missing) > 0 {
|
||||
return apperrors.NewValidation(fmt.Sprintf("新纪要丢失 %d 个原有 Markdown 图片引用", len(missing)))
|
||||
}
|
||||
if before == after {
|
||||
return rt.Output(map[string]any{"taskUuid": id, "changed": false, "verified": true})
|
||||
}
|
||||
if _, err := rt.CallMCPWriteDataStrict("minutes", "update_minutes_summary", map[string]any{"taskUuid": id, "summaryText": after}); err != nil {
|
||||
return err
|
||||
}
|
||||
readBackData, err := rt.CallMCPData("minutes", "get_minutes_ai_summary", map[string]any{"taskUuid": id})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
readBack, err := minutesdata.SummaryText(readBackData)
|
||||
if err != nil || readBack != after {
|
||||
return minutesCompositeError("minutes_summary_verification_failed", "verify", map[string]any{"taskUuid": id, "expectedBytes": len(after)})
|
||||
}
|
||||
return rt.Output(map[string]any{"taskUuid": id, "changed": true, "verified": true, "beforeBytes": len(before), "afterBytes": len(after), "preservedImages": true})
|
||||
}
|
||||
|
||||
func executeMinutesSpeakerReplace(rt *shortcut.RuntimeContext) error {
|
||||
id, from, to := rt.Str("id"), rt.Str("from"), rt.Str("to")
|
||||
if rt.DryRun() {
|
||||
return rt.Output(minutesDryRunPayload(contract.DryRunPreviewPlan, "minutes.speaker_replace", map[string]any{
|
||||
"taskUuid": id, "from": from, "to": to, "targetUid": strings.TrimSpace(rt.Str("target-uid")),
|
||||
"remotePreconditions": []string{"read full transcript", "verify source speaker exists", "replace speaker", "read back full transcript"},
|
||||
}))
|
||||
}
|
||||
before, err := collectTranscriptForMinutes(rt, id, rt.Int("page-limit"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
beforeSpeakers := speakerCounts(before.Paragraphs)
|
||||
if beforeSpeakers[from] == 0 {
|
||||
return apperrors.NewValidation(fmt.Sprintf("逐字稿中不存在发言人 %q", from))
|
||||
}
|
||||
params := map[string]any{"taskUuid": id, "speakerNick": from, "targetNickName": to}
|
||||
if uid := strings.TrimSpace(rt.Str("target-uid")); uid != "" {
|
||||
params["targetUid"] = uid
|
||||
}
|
||||
if _, err := rt.CallMCPWriteDataStrict("minutes", "replace_speaker", params); err != nil {
|
||||
return err
|
||||
}
|
||||
after, err := collectTranscriptForMinutes(rt, id, rt.Int("page-limit"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
afterSpeakers := speakerCounts(after.Paragraphs)
|
||||
if afterSpeakers[from] != 0 || afterSpeakers[to] < beforeSpeakers[from] {
|
||||
return minutesCompositeError("minutes_speaker_verification_failed", "verify", map[string]any{"taskUuid": id, "fromRemaining": afterSpeakers[from], "targetCount": afterSpeakers[to]})
|
||||
}
|
||||
return rt.Output(map[string]any{"taskUuid": id, "changed": true, "verified": true, "from": from, "to": to, "affectedParagraphs": beforeSpeakers[from]})
|
||||
}
|
||||
|
||||
func minutesTimeRange(startRaw, endRaw string) (int64, int64, error) {
|
||||
parse := func(name, raw string) (int64, error) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return 0, nil
|
||||
}
|
||||
value, err := time.Parse(time.RFC3339, raw)
|
||||
if err != nil {
|
||||
return 0, apperrors.NewValidation(fmt.Sprintf("--%s 必须是 RFC3339 时间: %v", name, err))
|
||||
}
|
||||
return value.UnixMilli(), nil
|
||||
}
|
||||
start, err := parse("start", startRaw)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
end, err := parse("end", endRaw)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if start != 0 && end != 0 && start > end {
|
||||
return 0, 0, apperrors.NewValidation("--start 不能晚于 --end")
|
||||
}
|
||||
return start, end, nil
|
||||
}
|
||||
|
||||
func minutesIDs(rt *shortcut.RuntimeContext) []string {
|
||||
values := []string{}
|
||||
if id := strings.TrimSpace(rt.Str("id")); id != "" {
|
||||
values = append(values, id)
|
||||
}
|
||||
values = append(values, rt.StrSlice("ids")...)
|
||||
seen := map[string]bool{}
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
for _, item := range strings.Split(value, ",") {
|
||||
item = strings.TrimSpace(item)
|
||||
if item != "" && !seen[item] {
|
||||
seen[item] = true
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mediaExtension(rawURL string) string {
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(parsed.Path))
|
||||
if len(ext) > 10 {
|
||||
return ""
|
||||
}
|
||||
return ext
|
||||
}
|
||||
|
||||
func cancelMinutesUpload(rt *shortcut.RuntimeContext, sessionID string) (bool, error) {
|
||||
data, err := rt.CallMCPWriteDataStrict("minutes", "cancel_upload_session", map[string]any{"sessionId": sessionID})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := minutesdata.RequireWriteAcknowledgement("cancel upload", data); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func completeMinutesUpload(rt *shortcut.RuntimeContext, sessionID string, timeout, interval time.Duration) (map[string]any, int, error) {
|
||||
deadline := time.Now().Add(timeout)
|
||||
attempts := 0
|
||||
for {
|
||||
attempts++
|
||||
data, err := rt.CallMCPWriteDataStrict("minutes", "complete_upload_session", map[string]any{"sessionId": sessionID})
|
||||
if err == nil {
|
||||
return data, attempts, nil
|
||||
}
|
||||
message := strings.ToLower(err.Error())
|
||||
retryable := strings.Contains(message, "still uploading") || strings.Contains(message, "wait and retry") || strings.Contains(message, "正在上传")
|
||||
if !retryable || time.Now().Add(interval).After(deadline) {
|
||||
return nil, attempts, err
|
||||
}
|
||||
timer := time.NewTimer(interval)
|
||||
select {
|
||||
case <-rt.Command().Context().Done():
|
||||
timer.Stop()
|
||||
return nil, attempts, rt.Command().Context().Err()
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func minutesUploadFailure(stage, sessionID string, cancelled bool, cancelErr, cause error) error {
|
||||
details := map[string]any{"stage": stage, "sessionId": sessionID, "cancelled": cancelled}
|
||||
if cause != nil {
|
||||
details["cause"] = cause.Error()
|
||||
}
|
||||
if cancelErr != nil {
|
||||
details["cancelError"] = cancelErr.Error()
|
||||
}
|
||||
return apperrors.NewAPI(
|
||||
fmt.Sprintf("听记上传在 %s 阶段失败", stage),
|
||||
apperrors.WithOperation("minutes/+upload"),
|
||||
apperrors.WithOrigin("shortcut"),
|
||||
apperrors.WithFailureStage(stage),
|
||||
apperrors.WithReason("minutes_upload_failed"),
|
||||
apperrors.WithExecutionStarted(true),
|
||||
apperrors.WithRetryable(false),
|
||||
apperrors.WithDetails(details),
|
||||
)
|
||||
}
|
||||
|
||||
func minutesUploadCompletionUnknown(sessionID string, attempts int, cause error) error {
|
||||
details := map[string]any{
|
||||
"stage": "complete",
|
||||
"sessionId": sessionID,
|
||||
"completeAttempts": attempts,
|
||||
"cancelled": false,
|
||||
"remoteEffect": "unknown",
|
||||
"recovery": map[string]any{
|
||||
"action": "verify_before_cancel",
|
||||
"nextAction": "保留 sessionId;先重试 complete_upload_session 或查询最近听记确认服务端状态,只有明确证明未完成时才取消会话",
|
||||
},
|
||||
}
|
||||
if cause != nil {
|
||||
details["cause"] = cause.Error()
|
||||
}
|
||||
return apperrors.NewAPI(
|
||||
"听记上传 complete 请求的远端结果未知;为避免破坏可能已完成的上传,已保留会话且未自动取消",
|
||||
apperrors.WithOperation("minutes/+upload"),
|
||||
apperrors.WithOrigin("shortcut"),
|
||||
apperrors.WithFailureStage("complete"),
|
||||
apperrors.WithReason("minutes_upload_completion_unknown"),
|
||||
apperrors.WithExecutionStarted(true),
|
||||
apperrors.WithRetryable(false),
|
||||
apperrors.WithHint("不要重新上传或直接取消;先使用错误详情中的 sessionId 核验服务端状态"),
|
||||
apperrors.WithActions("retry complete_upload_session with the preserved sessionId", "query recent Minutes before cancelling the upload session"),
|
||||
apperrors.WithDetails(details),
|
||||
)
|
||||
}
|
||||
|
||||
func minutesCompositeError(reason, stage string, details map[string]any) error {
|
||||
return apperrors.NewAPI(
|
||||
"Minutes shortcut 未完成,详见结构化错误",
|
||||
apperrors.WithOperation("minutes/shortcut"),
|
||||
apperrors.WithOrigin("shortcut"),
|
||||
apperrors.WithFailureStage(stage),
|
||||
apperrors.WithReason(reason),
|
||||
apperrors.WithRetryable(false),
|
||||
apperrors.WithDetails(details),
|
||||
)
|
||||
}
|
||||
|
||||
var markdownImageRE = regexp.MustCompile(`!\[[^\]]*\]\(([^)]+)\)`)
|
||||
|
||||
func missingMarkdownImages(before, after string) []string {
|
||||
remaining := map[string]bool{}
|
||||
for _, match := range markdownImageRE.FindAllStringSubmatch(after, -1) {
|
||||
remaining[match[1]] = true
|
||||
}
|
||||
missing := []string{}
|
||||
for _, match := range markdownImageRE.FindAllStringSubmatch(before, -1) {
|
||||
if !remaining[match[1]] {
|
||||
missing = append(missing, match[1])
|
||||
}
|
||||
}
|
||||
sort.Strings(missing)
|
||||
return missing
|
||||
}
|
||||
|
||||
func collectTranscriptForMinutes(rt *shortcut.RuntimeContext, taskUUID string, pageLimit int) (minutesdata.TranscriptResult, error) {
|
||||
return minutesdata.CollectTranscript(func(nextToken string) (map[string]any, error) {
|
||||
params := map[string]any{"taskUuid": taskUUID, "direction": "0"}
|
||||
if nextToken != "" {
|
||||
params["nextToken"] = nextToken
|
||||
}
|
||||
return rt.CallMCPData("minutes", "get_minutes_transcription", params)
|
||||
}, "", false, pageLimit)
|
||||
}
|
||||
|
||||
func speakerCounts(paragraphs []map[string]any) map[string]int {
|
||||
counts := map[string]int{}
|
||||
var visit func(any, map[string]bool)
|
||||
visit = func(value any, seen map[string]bool) {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
local := map[string]bool{}
|
||||
for _, key := range []string{"speakerNick", "nickName", "nickname", "subSpeakerNickname"} {
|
||||
if name, ok := typed[key].(string); ok && strings.TrimSpace(name) != "" {
|
||||
local[strings.TrimSpace(name)] = true
|
||||
}
|
||||
}
|
||||
for name := range local {
|
||||
seen[name] = true
|
||||
}
|
||||
for _, child := range typed {
|
||||
visit(child, seen)
|
||||
}
|
||||
case []any:
|
||||
for _, child := range typed {
|
||||
visit(child, seen)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, paragraph := range paragraphs {
|
||||
seen := map[string]bool{}
|
||||
visit(paragraph, seen)
|
||||
for name := range seen {
|
||||
counts[name]++
|
||||
}
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
func init() {
|
||||
shortcut.Register(finalizeMinutesShortcuts(Search, Download, Upload, Update, ApplyPermission, Summary, SpeakerReplace)...)
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type minutesFailWriter struct{}
|
||||
|
||||
func (minutesFailWriter) Write([]byte) (int, error) { return 0, errors.New("fixture output failure") }
|
||||
|
||||
func runMinutesAlignmentCLIWithWriter(t *testing.T, caller *minutesE2ECaller, writer io.Writer, args ...string) error {
|
||||
t.Helper()
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.PersistentFlags().String("format", "json", "")
|
||||
root.AddCommand(shortcut.Commands()...)
|
||||
root.SetOut(writer)
|
||||
root.SetErr(&bytes.Buffer{})
|
||||
root.SetArgs(args)
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesAlignmentValidationAndHelpers(t *testing.T) {
|
||||
caller := &minutesE2ECaller{}
|
||||
manyIDs := make([]string, 51)
|
||||
for index := range manyIDs {
|
||||
manyIDs[index] = fmt.Sprintf("u%d", index)
|
||||
}
|
||||
invalid := [][]string{
|
||||
{"minutes", "+search", "--query", "x", "--limit", "0"},
|
||||
{"minutes", "+search", "--query", "x", "--start", "bad"},
|
||||
{"minutes", "+search", "--query", "x", "--end", "bad"},
|
||||
{"minutes", "+search", "--query", "x", "--start", "2026-08-02T00:00:00Z", "--end", "2026-08-01T00:00:00Z"},
|
||||
{"minutes", "+download", "--id", "u1", "--ids", "u2"},
|
||||
{"minutes", "+download", "--ids", strings.Join(manyIDs, ",")},
|
||||
{"minutes", "+download", "--ids", "u1,u2", "--output", "one"},
|
||||
{"minutes", "+download", "--id", "u1", "--output", "../escape"},
|
||||
{"minutes", "+upload", "--file", "missing", "--complete-timeout", "0"},
|
||||
{"minutes", "+speaker-replace", "--id", "u1", "--from", "same", "--to", " same "},
|
||||
{"minutes", "+speaker-replace", "--id", "u1", "--from", "a", "--to", "b", "--page-limit", "0"},
|
||||
}
|
||||
for _, args := range invalid {
|
||||
if payload, output, err := runMinutesAlignmentCLI(t, caller, args...); err == nil || payload != nil || output != "" {
|
||||
t.Fatalf("invalid argv accepted: %v payload=%#v output=%q err=%v", args, payload, output, err)
|
||||
}
|
||||
}
|
||||
|
||||
if start, end, err := minutesTimeRange("2026-08-01T00:00:00Z", "2026-08-02T00:00:00Z"); err != nil || start == 0 || end <= start {
|
||||
t.Fatalf("valid range = %d %d %v", start, end, err)
|
||||
}
|
||||
if got := mediaExtension("https://example.invalid/a.MP3?token=x"); got != ".mp3" {
|
||||
t.Fatalf("extension = %q", got)
|
||||
}
|
||||
if got := mediaExtension("://bad"); got != "" {
|
||||
t.Fatalf("bad extension = %q", got)
|
||||
}
|
||||
if got := mediaExtension("https://example.invalid/a.12345678901"); got != "" {
|
||||
t.Fatalf("long extension = %q", got)
|
||||
}
|
||||
counts := speakerCounts([]map[string]any{{
|
||||
"speakerNick": " A ", "nested": []any{map[string]any{"nickName": "A", "subSpeakerNickname": "B"}},
|
||||
}})
|
||||
if counts["A"] != 1 || counts["B"] != 1 {
|
||||
t.Fatalf("speaker counts = %#v", counts)
|
||||
}
|
||||
|
||||
decl := corecmd.ContractDecl{Parameters: []contract.ParamDecl{
|
||||
{Name: "a", Description: "parameter"},
|
||||
{Name: "a", Description: "already evidence"},
|
||||
{Name: "other", Description: "other"},
|
||||
}}
|
||||
value := shortcut.Shortcut{Description: "desc", Intent: "intent", Contract: decl,
|
||||
Flags: []shortcut.Flag{{Name: "a", Desc: "flag"}},
|
||||
Constraints: []shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"a"}},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"a"}},
|
||||
},
|
||||
}
|
||||
finalized := finalizeMinutesShortcuts(value)[0]
|
||||
if finalized.Contract.Selection.AgentSummary != "desc" || strings.Contains(finalized.Flags[0].Desc, "约束") {
|
||||
t.Fatalf("finalized = %#v", finalized)
|
||||
}
|
||||
withEvidence := value
|
||||
withEvidence.Constraints = []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"a"}, Description: "evidence"}}
|
||||
withEvidence.Flags[0].Desc = "flag evidence"
|
||||
withEvidence.Contract.Parameters[1].Description = "already evidence"
|
||||
finalized = finalizeMinutesShortcuts(withEvidence)[0]
|
||||
if !strings.Contains(finalized.Contract.Parameters[0].Description, "约束:evidence") || finalized.Contract.Parameters[1].Description != "already evidence" || finalized.Contract.Parameters[2].Description != "other" {
|
||||
t.Fatalf("parameter evidence = %#v", finalized.Contract.Parameters)
|
||||
}
|
||||
payload := minutesDryRunPayload("plan", "op", nil)
|
||||
if payload["operation"] != "op" || payload["executed"] != false {
|
||||
t.Fatalf("dry-run payload = %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesSearchFailureAndCursorBranchesE2E(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
caller *minutesE2ECaller
|
||||
args []string
|
||||
wantError bool
|
||||
wantNext string
|
||||
}{
|
||||
{name: "call", caller: &minutesE2ECaller{failAt: map[string]int{"minutes/list_by_keyword_and_time_range": 1}}, args: []string{"minutes", "+search", "--query", "x"}, wantError: true},
|
||||
{name: "parse", caller: &minutesE2ECaller{responses: map[string][]string{"minutes/list_by_keyword_and_time_range": {`{"success":true,"result":{}}`}}}, args: []string{"minutes", "+search", "--query", "x"}, wantError: true},
|
||||
{name: "project", caller: &minutesE2ECaller{responses: map[string][]string{"minutes/list_by_keyword_and_time_range": {`{"success":true,"result":{"itemList":[{"title":"x"}],"hasNext":false}}`}}}, args: []string{"minutes", "+search", "--query", "x"}, wantError: true},
|
||||
{name: "single page", caller: &minutesE2ECaller{responses: map[string][]string{"minutes/list_by_keyword_and_time_range": {`{"success":true,"result":{"itemList":[{"uuid":"u1","title":"x"}],"hasNext":true,"nextToken":"n2"}}`}}}, args: []string{"minutes", "+search", "--start", "2026-08-01T00:00:00Z", "--end", "2026-08-02T00:00:00Z", "--cursor", "n1"}, wantNext: "n2"},
|
||||
{name: "limit", caller: &minutesE2ECaller{responses: map[string][]string{"minutes/list_by_keyword_and_time_range": {`{"success":true,"result":{"itemList":[],"hasNext":true,"nextToken":"n2"}}`}}}, args: []string{"minutes", "+search", "--query", "x", "--page-all", "--page-limit", "1"}, wantError: true},
|
||||
{name: "cycle", caller: &minutesE2ECaller{responses: map[string][]string{"minutes/list_by_keyword_and_time_range": {`{"success":true,"result":{"itemList":[],"hasNext":true,"nextToken":"n1"}}`}}}, args: []string{"minutes", "+search", "--query", "x", "--page-all", "--cursor", "n1"}, wantError: true},
|
||||
}
|
||||
for _, test := range cases {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
payload, _, err := runMinutesAlignmentCLI(t, test.caller, test.args...)
|
||||
if (err != nil) != test.wantError {
|
||||
t.Fatalf("payload=%#v err=%v", payload, err)
|
||||
}
|
||||
if test.wantNext != "" && payload["nextToken"] != test.wantNext {
|
||||
t.Fatalf("payload=%#v", payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
if err := runMinutesAlignmentCLIWithWriter(t, &minutesE2ECaller{responses: map[string][]string{
|
||||
"minutes/list_by_keyword_and_time_range": {`{"success":true,"result":{"itemList":[],"hasNext":false}}`},
|
||||
}}, minutesFailWriter{}, "minutes", "+search", "--query", "x"); err == nil {
|
||||
t.Fatal("output failure accepted")
|
||||
}
|
||||
// Execute is defensive even when invoked without the Shortcut validator.
|
||||
cmd := &cobra.Command{Use: "search"}
|
||||
cmd.Flags().String("start", "bad", "")
|
||||
cmd.Flags().String("end", "", "")
|
||||
cmd.Flags().String("scope", "all", "")
|
||||
cmd.Flags().String("cursor", "", "")
|
||||
cmd.Flags().String("query", "x", "")
|
||||
cmd.Flags().Int("limit", 20, "")
|
||||
cmd.Flags().Int("page-limit", 100, "")
|
||||
cmd.Flags().Bool("page-all", false, "")
|
||||
if err := executeMinutesSearch(shortcut.RuntimeContextForTest(cmd, Search)); err == nil {
|
||||
t.Fatal("direct invalid time accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesDownloadBranchesE2E(t *testing.T) {
|
||||
testseam.Swap(t, &minutesDownload, func(_ context.Context, rawURL string, options localio.DownloadOptions) (localio.DownloadResult, error) {
|
||||
if strings.Contains(rawURL, "fail") {
|
||||
return localio.DownloadResult{}, errors.New("fixture download failure")
|
||||
}
|
||||
return localio.DownloadResult{RelativePath: filepath.ToSlash(filepath.Join(options.Output, options.PreferredName)), SizeBytes: 12}, nil
|
||||
})
|
||||
caller := &minutesE2ECaller{
|
||||
responses: map[string][]string{"minutes/query_minutes_audio_url": {
|
||||
`{"success":true,"result":{"downloadUrl":"https://example.invalid/u1.mp3"}}`,
|
||||
`{"success":true,"result":{"downloadUrl":"https://example.invalid/fail.mp4"}}`,
|
||||
}},
|
||||
failAt: map[string]int{"minutes/query_minutes_audio_url": 3},
|
||||
}
|
||||
payload, output, err := runMinutesAlignmentCLI(t, caller, "minutes", "+download", "--ids", "u1,u2,u3", "--output-dir", "media")
|
||||
if err == nil || output == "" || payload["succeeded"] != float64(1) || payload["failed"] != float64(2) {
|
||||
t.Fatalf("download payload=%#v err=%v", payload, err)
|
||||
}
|
||||
urlOnly := &minutesE2ECaller{responses: map[string][]string{"minutes/query_minutes_audio_url": {`{"success":true,"result":{"downloadUrl":"https://example.invalid/u1.mp3"}}`}}}
|
||||
if payload, _, err = runMinutesAlignmentCLI(t, urlOnly, "minutes", "+download", "--id", "u1", "--url-only"); err != nil || payload["succeeded"] != float64(1) {
|
||||
t.Fatalf("url-only payload=%#v err=%v", payload, err)
|
||||
}
|
||||
if err := runMinutesAlignmentCLIWithWriter(t, urlOnly, minutesFailWriter{}, "minutes", "+download", "--id", "u1", "--url-only"); err == nil {
|
||||
t.Fatal("download output failure accepted")
|
||||
}
|
||||
single := &minutesE2ECaller{responses: map[string][]string{"minutes/query_minutes_audio_url": {`{"success":true,"result":{"downloadUrl":"https://example.invalid/u1.mp3"}}`}}}
|
||||
if payload, _, err = runMinutesAlignmentCLI(t, single, "minutes", "+download", "--id", "u1", "--output", "single.mp3"); err != nil || payload["succeeded"] != float64(1) {
|
||||
t.Fatalf("single payload=%#v err=%v", payload, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesUploadFailureAndSuccessBranchesE2E(t *testing.T) {
|
||||
file := filepath.Join(t.TempDir(), "source.wav")
|
||||
if err := os.WriteFile(file, []byte("audio"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload, output, err := runMinutesAlignmentCLI(t, &minutesE2ECaller{}, "minutes", "+upload", "--file", t.TempDir(), "--yes"); err == nil || payload != nil || output != "" {
|
||||
t.Fatalf("directory upload accepted: %#v %q %v", payload, output, err)
|
||||
}
|
||||
|
||||
createFail := &minutesE2ECaller{failAt: map[string]int{"minutes/create_upload_session": 1}}
|
||||
if _, _, err := runMinutesAlignmentCLI(t, createFail, "minutes", "+upload", "--file", file, "--yes"); err == nil {
|
||||
t.Fatal("create failure accepted")
|
||||
}
|
||||
badSession := &minutesE2ECaller{responses: map[string][]string{"minutes/create_upload_session": {`{"success":true,"result":{}}`}}}
|
||||
if _, _, err := runMinutesAlignmentCLI(t, badSession, "minutes", "+upload", "--file", file, "--yes"); err == nil {
|
||||
t.Fatal("bad session accepted")
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
cancelRaw string
|
||||
cancelFailAt int
|
||||
}{
|
||||
{name: "cancel success", cancelRaw: `{"success":true,"result":{}}`},
|
||||
{name: "cancel call failure", cancelFailAt: 1},
|
||||
{name: "cancel acknowledgement failure", cancelRaw: `{"result":{}}`},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
testseam.Swap(t, &minutesPutFile, func(context.Context, string, string, int64) (localio.UploadResult, error) {
|
||||
return localio.UploadResult{}, errors.New("put failed")
|
||||
})
|
||||
caller := &minutesE2ECaller{
|
||||
responses: map[string][]string{
|
||||
"minutes/create_upload_session": {`{"success":true,"result":{"sessionId":"s1","presignedUrl":"https://example.invalid/u"}}`},
|
||||
"minutes/cancel_upload_session": {test.cancelRaw},
|
||||
},
|
||||
failAt: map[string]int{"minutes/cancel_upload_session": test.cancelFailAt},
|
||||
}
|
||||
if _, _, err := runMinutesAlignmentCLI(t, caller, "minutes", "+upload", "--file", file, "--yes"); err == nil {
|
||||
t.Fatal("put failure accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
testseam.Swap(t, &minutesPutFile, func(context.Context, string, string, int64) (localio.UploadResult, error) {
|
||||
return localio.UploadResult{SizeBytes: 5, Attempts: 2}, nil
|
||||
})
|
||||
base := map[string][]string{
|
||||
"minutes/create_upload_session": {`{"success":true,"result":{"sessionId":"s1","presignedUrl":"https://example.invalid/u"}}`},
|
||||
"minutes/complete_upload_session": {`{"success":true,"result":{"taskUuid":"u1"}}`},
|
||||
"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1","title":"ok"}}`},
|
||||
}
|
||||
success := &minutesE2ECaller{responses: base}
|
||||
payload, _, err := runMinutesAlignmentCLI(t, success, "minutes", "+upload", "--file", file, "--title", "T", "--template-id", "tpl", "--input-language", "zh", "--enable-message-card", "--yes")
|
||||
if err != nil || payload["complete"] != true || payload["verified"] != true {
|
||||
t.Fatalf("upload payload=%#v err=%v", payload, err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
responses map[string][]string
|
||||
failAt map[string]int
|
||||
}{
|
||||
{name: "invalid completion", responses: map[string][]string{
|
||||
"minutes/create_upload_session": {`{"success":true,"result":{"sessionId":"s1","presignedUrl":"https://example.invalid/u"}}`},
|
||||
"minutes/complete_upload_session": {`{"success":true,"result":{}}`},
|
||||
}},
|
||||
{name: "verify call", responses: base, failAt: map[string]int{"minutes/get_minutes_basic_info": 1}},
|
||||
{name: "verify parse", responses: map[string][]string{
|
||||
"minutes/create_upload_session": {`{"success":true,"result":{"sessionId":"s1","presignedUrl":"https://example.invalid/u"}}`},
|
||||
"minutes/complete_upload_session": {`{"success":true,"result":{"taskUuid":"u1"}}`},
|
||||
"minutes/get_minutes_basic_info": {`{"success":true,"result":{}}`},
|
||||
}},
|
||||
}
|
||||
for _, test := range cases {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
caller := &minutesE2ECaller{responses: test.responses, failAt: test.failAt}
|
||||
if _, _, err := runMinutesAlignmentCLI(t, caller, "minutes", "+upload", "--file", file, "--yes"); err == nil {
|
||||
t.Fatal("upload failure accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
if err := runMinutesAlignmentCLIWithWriter(t, &minutesE2ECaller{}, minutesFailWriter{}, "minutes", "+upload", "--file", file, "--dry-run"); err == nil {
|
||||
t.Fatal("upload output failure accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesUploadErrorConstructors(t *testing.T) {
|
||||
if err := minutesUploadFailure("put", "s", false, nil, nil); err == nil {
|
||||
t.Fatal("nil-cause failure missing")
|
||||
}
|
||||
if err := minutesUploadCompletionUnknown("s", 1, nil); err == nil {
|
||||
t.Fatal("nil-cause unknown completion missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesUpdatePermissionSummarySpeakerFailuresE2E(t *testing.T) {
|
||||
// Dry-run branches must not access MCP and must still propagate output errors.
|
||||
dryCases := [][]string{
|
||||
{"minutes", "+update", "--id", "u1", "--title", "new", "--dry-run"},
|
||||
{"minutes", "+summary", "--id", "u1", "--content", "new", "--dry-run"},
|
||||
{"minutes", "+speaker-replace", "--id", "u1", "--from", "a", "--to", "b", "--dry-run"},
|
||||
}
|
||||
for _, args := range dryCases {
|
||||
if err := runMinutesAlignmentCLIWithWriter(t, &minutesE2ECaller{}, minutesFailWriter{}, args...); err == nil {
|
||||
t.Fatalf("output failure accepted: %v", args)
|
||||
}
|
||||
}
|
||||
|
||||
updateCases := []struct {
|
||||
name string
|
||||
responses map[string][]string
|
||||
failAt map[string]int
|
||||
wantError bool
|
||||
}{
|
||||
{name: "before call", failAt: map[string]int{"minutes/get_minutes_basic_info": 1}, wantError: true},
|
||||
{name: "before parse", responses: map[string][]string{"minutes/get_minutes_basic_info": {`{"success":true,"result":{}}`}}, wantError: true},
|
||||
{name: "unchanged", responses: map[string][]string{"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1","title":"new"}}`}}},
|
||||
{name: "write", responses: map[string][]string{"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1","title":"old"}}`}}, failAt: map[string]int{"minutes/update_minutes_title": 1}, wantError: true},
|
||||
{name: "after call", responses: map[string][]string{"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1","title":"old"}}`}}, failAt: map[string]int{"minutes/get_minutes_basic_info": 2}, wantError: true},
|
||||
{name: "after parse", responses: map[string][]string{"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1","title":"old"}}`, `{"success":true,"result":{}}`}, "minutes/update_minutes_title": {`{"success":true,"result":{}}`}}, wantError: true},
|
||||
}
|
||||
for _, test := range updateCases {
|
||||
t.Run("update "+test.name, func(t *testing.T) {
|
||||
payload, _, err := runMinutesAlignmentCLI(t, &minutesE2ECaller{responses: test.responses, failAt: test.failAt}, "minutes", "+update", "--id", "u1", "--title", "new", "--yes")
|
||||
if (err != nil) != test.wantError {
|
||||
t.Fatalf("payload=%#v err=%v", payload, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
permissionCases := []struct {
|
||||
name string
|
||||
responses map[string][]string
|
||||
failAt map[string]int
|
||||
writer io.Writer
|
||||
}{
|
||||
{name: "call", failAt: map[string]int{"minutes/apply_minutes_permission": 1}},
|
||||
{name: "ack", responses: map[string][]string{"minutes/apply_minutes_permission": {`{"result":{}}`}}},
|
||||
{name: "success output", responses: map[string][]string{"minutes/apply_minutes_permission": {`{"success":true,"result":{}}`}}, writer: minutesFailWriter{}},
|
||||
}
|
||||
for _, test := range permissionCases {
|
||||
t.Run("permission "+test.name, func(t *testing.T) {
|
||||
writer := test.writer
|
||||
if writer == nil {
|
||||
writer = &bytes.Buffer{}
|
||||
}
|
||||
if err := runMinutesAlignmentCLIWithWriter(t, &minutesE2ECaller{responses: test.responses, failAt: test.failAt}, writer, "minutes", "+apply-permission", "--id", "u1", "--permission", "view", "--yes"); err == nil {
|
||||
t.Fatal("permission failure accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
permissionSuccess := &minutesE2ECaller{responses: map[string][]string{"minutes/apply_minutes_permission": {`{"success":true,"result":{}}`}}}
|
||||
if payload, _, err := runMinutesAlignmentCLI(t, permissionSuccess, "minutes", "+apply-permission", "--id", "u1", "--permission", "view", "--yes"); err != nil || payload["requested"] != true {
|
||||
t.Fatalf("permission payload=%#v err=%v", payload, err)
|
||||
}
|
||||
|
||||
if _, _, err := runMinutesAlignmentCLI(t, &minutesE2ECaller{}, "minutes", "+summary", "--id", "u1", "--content", "@missing", "--yes"); err == nil {
|
||||
t.Fatal("missing input accepted")
|
||||
}
|
||||
if _, _, err := runMinutesAlignmentCLI(t, &minutesE2ECaller{}, "minutes", "+summary", "--id", "u1", "--content", " ", "--yes"); err == nil {
|
||||
t.Fatal("empty summary accepted")
|
||||
}
|
||||
cmd := &cobra.Command{Use: "summary"}
|
||||
cmd.Flags().String("id", "u1", "")
|
||||
cmd.Flags().String("content", " ", "")
|
||||
if err := executeMinutesSummary(shortcut.RuntimeContextForTest(cmd, Summary)); err == nil {
|
||||
t.Fatal("direct empty summary accepted")
|
||||
}
|
||||
summaryCases := []struct {
|
||||
name string
|
||||
responses map[string][]string
|
||||
failAt map[string]int
|
||||
wantError bool
|
||||
}{
|
||||
{name: "before call", failAt: map[string]int{"minutes/get_minutes_ai_summary": 1}, wantError: true},
|
||||
{name: "before parse", responses: map[string][]string{"minutes/get_minutes_ai_summary": {`{"success":true,"result":{}}`}}, wantError: true},
|
||||
{name: "unchanged", responses: map[string][]string{"minutes/get_minutes_ai_summary": {`{"success":true,"result":{"fullSummary":"new"}}`}}},
|
||||
{name: "write", responses: map[string][]string{"minutes/get_minutes_ai_summary": {`{"success":true,"result":{"fullSummary":"old"}}`}}, failAt: map[string]int{"minutes/update_minutes_summary": 1}, wantError: true},
|
||||
{name: "readback call", responses: map[string][]string{"minutes/get_minutes_ai_summary": {`{"success":true,"result":{"fullSummary":"old"}}`}}, failAt: map[string]int{"minutes/get_minutes_ai_summary": 2}, wantError: true},
|
||||
{name: "readback parse", responses: map[string][]string{"minutes/get_minutes_ai_summary": {`{"success":true,"result":{"fullSummary":"old"}}`, `{"success":true,"result":{}}`}, "minutes/update_minutes_summary": {`{"success":true,"result":{}}`}}, wantError: true},
|
||||
}
|
||||
for _, test := range summaryCases {
|
||||
t.Run("summary "+test.name, func(t *testing.T) {
|
||||
payload, _, err := runMinutesAlignmentCLI(t, &minutesE2ECaller{responses: test.responses, failAt: test.failAt}, "minutes", "+summary", "--id", "u1", "--content", "new", "--yes")
|
||||
if (err != nil) != test.wantError {
|
||||
t.Fatalf("payload=%#v err=%v", payload, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
speakerCases := []struct {
|
||||
name string
|
||||
responses map[string][]string
|
||||
failAt map[string]int
|
||||
}{
|
||||
{name: "before", failAt: map[string]int{"minutes/get_minutes_transcription": 1}},
|
||||
{name: "missing", responses: map[string][]string{"minutes/get_minutes_transcription": {`{"success":true,"result":{"paragraphList":[{"speakerNick":"x"}],"hasNext":false}}`}}},
|
||||
{name: "write", responses: map[string][]string{"minutes/get_minutes_transcription": {`{"success":true,"result":{"paragraphList":[{"speakerNick":"a"}],"hasNext":false}}`}}, failAt: map[string]int{"minutes/replace_speaker": 1}},
|
||||
{name: "after", responses: map[string][]string{"minutes/get_minutes_transcription": {`{"success":true,"result":{"paragraphList":[{"speakerNick":"a"}],"hasNext":false}}`}}, failAt: map[string]int{"minutes/get_minutes_transcription": 2}},
|
||||
{name: "verify", responses: map[string][]string{"minutes/get_minutes_transcription": {`{"success":true,"result":{"paragraphList":[{"speakerNick":"a"}],"hasNext":false}}`, `{"success":true,"result":{"paragraphList":[{"speakerNick":"a"}],"hasNext":false}}`}, "minutes/replace_speaker": {`{"success":true,"result":{}}`}}},
|
||||
}
|
||||
for _, test := range speakerCases {
|
||||
t.Run("speaker "+test.name, func(t *testing.T) {
|
||||
if _, _, err := runMinutesAlignmentCLI(t, &minutesE2ECaller{responses: test.responses, failAt: test.failAt}, "minutes", "+speaker-replace", "--id", "u1", "--from", "a", "--to", "b", "--target-uid", "uid", "--yes"); err == nil {
|
||||
t.Fatal("speaker failure accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesCompleteUploadRetryAndContext(t *testing.T) {
|
||||
// Use the real runtime/caller path so retry classification is tested, while
|
||||
// zero intervals keep the fault-injection cases deterministic and fast.
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
responses []string
|
||||
failAt int
|
||||
failError error
|
||||
cancel bool
|
||||
timeout time.Duration
|
||||
interval time.Duration
|
||||
wantCalls int
|
||||
wantError bool
|
||||
}{
|
||||
{name: "success", responses: []string{`{"success":true,"result":{"taskUuid":"u1"}}`}, timeout: time.Second, wantCalls: 1},
|
||||
{name: "retry success", responses: []string{`{"success":true,"result":{"taskUuid":"u1"}}`}, failAt: 1, failError: errors.New("still uploading"), timeout: time.Second, wantCalls: 2},
|
||||
{name: "nonretryable", failAt: 1, failError: errors.New("denied"), timeout: time.Second, wantCalls: 1, wantError: true},
|
||||
{name: "timeout", failAt: 1, failError: errors.New("wait and retry"), wantCalls: 1, wantError: true},
|
||||
{name: "context", failAt: 1, failError: errors.New("正在上传"), cancel: true, timeout: 2 * time.Hour, interval: time.Hour, wantCalls: 1, wantError: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
key := "minutes/complete_upload_session"
|
||||
caller := &minutesE2ECaller{
|
||||
responses: map[string][]string{key: test.responses},
|
||||
failAt: map[string]int{key: test.failAt},
|
||||
failErrors: map[string]error{key: test.failError},
|
||||
}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
ctx := context.Background()
|
||||
if test.cancel {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithCancel(ctx)
|
||||
cancel()
|
||||
}
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.SetContext(ctx)
|
||||
rt := shortcut.RuntimeContextForTest(cmd, Upload)
|
||||
_, attempts, err := completeMinutesUpload(rt, "s1", test.timeout, test.interval)
|
||||
if (err != nil) != test.wantError || attempts != test.wantCalls || caller.counts[key] != test.wantCalls {
|
||||
t.Fatalf("attempts=%d calls=%d err=%v", attempts, caller.counts[key], err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesLegacyListsAndRecordCommandsE2E(t *testing.T) {
|
||||
listSuccess := &minutesE2ECaller{responses: map[string][]string{"minutes/list_by_keyword_and_time_range": {`{"success":true,"result":{"itemList":[{"uuid":"u1","title":"weekly"}],"hasNext":false}}`}}}
|
||||
for _, command := range []string{"+list-mine", "+list-shared", "+list-all"} {
|
||||
payload, _, err := runMinutesAlignmentCLI(t, listSuccess, "minutes", command, "--query", "weekly", "--limit", "5", "--cursor", "n1")
|
||||
if err != nil || payload["count"] != float64(1) {
|
||||
t.Fatalf("%s payload=%#v err=%v", command, payload, err)
|
||||
}
|
||||
}
|
||||
if calls := listSuccess.arguments["minutes/list_by_keyword_and_time_range"]; len(calls) != 3 || calls[0]["maxResults"] != 5 || calls[0]["keyword"] != "weekly" || calls[0]["nextToken"] != "n1" {
|
||||
t.Fatalf("list args=%#v", calls)
|
||||
}
|
||||
listCall := &minutesE2ECaller{failAt: map[string]int{"minutes/list_by_keyword_and_time_range": 1}}
|
||||
if _, _, err := runMinutesAlignmentCLI(t, listCall, "minutes", "+list-all"); err == nil {
|
||||
t.Fatal("list call failure accepted")
|
||||
}
|
||||
listParse := &minutesE2ECaller{responses: map[string][]string{"minutes/list_by_keyword_and_time_range": {`{"success":true,"result":{}}`}}}
|
||||
if _, _, err := runMinutesAlignmentCLI(t, listParse, "minutes", "+list-all"); err == nil {
|
||||
t.Fatal("list parse failure accepted")
|
||||
}
|
||||
listProject := &minutesE2ECaller{responses: map[string][]string{"minutes/list_by_keyword_and_time_range": {`{"success":true,"result":{"itemList":[{"title":"missing id"}]}}`}}}
|
||||
if _, _, err := runMinutesAlignmentCLI(t, listProject, "minutes", "+list-all"); err == nil {
|
||||
t.Fatal("list projection failure accepted")
|
||||
}
|
||||
if err := runMinutesAlignmentCLIWithWriter(t, listSuccess, minutesFailWriter{}, "minutes", "+list-all"); err == nil {
|
||||
t.Fatal("list output failure accepted")
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
command string
|
||||
cmd string
|
||||
id string
|
||||
}{
|
||||
{command: "+record-start", cmd: "create"},
|
||||
{command: "+record-pause", cmd: "pause", id: "u1"},
|
||||
{command: "+record-resume", cmd: "resume", id: "u1"},
|
||||
{command: "+record-stop", cmd: "end", id: "u1"},
|
||||
} {
|
||||
t.Run(test.command, func(t *testing.T) {
|
||||
result := fmt.Sprintf(`{"success":true,"result":{"cmd":%q,"uuid":%q}}`, test.cmd, test.id)
|
||||
caller := &minutesE2ECaller{responses: map[string][]string{"minutes/" + listeningNoteCmdTool: {result}}}
|
||||
args := []string{"minutes", test.command, "--session-id", "session", "--yes"}
|
||||
if test.id != "" {
|
||||
args = append(args, "--id", test.id)
|
||||
}
|
||||
payload, _, err := runMinutesAlignmentCLI(t, caller, args...)
|
||||
if err != nil || payload["accepted"] != true || payload["command"] != test.cmd {
|
||||
t.Fatalf("payload=%#v err=%v", payload, err)
|
||||
}
|
||||
if caller.arguments["minutes/"+listeningNoteCmdTool][0]["sessionId"] != "session" {
|
||||
t.Fatalf("args=%#v", caller.arguments)
|
||||
}
|
||||
})
|
||||
}
|
||||
recordCall := &minutesE2ECaller{failAt: map[string]int{"minutes/" + listeningNoteCmdTool: 1}}
|
||||
if _, _, err := runMinutesAlignmentCLI(t, recordCall, "minutes", "+record-start", "--yes"); err == nil {
|
||||
t.Fatal("record call failure accepted")
|
||||
}
|
||||
recordParse := &minutesE2ECaller{responses: map[string][]string{"minutes/" + listeningNoteCmdTool: {`{"success":true,"result":{"cmd":"pause","uuid":"u1"}}`}}}
|
||||
if _, _, err := runMinutesAlignmentCLI(t, recordParse, "minutes", "+record-stop", "--id", "u1", "--yes"); err == nil {
|
||||
t.Fatal("record result mismatch accepted")
|
||||
}
|
||||
recordOutput := &minutesE2ECaller{responses: map[string][]string{"minutes/" + listeningNoteCmdTool: {`{"success":true,"result":{"cmd":"end","uuid":"u1"}}`}}}
|
||||
if err := runMinutesAlignmentCLIWithWriter(t, recordOutput, minutesFailWriter{}, "minutes", "+record-stop", "--id", "u1", "--yes"); err == nil {
|
||||
t.Fatal("record output failure accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
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"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type minutesE2ECaller struct {
|
||||
responses map[string][]string
|
||||
failAt map[string]int
|
||||
counts map[string]int
|
||||
arguments map[string][]map[string]any
|
||||
beforeFail map[string]func()
|
||||
failErrors map[string]error
|
||||
}
|
||||
|
||||
func (c *minutesE2ECaller) CallTool(_ context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
if c.counts == nil {
|
||||
c.counts = map[string]int{}
|
||||
}
|
||||
if c.arguments == nil {
|
||||
c.arguments = map[string][]map[string]any{}
|
||||
}
|
||||
key := product + "/" + tool
|
||||
c.counts[key]++
|
||||
c.arguments[key] = append(c.arguments[key], args)
|
||||
if c.failAt[key] == c.counts[key] {
|
||||
if hook := c.beforeFail[key]; hook != nil {
|
||||
hook()
|
||||
}
|
||||
if err := c.failErrors[key]; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, errors.New("fixture failure")
|
||||
}
|
||||
responses := c.responses[key]
|
||||
text := `{"success":true,"result":{}}`
|
||||
if len(responses) > 0 {
|
||||
index := c.counts[key] - 1
|
||||
if index >= len(responses) {
|
||||
index = len(responses) - 1
|
||||
}
|
||||
text = responses[index]
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
|
||||
}
|
||||
|
||||
func (c *minutesE2ECaller) CallReadTool(ctx context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
return c.CallTool(ctx, product, tool, args)
|
||||
}
|
||||
|
||||
func (*minutesE2ECaller) Format() string { return "json" }
|
||||
func (*minutesE2ECaller) DryRun() bool { return false }
|
||||
func (*minutesE2ECaller) Fields() string { return "" }
|
||||
func (*minutesE2ECaller) JQ() string { return "" }
|
||||
|
||||
func runMinutesAlignmentCLI(t *testing.T, caller *minutesE2ECaller, args ...string) (map[string]any, string, error) {
|
||||
t.Helper()
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.PersistentFlags().String("format", "json", "")
|
||||
root.AddCommand(shortcut.Commands()...)
|
||||
var stdout bytes.Buffer
|
||||
root.SetOut(&stdout)
|
||||
root.SetErr(&bytes.Buffer{})
|
||||
root.SetArgs(args)
|
||||
err := root.Execute()
|
||||
if stdout.Len() == 0 {
|
||||
return nil, "", err
|
||||
}
|
||||
var payload map[string]any
|
||||
if decodeErr := json.Unmarshal(stdout.Bytes(), &payload); decodeErr != nil {
|
||||
t.Fatalf("decode output %q: %v", stdout.String(), decodeErr)
|
||||
}
|
||||
return payload, stdout.String(), err
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesSearchPaginatesFiltersAndRejectsUnknownE2E(t *testing.T) {
|
||||
caller := &minutesE2ECaller{responses: map[string][]string{
|
||||
"minutes/list_by_keyword_and_time_range": {
|
||||
`{"success":true,"result":{"itemList":[{"uuid":"u1","title":"周会 A","startTime":1},{"uuid":"u2","title":"其他","startTime":2}],"hasNext":true,"nextToken":"n2"}}`,
|
||||
`{"success":true,"result":{"itemList":[{"uuid":"u1","title":"周会 A","startTime":1},{"uuid":"u3","title":"周会 B","startTime":3}],"hasNext":false}}`,
|
||||
},
|
||||
}}
|
||||
payload, _, err := runMinutesAlignmentCLI(t, caller, "minutes", "+search", "--query", "周会", "--page-all")
|
||||
if err != nil || payload["count"] != float64(2) || payload["scannedCount"] != float64(3) || payload["pages"] != float64(2) || payload["complete"] != true {
|
||||
t.Fatalf("search payload=%#v err=%v", payload, err)
|
||||
}
|
||||
if calls := caller.arguments["minutes/list_by_keyword_and_time_range"]; len(calls) != 2 || calls[1]["nextToken"] != "n2" {
|
||||
t.Fatalf("search calls=%#v", calls)
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
scope string
|
||||
belonging string
|
||||
}{
|
||||
{scope: "mine", belonging: "createdByMe"},
|
||||
{scope: "shared", belonging: "sharedToMe"},
|
||||
{scope: "all", belonging: "noLimit"},
|
||||
} {
|
||||
t.Run("scope "+test.scope, func(t *testing.T) {
|
||||
scoped := &minutesE2ECaller{responses: map[string][]string{
|
||||
"minutes/list_by_keyword_and_time_range": {`{"success":true,"result":{"itemList":[],"hasNext":false}}`},
|
||||
}}
|
||||
payload, _, err := runMinutesAlignmentCLI(t, scoped, "minutes", "+search", "--query", "needle", "--scope", test.scope, "--limit", "1")
|
||||
if err != nil || payload["count"] != float64(0) || payload["complete"] != true {
|
||||
t.Fatalf("scope %s payload=%#v err=%v", test.scope, payload, err)
|
||||
}
|
||||
calls := scoped.arguments["minutes/list_by_keyword_and_time_range"]
|
||||
want := map[string]any{"belongingConditionId": test.belonging, "keyword": "needle", "maxResults": 1}
|
||||
if len(calls) != 1 || !reflect.DeepEqual(calls[0], want) {
|
||||
t.Fatalf("scope %s calls=%#v, want exactly %#v", test.scope, calls, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
unknown := &minutesE2ECaller{responses: map[string][]string{"minutes/list_by_keyword_and_time_range": {`{"success":true,"result":{}}`}}}
|
||||
if payload, output, err := runMinutesAlignmentCLI(t, unknown, "minutes", "+search", "--query", "周会"); err == nil || payload != nil || output != "" {
|
||||
t.Fatalf("unknown list accepted: payload=%#v output=%q err=%v", payload, output, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesDownloadEmptyMediaIsPartialFailureE2E(t *testing.T) {
|
||||
caller := &minutesE2ECaller{responses: map[string][]string{"minutes/query_minutes_audio_url": {`{"success":true,"result":{}}`}}}
|
||||
payload, output, err := runMinutesAlignmentCLI(t, caller, "minutes", "+download", "--id", "u1", "--url-only")
|
||||
if err == nil || output == "" || payload["ok"] != false || payload["failed"] != float64(1) || payload["succeeded"] != float64(0) {
|
||||
t.Fatalf("empty media accepted: payload=%#v output=%q err=%v", payload, output, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesUpdateRequiresVerifiedReadbackE2E(t *testing.T) {
|
||||
success := &minutesE2ECaller{responses: map[string][]string{
|
||||
"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1","title":"旧标题"}}`, `{"success":true,"result":{"taskUuid":"u1","title":"新标题"}}`},
|
||||
"minutes/update_minutes_title": {`{"success":true,"result":{"updated":true}}`},
|
||||
}}
|
||||
payload, _, err := runMinutesAlignmentCLI(t, success, "minutes", "+update", "--id", "u1", "--title", "新标题", "--yes")
|
||||
if err != nil || payload["changed"] != true || payload["verified"] != true {
|
||||
t.Fatalf("verified update payload=%#v err=%v", payload, err)
|
||||
}
|
||||
|
||||
mismatch := &minutesE2ECaller{responses: map[string][]string{
|
||||
"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1","title":"旧标题"}}`, `{"success":true,"result":{"taskUuid":"u1","title":"仍是旧标题"}}`},
|
||||
"minutes/update_minutes_title": {`{"success":true,"result":{"updated":true}}`},
|
||||
}}
|
||||
if payload, output, err := runMinutesAlignmentCLI(t, mismatch, "minutes", "+update", "--id", "u1", "--title", "新标题", "--yes"); err == nil || payload != nil || output != "" {
|
||||
t.Fatalf("readback mismatch accepted: payload=%#v output=%q err=%v", payload, output, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesSummaryProtectsImagesAndVerifiesE2E(t *testing.T) {
|
||||
missingImage := &minutesE2ECaller{responses: map[string][]string{
|
||||
"minutes/get_minutes_ai_summary": {`{"success":true,"result":{"fullSummary":"旧内容\n"}}`},
|
||||
}}
|
||||
if payload, output, err := runMinutesAlignmentCLI(t, missingImage, "minutes", "+summary", "--id", "u1", "--content", "新内容", "--yes"); err == nil || payload != nil || output != "" || missingImage.counts["minutes/update_minutes_summary"] != 0 {
|
||||
t.Fatalf("missing image accepted: payload=%#v output=%q err=%v calls=%#v", payload, output, err, missingImage.counts)
|
||||
}
|
||||
|
||||
content := "新内容\n"
|
||||
success := &minutesE2ECaller{responses: map[string][]string{
|
||||
"minutes/get_minutes_ai_summary": {`{"success":true,"result":{"fullSummary":"旧内容\n"}}`, `{"success":true,"result":{"fullSummary":"新内容\n"}}`},
|
||||
"minutes/update_minutes_summary": {`{"success":true,"result":{"updated":true}}`},
|
||||
}}
|
||||
payload, _, err := runMinutesAlignmentCLI(t, success, "minutes", "+summary", "--id", "u1", "--content", content, "--yes")
|
||||
if err != nil || payload["changed"] != true || payload["verified"] != true || payload["preservedImages"] != true {
|
||||
t.Fatalf("summary payload=%#v err=%v", payload, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesSpeakerReplacePaginatesAndVerifiesE2E(t *testing.T) {
|
||||
caller := &minutesE2ECaller{responses: map[string][]string{
|
||||
"minutes/get_minutes_transcription": {
|
||||
`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1","speakerNick":"甲"}],"hasNext":true,"nextToken":"n2"}}`,
|
||||
`{"success":true,"result":{"paragraphList":[{"paragraphId":"p2","speakerNick":"甲"}],"hasNext":false}}`,
|
||||
`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1","speakerNick":"乙"}],"hasNext":true,"nextToken":"n2"}}`,
|
||||
`{"success":true,"result":{"paragraphList":[{"paragraphId":"p2","speakerNick":"乙"}],"hasNext":false}}`,
|
||||
},
|
||||
"minutes/replace_speaker": {`{"success":true,"result":{"updated":true}}`},
|
||||
}}
|
||||
payload, _, err := runMinutesAlignmentCLI(t, caller, "minutes", "+speaker-replace", "--id", "u1", "--from", "甲", "--to", "乙", "--yes")
|
||||
if err != nil || payload["verified"] != true || payload["affectedParagraphs"] != float64(2) || caller.counts["minutes/get_minutes_transcription"] != 4 {
|
||||
t.Fatalf("speaker payload=%#v err=%v calls=%#v", payload, err, caller.counts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesUploadAndPermissionDryRunDoNotWriteE2E(t *testing.T) {
|
||||
work := t.TempDir()
|
||||
file := filepath.Join(work, "source.wav")
|
||||
if err := os.WriteFile(file, []byte("non-empty-audio-fixture"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
caller := &minutesE2ECaller{}
|
||||
upload, _, err := runMinutesAlignmentCLI(t, caller, "minutes", "+upload", "--file", file, "--title", "E2E", "--dry-run")
|
||||
if err != nil || upload["dryRun"] != true || upload["executed"] != false || len(caller.counts) != 0 {
|
||||
t.Fatalf("upload dry-run=%#v err=%v calls=%#v", upload, err, caller.counts)
|
||||
}
|
||||
permission, _, err := runMinutesAlignmentCLI(t, caller, "minutes", "+apply-permission", "--id", "u1", "--permission", "edit", "--dry-run")
|
||||
if err != nil || permission["policyId"] != float64(2) || permission["executed"] != false || len(caller.counts) != 0 {
|
||||
t.Fatalf("permission dry-run=%#v err=%v calls=%#v", permission, err, caller.counts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesUploadUnknownCompletionPreservesSessionE2E(t *testing.T) {
|
||||
work := t.TempDir()
|
||||
file := filepath.Join(work, "response-loss.wav")
|
||||
if err := os.WriteFile(file, []byte("non-empty-audio-fixture"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &minutesPutFile, func(context.Context, string, string, int64) (localio.UploadResult, error) {
|
||||
return localio.UploadResult{SizeBytes: 23, Attempts: 1}, nil
|
||||
})
|
||||
|
||||
serverCompleted := false
|
||||
caller := &minutesE2ECaller{
|
||||
responses: map[string][]string{
|
||||
"minutes/create_upload_session": {`{"success":true,"result":{"sessionId":"session-redacted","presignedUrl":"https://upload.example.invalid/object"}}`},
|
||||
},
|
||||
failAt: map[string]int{"minutes/complete_upload_session": 1},
|
||||
beforeFail: map[string]func(){
|
||||
"minutes/complete_upload_session": func() { serverCompleted = true },
|
||||
},
|
||||
}
|
||||
payload, output, err := runMinutesAlignmentCLI(t, caller, "minutes", "+upload", "--file", file, "--yes")
|
||||
if err == nil || payload != nil || output != "" || !serverCompleted {
|
||||
t.Fatalf("response-loss outcome = payload:%#v output:%q err:%v completed:%v", payload, output, err, serverCompleted)
|
||||
}
|
||||
if caller.counts["minutes/cancel_upload_session"] != 0 {
|
||||
t.Fatalf("unknown remote completion was cancelled: calls=%#v", caller.counts)
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != "minutes_upload_completion_unknown" || typed.Retryable || typed.FailureStage != "complete" {
|
||||
t.Fatalf("unknown completion error = %#v", err)
|
||||
}
|
||||
if typed.Details["sessionId"] != "session-redacted" || typed.Details["cancelled"] != false || typed.Details["remoteEffect"] != "unknown" {
|
||||
t.Fatalf("unknown completion recovery details = %#v", typed.Details)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesMindmapExplicitStatusControlsExitE2E(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
status string
|
||||
wantErr bool
|
||||
wantResult bool
|
||||
}{
|
||||
{name: "success", status: `{"success":true,"result":{"taskStatus":1,"mindGraph":"ready"}}`, wantResult: true},
|
||||
{name: "platform failed", status: `{"success":true,"result":{"taskStatus":2}}`, wantErr: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
caller := &minutesE2ECaller{responses: map[string][]string{
|
||||
"minutes/create_mind_graph": {`{"success":true,"result":{}}`},
|
||||
"minutes/query_mind_graph_status": {test.status},
|
||||
}}
|
||||
payload, output, err := runMinutesAlignmentCLI(t, caller, "minutes", "+mindmap", "--id", "u1", "--timeout", "2", "--interval", "1", "--yes")
|
||||
if output == "" || (err != nil) != test.wantErr || payload["complete"] != test.wantResult {
|
||||
t.Fatalf("mindmap payload=%#v output=%q err=%v", payload, output, err)
|
||||
}
|
||||
if caller.counts["minutes/create_mind_graph"] != 1 {
|
||||
t.Fatalf("mindmap create repeated: %#v", caller.counts)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesSpeakerInsightsRequiresTaskAndResultE2E(t *testing.T) {
|
||||
caller := &minutesE2ECaller{responses: map[string][]string{
|
||||
"minutes/create_speaker_summary": {`{"success":true,"result":{"taskId":"job-1","status":"processing"}}`},
|
||||
"minutes/get_speaker_summary": {
|
||||
`{"success":false,"errorMsg":"downstream query empty"}`,
|
||||
`{"success":true,"result":{"summaries":[{"speaker":"甲","summary":"结论"}]}}`,
|
||||
},
|
||||
}}
|
||||
payload, output, err := runMinutesAlignmentCLI(t, caller, "minutes", "+speaker-insights", "--id", "u1", "--timeout", "3", "--interval", "1", "--yes")
|
||||
if err != nil || output == "" || payload["complete"] != true || payload["taskId"] != "job-1" || payload["attempts"] != float64(2) {
|
||||
t.Fatalf("speaker insights payload=%#v output=%q err=%v", payload, output, err)
|
||||
}
|
||||
|
||||
missingTask := &minutesE2ECaller{responses: map[string][]string{"minutes/create_speaker_summary": {`{"success":true,"result":{"status":"processing"}}`}}}
|
||||
if payload, output, err := runMinutesAlignmentCLI(t, missingTask, "minutes", "+speaker-insights", "--id", "u1", "--timeout", "2", "--interval", "1", "--yes"); err == nil || output == "" || payload["complete"] != false {
|
||||
t.Fatalf("missing task accepted: payload=%#v output=%q err=%v", payload, output, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesPrepareASRDiffWritesAndReadbackE2E(t *testing.T) {
|
||||
caller := &minutesE2ECaller{responses: map[string][]string{
|
||||
"minutes/list_my_hotwords": {
|
||||
`{"success":true,"result":{"hotWordList":["已有"],"currentCount":1}}`,
|
||||
`{"success":true,"result":{"hotWordList":["已有","DWS"],"currentCount":2}}`,
|
||||
},
|
||||
"minutes/add_personal_hot_word": {`{"success":true,"result":{}}`},
|
||||
}}
|
||||
payload, _, err := runMinutesAlignmentCLI(t, caller, "minutes", "+prepare-asr", "--words", "已有,DWS", "--yes")
|
||||
if err != nil || payload["complete"] != true || payload["verified"] != true || caller.counts["minutes/add_personal_hot_word"] != 1 || caller.counts["minutes/delete_personal_hotword"] != 0 {
|
||||
t.Fatalf("prepare asr payload=%#v err=%v calls=%#v", payload, err, caller.counts)
|
||||
}
|
||||
|
||||
unknown := &minutesE2ECaller{responses: map[string][]string{"minutes/list_my_hotwords": {`{"success":true,"result":{}}`}}}
|
||||
if payload, output, err := runMinutesAlignmentCLI(t, unknown, "minutes", "+prepare-asr", "--words", "DWS", "--yes"); err == nil || payload != nil || output != "" {
|
||||
t.Fatalf("unknown hotword list accepted: payload=%#v output=%q err=%v", payload, output, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesRecordWrapUpStopSuccessArtifactFailureIsPartialE2E(t *testing.T) {
|
||||
caller := &minutesE2ECaller{responses: map[string][]string{
|
||||
"minutes/" + listeningNoteCmdTool: {`{"success":true,"result":{"cmd":"end","uuid":"u1"}}`},
|
||||
"minutes/get_minutes_ai_summary": {`{"success":true,"result":{}}`},
|
||||
}}
|
||||
payload, output, err := runMinutesAlignmentCLI(t, caller, "minutes", "+record-wrap-up", "--id", "u1", "--artifacts", "summary", "--wait-timeout", "1", "--poll-interval", "1", "--yes")
|
||||
if err == nil || output == "" || payload["complete"] != false || payload["taskUuid"] != "u1" || payload["recovery"] == nil || caller.counts["minutes/"+listeningNoteCmdTool] != 1 {
|
||||
t.Fatalf("wrap-up partial payload=%#v output=%q err=%v calls=%#v", payload, output, err, caller.counts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesExportPackPublishesOnlyCompleteArtifactsE2E(t *testing.T) {
|
||||
old, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
work := t.TempDir()
|
||||
if err := os.Chdir(work); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(old) })
|
||||
caller := &minutesE2ECaller{responses: map[string][]string{
|
||||
"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1","title":"E2E"}}`},
|
||||
"minutes/get_minutes_ai_summary": {`{"success":true,"result":{"fullSummary":"完整纪要"}}`},
|
||||
}}
|
||||
payload, _, err := runMinutesAlignmentCLI(t, caller, "minutes", "+export-pack", "--id", "u1", "--output", "pack", "--artifacts", "basic,summary")
|
||||
if err != nil || payload["complete"] != true || payload["published"] != true {
|
||||
t.Fatalf("export payload=%#v err=%v", payload, err)
|
||||
}
|
||||
manifestRaw, err := os.ReadFile(filepath.Join(work, "pack", "manifest.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bytes.Contains(manifestRaw, []byte("https://")) || !bytes.Contains(manifestRaw, []byte(`"complete": true`)) {
|
||||
t.Fatalf("manifest contains secret URL or is incomplete: %s", manifestRaw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesSharePartialWriteIsNonZeroE2E(t *testing.T) {
|
||||
caller := &minutesE2ECaller{
|
||||
responses: map[string][]string{"minutes/add_member_permission": {`{"success":true,"result":{}}`, `{"success":false,"errorMsg":"denied"}`}},
|
||||
}
|
||||
payload, output, err := runMinutesAlignmentCLI(t, caller, "minutes", "+share", "--id", "u1", "--member-uids", "m1,m2,m3", "--permission", "view", "--yes")
|
||||
if err == nil || output == "" || payload["complete"] != false || payload["succeeded"] != float64(1) || payload["failed"] != float64(1) || len(payload["unattempted"].([]any)) != 1 {
|
||||
t.Fatalf("share partial payload=%#v output=%q err=%v", payload, output, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesUploadAndAnalyzeDryRunDoesNotWriteE2E(t *testing.T) {
|
||||
file := filepath.Join(t.TempDir(), "source.wav")
|
||||
if err := os.WriteFile(file, []byte("non-empty-audio-fixture"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
caller := &minutesE2ECaller{}
|
||||
payload, _, err := runMinutesAlignmentCLI(t, caller, "minutes", "+upload-and-analyze", "--file", file, "--artifacts", "summary,transcript", "--mindmap", "--dry-run")
|
||||
if err != nil || payload["dryRun"] != true || payload["executed"] != false || len(caller.counts) != 0 {
|
||||
t.Fatalf("upload-and-analyze dry-run=%#v err=%v calls=%#v", payload, err, caller.counts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesArtifactWaitDoesNotTreatEmptyAnalysisAsReadyE2E(t *testing.T) {
|
||||
caller := &minutesE2ECaller{responses: map[string][]string{
|
||||
"minutes/get_minutes_transcription": {`{"success":true,"result":{"paragraphList":[],"hasNext":false}}`},
|
||||
}}
|
||||
// Exercise the same readiness collector used after upload/record stop. An
|
||||
// explicit [] is a valid transport shape, but cannot prove asynchronous ASR
|
||||
// has finished when the workflow promised transcript analysis.
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: "+export-pack"}, ExportPack)
|
||||
bundle, failures := collectMinutesArtifactsOnce(rt, "u1", []string{"transcript"}, 10)
|
||||
if len(failures) != 1 || bundle["transcript"] != nil {
|
||||
t.Fatalf("empty transcript accepted: bundle=%#v failures=%#v", bundle, failures)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package minutes
|
||||
|
||||
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/shortcut"
|
||||
)
|
||||
|
||||
func minutesContract(command, description, useWhen string, avoidWhen []string, examples []string) corecmd.ContractDecl {
|
||||
name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_")
|
||||
return corecmd.ContractDecl{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "minutes",
|
||||
Name: name,
|
||||
CanonicalPath: "minutes." + name,
|
||||
CLIPath: "minutes " + command,
|
||||
PrimaryCLIPath: "minutes " + command,
|
||||
},
|
||||
Description: description,
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: contract.InterfaceModeComposite,
|
||||
Availability: contract.InterfaceAvailable,
|
||||
Reason: "The executable Shortcut owns validation, orchestration, completeness and verification across one or more Minutes RPCs; no single RPC represents the final command contract.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: description,
|
||||
UseWhen: []string{useWhen},
|
||||
AvoidWhen: avoidWhen,
|
||||
Examples: examples,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func withMinutesDryRun(decl corecmd.ContractDecl, kind string, remoteReads bool) corecmd.ContractDecl {
|
||||
decl.DryRun = &contract.DryRunSpec{PreviewKind: kind, RemoteReads: remoteReads}
|
||||
return decl
|
||||
}
|
||||
|
||||
func minutesDryRunPayload(kind, operation string, payload map[string]any) map[string]any {
|
||||
if payload == nil {
|
||||
payload = map[string]any{}
|
||||
}
|
||||
payload["operation"] = operation
|
||||
payload["dry_run"] = true
|
||||
payload["dryRun"] = true
|
||||
payload["preview_kind"] = kind
|
||||
payload["executed"] = false
|
||||
return payload
|
||||
}
|
||||
|
||||
// finalizeMinutesShortcuts keeps the human Shortcut declaration and the final
|
||||
// Agent contract on one source of truth. Custom validation is prose-only in the
|
||||
// Schema wire, so publish its exact evidence on every affected flag as well.
|
||||
func finalizeMinutesShortcuts(values ...shortcut.Shortcut) []shortcut.Shortcut {
|
||||
finalized := make([]shortcut.Shortcut, len(values))
|
||||
for index, value := range values {
|
||||
value.Contract.Selection.AgentSummary = value.Description
|
||||
value.Contract.Selection.UseWhen = []string{value.Intent}
|
||||
for _, constraint := range value.Constraints {
|
||||
if constraint.Kind != shortcut.ConstraintCustom {
|
||||
continue
|
||||
}
|
||||
evidence := strings.TrimSpace(constraint.Description)
|
||||
if evidence == "" {
|
||||
continue
|
||||
}
|
||||
for flagIndex := range value.Flags {
|
||||
flag := &value.Flags[flagIndex]
|
||||
if !containsString(constraint.Flags, flag.Name) || strings.Contains(flag.Desc, evidence) {
|
||||
continue
|
||||
}
|
||||
flag.Desc = strings.TrimRight(flag.Desc, ";。 ") + ";约束:" + evidence
|
||||
}
|
||||
for parameterIndex := range value.Contract.Parameters {
|
||||
parameter := &value.Contract.Parameters[parameterIndex]
|
||||
if !containsString(constraint.Flags, parameter.Name) || strings.Contains(parameter.Description, evidence) {
|
||||
continue
|
||||
}
|
||||
parameter.Description = strings.TrimRight(parameter.Description, ";。 ") + ";约束:" + evidence
|
||||
}
|
||||
}
|
||||
finalized[index] = value
|
||||
}
|
||||
return finalized
|
||||
}
|
||||
|
||||
func containsString(values []string, target string) bool {
|
||||
for _, value := range values {
|
||||
if value == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -32,7 +32,10 @@ func TestCallListProjectItemListShape(t *testing.T) {
|
||||
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||
t.Fatalf("unmarshal fixture: %v", err)
|
||||
}
|
||||
got := callListProject(data)
|
||||
got, err := callListProject(data)
|
||||
if err != nil {
|
||||
t.Fatalf("project itemList: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("lower/upper mismatch: itemList has 2 entries, projection returned %d", len(got))
|
||||
}
|
||||
@@ -40,3 +43,20 @@ func TestCallListProjectItemListShape(t *testing.T) {
|
||||
t.Fatalf("taskUuid not projected from taskUuid/task_uuid keys: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesUnknownListIsNotEmptySuccessE2E(t *testing.T) {
|
||||
for _, raw := range []string{
|
||||
`{}`,
|
||||
`{"success":true}`,
|
||||
`{"success":true,"result":{"itemList":null}}`,
|
||||
`{"success":false,"errorMsg":"denied"}`,
|
||||
} {
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||
t.Fatalf("unmarshal fixture: %v", err)
|
||||
}
|
||||
if got, err := callListProject(data); err == nil || got != nil {
|
||||
t.Fatalf("unknown list response accepted: got=%#v err=%v raw=%s", got, err, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"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/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/minutesdata"
|
||||
)
|
||||
|
||||
// listeningNoteCmdTool is the gateway-registered name of the 听记指令 tool.
|
||||
@@ -173,7 +174,10 @@ func callList(rt *shortcut.RuntimeContext, belonging string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
minutes := callListProject(data)
|
||||
minutes, err := callListProject(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"count": len(minutes), "minutes": minutes})
|
||||
}
|
||||
|
||||
@@ -183,83 +187,12 @@ func callList(rt *shortcut.RuntimeContext, belonging string) error {
|
||||
// The list container and field names are probed defensively across candidate
|
||||
// keys so the projection tolerates response-shape drift; unknown keys are never
|
||||
// invented.
|
||||
func callListProject(data map[string]any) []map[string]any {
|
||||
raw := callListResolveList(data)
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
row := map[string]any{}
|
||||
// Only real taskUuid spellings map to taskUuid: the downstream
|
||||
// +record-pause/resume/stop commands feed this value straight into the
|
||||
// recording-control tool as the task uuid, so a minutesId (the minutes
|
||||
// document id, a different identifier) must not be substituted here or
|
||||
// record control would fail with a wrong id. The backend list already
|
||||
// returns taskUuid.
|
||||
if v, ok := callListFirst(m, "taskUuid", "task_uuid", "uuid"); ok {
|
||||
row["taskUuid"] = v
|
||||
}
|
||||
if v, ok := callListFirst(m, "title", "name"); ok {
|
||||
row["title"] = v
|
||||
}
|
||||
if v, ok := callListFirst(m, "creator", "creatorName", "createUserName", "creatorNick"); ok {
|
||||
row["creator"] = v
|
||||
}
|
||||
if v, ok := callListFirst(m, "startTime", "gmtStart", "beginTime"); ok {
|
||||
row["startTime"] = v
|
||||
}
|
||||
if v, ok := callListFirst(m, "endTime", "gmtEnd", "deadline"); ok {
|
||||
row["endTime"] = v
|
||||
}
|
||||
if v, ok := callListFirst(m, "url", "shareUrl", "link"); ok {
|
||||
row["url"] = v
|
||||
}
|
||||
if v, ok := callListFirst(m, "status", "taskStatus", "state"); ok {
|
||||
row["status"] = v
|
||||
}
|
||||
if len(row) > 0 {
|
||||
out = append(out, row)
|
||||
}
|
||||
func callListProject(data map[string]any) ([]map[string]any, error) {
|
||||
page, err := minutesdata.ParseListPage(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// callListResolveList locates the list payload inside the response, tolerating a
|
||||
// bare top-level array or nesting under result/data/list/items/records containers.
|
||||
func callListResolveList(data map[string]any) []any {
|
||||
// list_by_keyword_and_time_range nests the minutes under result.itemList;
|
||||
// "itemList" MUST be probed or +list-all/+list-mine/+list-shared silently
|
||||
// return empty despite the backend returning minutes.
|
||||
for _, key := range []string{"result", "data", "list", "items", "itemList", "records", "dataList"} {
|
||||
v, ok := data[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if arr, ok := v.([]any); ok {
|
||||
return arr
|
||||
}
|
||||
// container may itself wrap the list one level deeper
|
||||
if inner, ok := v.(map[string]any); ok {
|
||||
for _, ik := range []string{"list", "items", "itemList", "records", "dataList", "result", "data"} {
|
||||
if arr, ok := inner[ik].([]any); ok {
|
||||
return arr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return []any{}
|
||||
}
|
||||
|
||||
// callListFirst returns the first present candidate key's value.
|
||||
func callListFirst(m map[string]any, keys ...string) (any, bool) {
|
||||
for _, k := range keys {
|
||||
if v, ok := m[k]; ok {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
return minutesdata.ProjectList(page)
|
||||
}
|
||||
|
||||
// ── get ─────────────────────────────────────────────────────────────────────
|
||||
@@ -273,7 +206,7 @@ var RecordStart = shortcut.Shortcut{
|
||||
Command: "+record-start",
|
||||
Product: "minutes",
|
||||
Description: "发起听记(开始录音)",
|
||||
Intent: "当你要开始一场实时会议/通话的 AI 听记、立刻启动录音并生成一条新听记任务时使用;可选传入 AI 助理会话 ID,会真实发起录音,返回新建听记的 taskUuid 供后续暂停/恢复/结束。",
|
||||
Intent: "当你要开始一场实时会议/通话的 AI 听记、立刻启动录音时使用;可选传入 AI 助理会话 ID。当前网关 create 回执不返回 taskUuid,只能证明录音指令已被接受;随后需用 +latest/+search 定位新听记。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
@@ -295,7 +228,7 @@ var RecordStart = shortcut.Shortcut{
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "发起听记(开始录音)",
|
||||
UseWhen: []string{"当你要开始一场实时会议/通话的 AI 听记、立刻启动录音并生成一条新听记任务时使用;可选传入 AI 助理会话 ID,会真实发起录音,返回新建听记的 taskUuid 供后续暂停/恢复/结束。"},
|
||||
UseWhen: []string{"当你要开始一场实时会议/通话的 AI 听记、立刻启动录音时使用;网关 create 只返回已接受回执,不返回 taskUuid,随后需用 +latest/+search 定位新听记。"},
|
||||
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
|
||||
Examples: []string{"dws minutes +record-start --session-id <sessionId>"},
|
||||
},
|
||||
@@ -309,7 +242,7 @@ var RecordStart = shortcut.Shortcut{
|
||||
if rt.Changed("session-id") {
|
||||
params["sessionId"] = rt.Str("session-id")
|
||||
}
|
||||
return rt.CallMCP(listeningNoteCmdTool, params)
|
||||
return executeRecordCommand(rt, "create", "", params)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -320,6 +253,16 @@ var RecordPause = shortcut.Shortcut{
|
||||
Description: "暂停听记录音",
|
||||
Intent: "录音进行中想临时中断(如中场休息、切换话题)又不想结束整条听记时使用;传入正在录音的听记 taskUuid,会真实暂停该次录音,之后可用 +record-resume 继续。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: minutesContract(
|
||||
"+record-pause",
|
||||
"暂停听记录音",
|
||||
"实时听记仍在录制,但要临时中断且保留后续恢复能力时使用;必须传当前录制任务的 taskUuid。",
|
||||
[]string{"会议已经结束时使用 +record-stop;只是查看听记状态或内容时使用只读命令"},
|
||||
[]string{"dws minutes +record-pause --id <taskUuid>"},
|
||||
),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid", Required: true},
|
||||
{Name: "session-id", Type: shortcut.FlagString, Desc: "AI 助理会话 ID (可选)"},
|
||||
@@ -330,7 +273,7 @@ var RecordPause = shortcut.Shortcut{
|
||||
if rt.Changed("session-id") {
|
||||
params["sessionId"] = rt.Str("session-id")
|
||||
}
|
||||
return rt.CallMCP(listeningNoteCmdTool, params)
|
||||
return executeRecordCommand(rt, "pause", rt.Str("id"), params)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -341,6 +284,16 @@ var RecordResume = shortcut.Shortcut{
|
||||
Description: "恢复听记录音",
|
||||
Intent: "之前用 +record-pause 暂停过的听记,现在想接着录时使用;传入该听记 taskUuid,会真实恢复录音,继续追加到同一条听记中。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: minutesContract(
|
||||
"+record-resume",
|
||||
"恢复听记录音",
|
||||
"实时听记此前已暂停、现在要继续向同一 taskUuid 追加录音时使用。",
|
||||
[]string{"已经结束的听记不能恢复;要新建录制时使用 +record-start"},
|
||||
[]string{"dws minutes +record-resume --id <taskUuid>"},
|
||||
),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid", Required: true},
|
||||
{Name: "session-id", Type: shortcut.FlagString, Desc: "AI 助理会话 ID (可选)"},
|
||||
@@ -351,7 +304,7 @@ var RecordResume = shortcut.Shortcut{
|
||||
if rt.Changed("session-id") {
|
||||
params["sessionId"] = rt.Str("session-id")
|
||||
}
|
||||
return rt.CallMCP(listeningNoteCmdTool, params)
|
||||
return executeRecordCommand(rt, "resume", rt.Str("id"), params)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -362,6 +315,16 @@ var RecordStop = shortcut.Shortcut{
|
||||
Description: "结束听记录音",
|
||||
Intent: "会议开完、想彻底停止录音并让系统开始生成转写与 AI 纪要时使用;传入正在录音的听记 taskUuid,会真实结束该次录音,结束后无法再恢复到这条听记继续录。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: minutesContract(
|
||||
"+record-stop",
|
||||
"结束听记录音",
|
||||
"会议结束且要永久停止指定 taskUuid 的实时录制,让服务进入转写和纪要处理阶段时使用。",
|
||||
[]string{"仍计划继续录音时使用 +record-pause;结束后不能通过 +record-resume 继续同一条录制"},
|
||||
[]string{"dws minutes +record-stop --id <taskUuid>"},
|
||||
),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid", Required: true},
|
||||
{Name: "session-id", Type: shortcut.FlagString, Desc: "AI 助理会话 ID (可选)"},
|
||||
@@ -372,10 +335,27 @@ var RecordStop = shortcut.Shortcut{
|
||||
if rt.Changed("session-id") {
|
||||
params["sessionId"] = rt.Str("session-id")
|
||||
}
|
||||
return rt.CallMCP(listeningNoteCmdTool, params)
|
||||
return executeRecordCommand(rt, "end", rt.Str("id"), params)
|
||||
},
|
||||
}
|
||||
|
||||
func executeRecordCommand(rt *shortcut.RuntimeContext, expectedCmd, taskUUID string, params map[string]any) error {
|
||||
data, err := rt.CallMCPWriteDataStrict("minutes", listeningNoteCmdTool, params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := minutesdata.RecordResult(expectedCmd, taskUUID, data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{
|
||||
"accepted": true,
|
||||
"command": expectedCmd,
|
||||
"taskUuid": taskUUID,
|
||||
"result": result,
|
||||
})
|
||||
}
|
||||
|
||||
// ── mind-graph ──────────────────────────────────────────────────────────────
|
||||
|
||||
// ── speaker ─────────────────────────────────────────────────────────────────
|
||||
@@ -391,7 +371,7 @@ var RecordStop = shortcut.Shortcut{
|
||||
// ── tag ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
func init() {
|
||||
shortcut.Register(
|
||||
shortcut.Register(finalizeMinutesShortcuts(
|
||||
ListMine,
|
||||
ListShared,
|
||||
ListAll,
|
||||
@@ -399,5 +379,5 @@ func init() {
|
||||
RecordPause,
|
||||
RecordResume,
|
||||
RecordStop,
|
||||
)
|
||||
)...)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,836 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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/localio"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/minutesdata"
|
||||
)
|
||||
|
||||
var minutesWorkflowArtifacts = []string{"basic", "summary", "keywords", "transcript", "todos"}
|
||||
|
||||
var (
|
||||
minutesMkdirTemp = os.MkdirTemp
|
||||
minutesRemoveAll = os.RemoveAll
|
||||
minutesWriteFile = os.WriteFile
|
||||
minutesStat = os.Stat
|
||||
minutesRename = os.Rename
|
||||
minutesGetwd = os.Getwd
|
||||
minutesEvalSymlinks = filepath.EvalSymlinks
|
||||
minutesRel = filepath.Rel
|
||||
minutesLstat = os.Lstat
|
||||
minutesMkdir = os.Mkdir
|
||||
minutesMarshalIndent = json.MarshalIndent
|
||||
)
|
||||
|
||||
var RecordWrapUp = shortcut.Shortcut{
|
||||
Service: "minutes", Command: "+record-wrap-up", Product: "minutes",
|
||||
Description: "停止实时录音并有界等待听记产物,失败时保留恢复句柄",
|
||||
Intent: "会议结束时一次完成 stop 与 basic/summary/keywords/transcript/todos 读取;停止已生效但产物未就绪时返回非零 partial 结果和 taskUuid,不会诱导重复 stop。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
|
||||
Contract: withMinutesDryRun(minutesContract("+record-wrap-up", "停止实时录音并有界等待听记产物,失败时保留恢复句柄",
|
||||
"已知正在录制的 taskUuid,会议结束后要停止录制并立即收口转写、纪要、关键词和行动项时使用",
|
||||
[]string{"仍要继续录音时使用 +record-pause;只停止、不等待产物时使用 +record-stop"},
|
||||
[]string{`dws minutes +record-wrap-up --id <taskUuid>`, `dws minutes +record-wrap-up --id <taskUuid> --artifacts transcript,summary --wait-timeout 120`}), contract.DryRunPreviewPlan, false),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "id", Type: shortcut.FlagString, Desc: "正在录制的听记 taskUuid", Required: true},
|
||||
{Name: "artifacts", Type: shortcut.FlagStringSlice, Desc: "停止后等待的产物", Enum: minutesWorkflowArtifacts},
|
||||
{Name: "wait-timeout", Type: shortcut.FlagInt, Default: "90", Desc: "等待产物秒数"},
|
||||
{Name: "poll-interval", Type: shortcut.FlagInt, Default: "3", Desc: "轮询间隔秒数"},
|
||||
{Name: "page-limit", Type: shortcut.FlagInt, Default: "100", Desc: "逐字稿翻页上限"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"wait-timeout", "poll-interval", "page-limit"}, Description: "超时、轮询间隔和逐字稿页数上限必须大于 0"}},
|
||||
Tips: []string{`dws minutes +record-wrap-up --id <taskUuid>`, `dws minutes +record-wrap-up --id <taskUuid> --artifacts transcript,summary --wait-timeout 120`},
|
||||
Validate: validateMinutesWorkflowWait,
|
||||
Execute: executeMinutesRecordWrapUp,
|
||||
}
|
||||
|
||||
var UploadAndAnalyze = shortcut.Shortcut{
|
||||
Service: "minutes", Command: "+upload-and-analyze", Product: "minutes",
|
||||
Description: "本地音视频直传听记并等待分析产物,可选思维导图和发言人洞察",
|
||||
Intent: "从本地媒体一次完成 upload create/PUT/complete/read-back,再等待选定听记产物;后续分析失败仍返回已创建 taskUuid 和恢复动作。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
|
||||
Contract: withMinutesDryRun(minutesContract("+upload-and-analyze", "本地音视频直传听记并等待分析产物,可选思维导图和发言人洞察",
|
||||
"有本地音视频且希望一次创建听记、等待基础分析,并可继续生成思维导图或发言人洞察时使用",
|
||||
[]string{"只需上传时使用 +upload;已有 taskUuid 时分别使用 +detail/+mindmap/+speaker-insights"},
|
||||
[]string{`dws minutes +upload-and-analyze --file ./meeting.mp3 --title "项目周会"`, `dws minutes +upload-and-analyze --file ./meeting.mp4 --artifacts transcript,summary --mindmap`}), contract.DryRunPreviewPlan, false),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "file", Type: shortcut.FlagString, Desc: "本地音视频文件;与 --resume-id 二选一"},
|
||||
{Name: "resume-id", Type: shortcut.FlagString, Desc: "先前已成功上传的 taskUuid;只恢复分析、不重复上传"},
|
||||
{Name: "title", Type: shortcut.FlagString, Desc: "听记标题"},
|
||||
{Name: "template-id", Type: shortcut.FlagString, Desc: "纪要模板 ID"},
|
||||
{Name: "input-language", Type: shortcut.FlagString, Desc: "ASR 输入语言"},
|
||||
{Name: "enable-message-card", Type: shortcut.FlagBool, Desc: "生成后推送闪记卡片"},
|
||||
{Name: "complete-timeout", Type: shortcut.FlagInt, Default: "90", Desc: "上传 complete 超时秒数"},
|
||||
{Name: "poll-interval", Type: shortcut.FlagInt, Default: "3", Desc: "轮询间隔秒数"},
|
||||
{Name: "wait-timeout", Type: shortcut.FlagInt, Default: "180", Desc: "等待分析产物秒数"},
|
||||
{Name: "artifacts", Type: shortcut.FlagStringSlice, Desc: "等待的分析产物", Enum: minutesWorkflowArtifacts},
|
||||
{Name: "page-limit", Type: shortcut.FlagInt, Default: "100", Desc: "逐字稿翻页上限"},
|
||||
{Name: "mindmap", Type: shortcut.FlagBool, Desc: "继续创建并等待思维导图"},
|
||||
{Name: "speaker-insights", Type: shortcut.FlagBool, Desc: "继续创建并等待发言人总结"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"file", "resume-id"}},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"complete-timeout", "wait-timeout", "poll-interval", "page-limit"}, Description: "上传超时、分析超时、轮询间隔和逐字稿页数上限必须大于 0"},
|
||||
},
|
||||
Tips: []string{`dws minutes +upload-and-analyze --file ./meeting.mp3 --title "项目周会"`, `dws minutes +upload-and-analyze --file ./meeting.mp4 --artifacts transcript,summary --mindmap`},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
if rt.Int("complete-timeout") <= 0 {
|
||||
return apperrors.NewValidation("--complete-timeout 必须大于 0")
|
||||
}
|
||||
return validateMinutesWorkflowWait(rt)
|
||||
},
|
||||
Execute: executeMinutesUploadAndAnalyze,
|
||||
}
|
||||
|
||||
var Mindmap = shortcut.Shortcut{
|
||||
Service: "minutes", Command: "+mindmap", Product: "minutes",
|
||||
Description: "创建听记思维导图并轮询到明确成功、失败或超时",
|
||||
Intent: "已有 taskUuid 且需要思维导图时使用;创建只执行一次,随后只轮询 taskStatus,失败/超时均返回非零和可继续查询的 taskUuid。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
|
||||
Contract: withMinutesDryRun(minutesContract("+mindmap", "创建听记思维导图并轮询到明确成功、失败或超时",
|
||||
"听记内容已就绪,需要触发并等待平台思维导图产物时使用",
|
||||
[]string{"只查询已经触发的任务状态时使用原子 mind-graph status;短音频或无有效内容可能明确失败"},
|
||||
[]string{`dws minutes +mindmap --id <taskUuid>`, `dws minutes +mindmap --id <taskUuid> --timeout 120 --interval 3`}), contract.DryRunPreviewPlan, false),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid", Required: true},
|
||||
{Name: "timeout", Type: shortcut.FlagInt, Default: "90", Desc: "等待秒数"},
|
||||
{Name: "interval", Type: shortcut.FlagInt, Default: "3", Desc: "轮询间隔秒数"},
|
||||
{Name: "resume", Type: shortcut.FlagBool, Desc: "只继续轮询,不重复创建任务"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"timeout", "interval"}, Description: "--timeout 和 --interval 必须大于 0"}},
|
||||
Tips: []string{`dws minutes +mindmap --id <taskUuid>`, `dws minutes +mindmap --id <taskUuid> --timeout 120 --interval 3`},
|
||||
Validate: validateMinutesPolling,
|
||||
Execute: executeMinutesMindmap,
|
||||
}
|
||||
|
||||
var SpeakerInsights = shortcut.Shortcut{
|
||||
Service: "minutes", Command: "+speaker-insights", Product: "minutes",
|
||||
Description: "创建发言人段落总结并轮询结果,保留异步任务恢复句柄",
|
||||
Intent: "需要按发言人汇总听记内容时使用;严格要求 create 返回 taskId,读取未就绪时有界重试,失败或超时返回 taskId/taskUuid。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
|
||||
Contract: withMinutesDryRun(minutesContract("+speaker-insights", "创建发言人段落总结并轮询结果,保留异步任务恢复句柄",
|
||||
"逐字稿已有多位发言人,需要触发并读取平台发言人段落总结时使用",
|
||||
[]string{"只改发言人昵称时使用 +speaker-replace;无有效发言内容时平台可能不生成结果"},
|
||||
[]string{`dws minutes +speaker-insights --id <taskUuid>`, `dws minutes +speaker-insights --id <taskUuid> --timeout 180`}), contract.DryRunPreviewPlan, false),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid", Required: true},
|
||||
{Name: "timeout", Type: shortcut.FlagInt, Default: "180", Desc: "等待秒数"},
|
||||
{Name: "interval", Type: shortcut.FlagInt, Default: "3", Desc: "轮询间隔秒数"},
|
||||
{Name: "resume", Type: shortcut.FlagBool, Desc: "只继续轮询,不重复创建任务"},
|
||||
{Name: "task-id", Type: shortcut.FlagString, Desc: "先前 create 返回的异步 taskId,恢复时用于输出追踪"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"timeout", "interval"}, Description: "--timeout 和 --interval 必须大于 0"}},
|
||||
Tips: []string{`dws minutes +speaker-insights --id <taskUuid>`, `dws minutes +speaker-insights --id <taskUuid> --timeout 180`},
|
||||
Validate: validateMinutesPolling,
|
||||
Execute: executeMinutesSpeakerInsights,
|
||||
}
|
||||
|
||||
var PrepareASR = shortcut.Shortcut{
|
||||
Service: "minutes", Command: "+prepare-asr", Product: "minutes",
|
||||
Description: "读取个人热词、计算差异、按需新增并可显式同步删除后读回验证",
|
||||
Intent: "录音/上传前准备 ASR 专有词表;默认只增加缺失热词,只有 --sync 才删除未出现在目标集合中的现有热词。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "idempotent"},
|
||||
Contract: withMinutesDryRun(minutesContract("+prepare-asr", "按需新增个人热词并可显式同步删除后读回验证;dry-run 不访问远端",
|
||||
"录音或上传前要补充人名、项目名等 ASR 热词,并希望看到差异和最终读回验证时使用",
|
||||
[]string{"只查看现有热词时使用原子 hot-word list;不明确接受删除时不要使用 --sync"},
|
||||
[]string{`dws minutes +prepare-asr --words "DWS,听记"`, `dws minutes +prepare-asr --words "DWS,听记" --sync`}), contract.DryRunPreviewPlan, false),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "words", Type: shortcut.FlagStringSlice, Desc: "目标热词,逗号分隔", Required: true},
|
||||
{Name: "sync", Type: shortcut.FlagBool, Desc: "删除目标集合之外的现有热词"},
|
||||
},
|
||||
Tips: []string{`dws minutes +prepare-asr --words "DWS,听记"`, `dws minutes +prepare-asr --words "DWS,听记" --sync`},
|
||||
Execute: executeMinutesPrepareASR,
|
||||
}
|
||||
|
||||
var ExportPack = shortcut.Shortcut{
|
||||
Service: "minutes", Command: "+export-pack", Product: "minutes",
|
||||
Description: "把完整听记产物写入受控目录并生成不含签名 URL 的 manifest",
|
||||
Intent: "需要离线归档 basic/summary/keywords/transcript/todos,可选媒体文件时使用;全部必需产物验证通过后才原子发布目录。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
|
||||
Contract: minutesContract("+export-pack", "把完整听记产物写入受控目录并生成不含签名 URL 的 manifest",
|
||||
"已知 taskUuid,需要把多个已验证产物和完整性 manifest 安全归档到工作目录时使用",
|
||||
[]string{"只下载媒体时使用 +download;目标目录已存在时本命令拒绝覆盖"},
|
||||
[]string{`dws minutes +export-pack --id <taskUuid> --output ./minutes-export`, `dws minutes +export-pack --id <taskUuid> --output ./minutes-export --include-media`}),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid", Required: true},
|
||||
{Name: "output", Type: shortcut.FlagString, Desc: "工作目录内的新归档目录", Required: true},
|
||||
{Name: "artifacts", Type: shortcut.FlagStringSlice, Desc: "要导出的产物", Enum: minutesWorkflowArtifacts},
|
||||
{Name: "include-media", Type: shortcut.FlagBool, Desc: "同时下载音视频"},
|
||||
{Name: "page-limit", Type: shortcut.FlagInt, Default: "100", Desc: "逐字稿翻页上限"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"output"}, Description: "输出必须是安全的工作目录相对路径"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-limit"}, Description: "--page-limit 必须大于 0"},
|
||||
},
|
||||
Tips: []string{`dws minutes +export-pack --id <taskUuid> --output ./minutes-export`, `dws minutes +export-pack --id <taskUuid> --output ./minutes-export --include-media`},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
if rt.Int("page-limit") <= 0 {
|
||||
return apperrors.NewValidation("--page-limit 必须大于 0")
|
||||
}
|
||||
return localio.ValidateOutput(rt.Str("output"))
|
||||
},
|
||||
Execute: executeMinutesExportPack,
|
||||
}
|
||||
|
||||
var Share = shortcut.Shortcut{
|
||||
Service: "minutes", Command: "+share", Product: "minutes",
|
||||
Description: "按成员逐项授予一个或多个听记权限,输出可审计的部分写入 ledger",
|
||||
Intent: "所有者已确认成员钉钉 UID 和权限,需批量授权时使用;逐成员调用以区分成功/失败,默认首错停止。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
|
||||
Contract: withMinutesDryRun(minutesContract("+share", "按成员逐项授予一个或多个听记权限,输出可审计的部分写入 ledger",
|
||||
"听记所有者已确认稳定 member UID,需要授予 view/download/edit 权限并审计每个成员结果时使用",
|
||||
[]string{"当前用户自己申请权限时使用 +apply-permission;只有姓名而无稳定 UID 时先用 contact 命令消歧"},
|
||||
[]string{`dws minutes +share --ids <uuid1,uuid2> --member-uids <uid1,uid2> --permission view`, `dws minutes +share --id <uuid> --member-uids <uid> --permission edit --cover`}), contract.DryRunPreviewPlan, false),
|
||||
Flags: minutesShareFlags(true),
|
||||
Constraints: []shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"id", "ids"}},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"id", "ids"}, Description: "听记去重后必须为 1..50 个"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"member-uids"}, Description: "成员 UID 去重后必须为 1..50 个"},
|
||||
},
|
||||
Tips: []string{`dws minutes +share --ids <uuid1,uuid2> --member-uids <uid1,uid2> --permission view`, `dws minutes +share --id <uuid> --member-uids <uid> --permission edit --cover`},
|
||||
Validate: validateMinutesShare,
|
||||
Execute: executeMinutesShare,
|
||||
}
|
||||
|
||||
var Unshare = shortcut.Shortcut{
|
||||
Service: "minutes", Command: "+unshare", Product: "minutes",
|
||||
Description: "按成员逐项移除一个或多个听记权限,输出可审计的部分写入 ledger",
|
||||
Intent: "所有者明确要撤销稳定成员 UID 的听记访问时使用;默认首错停止,任何部分失败都返回非零。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
|
||||
Contract: withMinutesDryRun(minutesContract("+unshare", "按成员逐项移除一个或多个听记权限,输出可审计的部分写入 ledger",
|
||||
"听记所有者已确认稳定 member UID,需要撤销其对一个或多个听记的访问权限时使用",
|
||||
[]string{"要授权时使用 +share;成员或听记 ID 未确认时不要撤销"},
|
||||
[]string{`dws minutes +unshare --ids <uuid1,uuid2> --member-uids <uid1,uid2>`, `dws minutes +unshare --id <uuid> --member-uids <uid> --failure-policy continue`}), contract.DryRunPreviewPlan, false),
|
||||
Flags: minutesShareFlags(false),
|
||||
Constraints: []shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"id", "ids"}},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"id", "ids"}, Description: "听记去重后必须为 1..50 个"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"member-uids"}, Description: "成员 UID 去重后必须为 1..50 个"},
|
||||
},
|
||||
Tips: []string{`dws minutes +unshare --ids <uuid1,uuid2> --member-uids <uid1,uid2>`, `dws minutes +unshare --id <uuid> --member-uids <uid> --failure-policy continue`},
|
||||
Validate: validateMinutesShare,
|
||||
Execute: executeMinutesUnshare,
|
||||
}
|
||||
|
||||
func validateMinutesWorkflowWait(rt *shortcut.RuntimeContext) error {
|
||||
if rt.Int("wait-timeout") <= 0 || rt.Int("poll-interval") <= 0 || rt.Int("page-limit") <= 0 {
|
||||
return apperrors.NewValidation("等待超时、轮询间隔和逐字稿页数上限必须大于 0")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateMinutesPolling(rt *shortcut.RuntimeContext) error {
|
||||
if rt.Int("timeout") <= 0 || rt.Int("interval") <= 0 {
|
||||
return apperrors.NewValidation("--timeout 和 --interval 必须大于 0")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func selectedWorkflowArtifacts(rt *shortcut.RuntimeContext) []string {
|
||||
selected := rt.StrSlice("artifacts")
|
||||
if len(selected) == 0 {
|
||||
return append([]string(nil), minutesWorkflowArtifacts...)
|
||||
}
|
||||
return uniqueStrings(selected)
|
||||
}
|
||||
|
||||
func executeMinutesRecordWrapUp(rt *shortcut.RuntimeContext) error {
|
||||
id := rt.Str("id")
|
||||
artifacts := selectedWorkflowArtifacts(rt)
|
||||
if rt.DryRun() {
|
||||
return rt.Output(minutesDryRunPayload(contract.DryRunPreviewPlan, "minutes.record_wrap_up", map[string]any{"taskUuid": id, "stages": []string{"record.stop", "artifacts.wait"}, "artifacts": artifacts}))
|
||||
}
|
||||
stopData, err := rt.CallMCPWriteDataStrict("minutes", listeningNoteCmdTool, map[string]any{"cmd": "end", "uuid": id})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := minutesdata.RecordResult("end", id, stopData); err != nil {
|
||||
return err
|
||||
}
|
||||
bundle, failures, attempts := waitMinutesArtifacts(rt, id, artifacts, rt.Int("page-limit"), time.Duration(rt.Int("wait-timeout"))*time.Second, time.Duration(rt.Int("poll-interval"))*time.Second)
|
||||
payload := map[string]any{
|
||||
"operation": "minutes.record_wrap_up", "complete": len(failures) == 0, "taskUuid": id,
|
||||
"stages": []map[string]any{{"name": "record.stop", "complete": true}, {"name": "artifacts.wait", "complete": len(failures) == 0, "attempts": attempts}},
|
||||
"artifacts": bundle, "failures": failures,
|
||||
}
|
||||
if len(failures) > 0 {
|
||||
payload["recovery"] = map[string]any{"taskUuid": id, "nextAction": "dws minutes +detail --id <taskUuid>"}
|
||||
}
|
||||
return outputWorkflowResult(rt, payload, len(failures) > 0, "minutes_record_wrap_up_incomplete", "artifacts")
|
||||
}
|
||||
|
||||
func executeMinutesUploadAndAnalyze(rt *shortcut.RuntimeContext) error {
|
||||
artifacts := selectedWorkflowArtifacts(rt)
|
||||
if rt.DryRun() {
|
||||
upload := map[string]any{"operation": "minutes.upload", "resume": rt.Str("resume-id") != "", "taskUuid": rt.Str("resume-id"), "executed": false}
|
||||
if rt.Str("resume-id") == "" {
|
||||
var err error
|
||||
upload, err = performMinutesUpload(rt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return rt.Output(minutesDryRunPayload(contract.DryRunPreviewPlan, "minutes.upload_and_analyze", map[string]any{"upload": upload, "artifacts": artifacts, "mindmap": rt.Bool("mindmap"), "speakerInsights": rt.Bool("speaker-insights")}))
|
||||
}
|
||||
id := strings.TrimSpace(rt.Str("resume-id"))
|
||||
upload := map[string]any{"operation": "minutes.upload", "complete": true, "resumed": true, "taskUuid": id, "verified": true}
|
||||
if id == "" {
|
||||
var err error
|
||||
upload, err = performMinutesUpload(rt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, _ = upload["taskUuid"].(string)
|
||||
}
|
||||
bundle, failures, attempts := waitMinutesArtifacts(rt, id, artifacts, rt.Int("page-limit"), time.Duration(rt.Int("wait-timeout"))*time.Second, time.Duration(rt.Int("poll-interval"))*time.Second)
|
||||
stages := []map[string]any{{"name": "upload", "complete": true, "result": upload}, {"name": "artifacts.wait", "complete": len(failures) == 0, "attempts": attempts}}
|
||||
if rt.Bool("mindmap") {
|
||||
result, runErr := runMinutesMindmap(rt, id, time.Duration(rt.Int("wait-timeout"))*time.Second, time.Duration(rt.Int("poll-interval"))*time.Second, true)
|
||||
stage := map[string]any{"name": "mindmap", "complete": runErr == nil, "result": result}
|
||||
if runErr != nil {
|
||||
stage["error"] = runErr.Error()
|
||||
failures = append(failures, map[string]any{"artifact": "mindmap", "error": runErr.Error()})
|
||||
}
|
||||
stages = append(stages, stage)
|
||||
}
|
||||
if rt.Bool("speaker-insights") {
|
||||
result, runErr := runMinutesSpeakerInsights(rt, id, time.Duration(rt.Int("wait-timeout"))*time.Second, time.Duration(rt.Int("poll-interval"))*time.Second, true, "")
|
||||
stage := map[string]any{"name": "speaker-insights", "complete": runErr == nil, "result": result}
|
||||
if runErr != nil {
|
||||
stage["error"] = runErr.Error()
|
||||
failures = append(failures, map[string]any{"artifact": "speaker-insights", "error": runErr.Error()})
|
||||
}
|
||||
stages = append(stages, stage)
|
||||
}
|
||||
payload := map[string]any{"operation": "minutes.upload_and_analyze", "complete": len(failures) == 0, "taskUuid": id, "stages": stages, "artifacts": bundle, "failures": failures}
|
||||
if len(failures) > 0 {
|
||||
payload["recovery"] = map[string]any{"taskUuid": id, "nextAction": "resume analysis with +detail/+mindmap/+speaker-insights; do not upload again"}
|
||||
}
|
||||
return outputWorkflowResult(rt, payload, len(failures) > 0, "minutes_upload_analysis_incomplete", "analysis")
|
||||
}
|
||||
|
||||
func executeMinutesMindmap(rt *shortcut.RuntimeContext) error {
|
||||
if rt.DryRun() {
|
||||
return rt.Output(minutesDryRunPayload(contract.DryRunPreviewPlan, "minutes.mindmap", map[string]any{"taskUuid": rt.Str("id"), "stages": []string{"create", "poll"}}))
|
||||
}
|
||||
payload, err := runMinutesMindmap(rt, rt.Str("id"), time.Duration(rt.Int("timeout"))*time.Second, time.Duration(rt.Int("interval"))*time.Second, !rt.Bool("resume"))
|
||||
if err := rt.Output(payload); err != nil {
|
||||
return err
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func runMinutesMindmap(rt *shortcut.RuntimeContext, id string, timeout, interval time.Duration, create bool) (map[string]any, error) {
|
||||
createAcknowledged := false
|
||||
if create {
|
||||
// create_mind_graph is observed to return either an explicit success
|
||||
// envelope or an empty acknowledgement. Empty alone is never success;
|
||||
// continue only so the status read-back can prove the final effect.
|
||||
created, err := rt.CallMCPWriteData("minutes", "create_mind_graph", map[string]any{"taskUuid": id})
|
||||
if err != nil {
|
||||
return map[string]any{"operation": "minutes.mindmap", "complete": false, "taskUuid": id, "stage": "create"}, err
|
||||
}
|
||||
createAcknowledged = minutesdata.RequireWriteAcknowledgement("mind graph create", created) == nil
|
||||
}
|
||||
deadline := time.Now().Add(timeout)
|
||||
attempts := 0
|
||||
for {
|
||||
attempts++
|
||||
data, callErr := rt.CallMCPData("minutes", "query_mind_graph_status", map[string]any{"taskUuid": id})
|
||||
if callErr != nil {
|
||||
return map[string]any{"operation": "minutes.mindmap", "complete": false, "taskUuid": id, "stage": "poll", "attempts": attempts, "recovery": map[string]any{"taskUuid": id, "nextAction": "dws minutes mind-graph status --id <taskUuid>"}}, callErr
|
||||
}
|
||||
status, result, parseErr := minutesdata.MindGraphStatus(data)
|
||||
if parseErr != nil {
|
||||
return map[string]any{"operation": "minutes.mindmap", "complete": false, "taskUuid": id, "stage": "poll", "attempts": attempts}, parseErr
|
||||
}
|
||||
if status == 1 {
|
||||
return map[string]any{"operation": "minutes.mindmap", "complete": true, "taskUuid": id, "taskStatus": status, "attempts": attempts, "createAttempted": create, "createAcknowledged": createAcknowledged, "verified": true, "result": result}, nil
|
||||
}
|
||||
if status == 2 {
|
||||
payload := map[string]any{"operation": "minutes.mindmap", "complete": false, "taskUuid": id, "taskStatus": status, "attempts": attempts, "result": result, "recovery": map[string]any{"taskUuid": id, "nextAction": "inspect source transcript; do not assume an empty mind map"}}
|
||||
return payload, minutesCompositeError("minutes_mindmap_failed", "poll", payload)
|
||||
}
|
||||
if time.Now().Add(interval).After(deadline) {
|
||||
payload := map[string]any{"operation": "minutes.mindmap", "complete": false, "taskUuid": id, "taskStatus": status, "attempts": attempts, "recovery": map[string]any{"taskUuid": id, "nextAction": "dws minutes mind-graph status --id <taskUuid>"}}
|
||||
return payload, minutesCompositeError("minutes_mindmap_timeout", "poll", payload)
|
||||
}
|
||||
if err := waitMinutesInterval(rt, interval); err != nil {
|
||||
return map[string]any{"operation": "minutes.mindmap", "complete": false, "taskUuid": id, "attempts": attempts}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func executeMinutesSpeakerInsights(rt *shortcut.RuntimeContext) error {
|
||||
if rt.DryRun() {
|
||||
return rt.Output(minutesDryRunPayload(contract.DryRunPreviewPlan, "minutes.speaker_insights", map[string]any{"taskUuid": rt.Str("id"), "stages": []string{"create", "poll"}}))
|
||||
}
|
||||
payload, err := runMinutesSpeakerInsights(rt, rt.Str("id"), time.Duration(rt.Int("timeout"))*time.Second, time.Duration(rt.Int("interval"))*time.Second, !rt.Bool("resume"), rt.Str("task-id"))
|
||||
if outputErr := rt.Output(payload); outputErr != nil {
|
||||
return outputErr
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func runMinutesSpeakerInsights(rt *shortcut.RuntimeContext, id string, timeout, interval time.Duration, create bool, taskID string) (map[string]any, error) {
|
||||
status := "resume"
|
||||
if create {
|
||||
created, err := rt.CallMCPWriteDataStrict("minutes", "create_speaker_summary", map[string]any{"uuids": []string{id}})
|
||||
if err != nil {
|
||||
return map[string]any{"operation": "minutes.speaker_insights", "complete": false, "taskUuid": id, "stage": "create"}, err
|
||||
}
|
||||
var parseErr error
|
||||
taskID, status, parseErr = minutesdata.SpeakerSummaryTask(created)
|
||||
if parseErr != nil {
|
||||
return map[string]any{"operation": "minutes.speaker_insights", "complete": false, "taskUuid": id, "stage": "create"}, parseErr
|
||||
}
|
||||
}
|
||||
deadline := time.Now().Add(timeout)
|
||||
attempts := 0
|
||||
for {
|
||||
attempts++
|
||||
data, callErr := rt.CallMCPData("minutes", "get_speaker_summary", map[string]any{"uuids": []string{id}})
|
||||
if callErr == nil {
|
||||
result, resultErr := minutesdata.SpeakerSummaryResult(data)
|
||||
if resultErr == nil {
|
||||
return map[string]any{"operation": "minutes.speaker_insights", "complete": true, "taskUuid": id, "taskId": taskID, "createStatus": status, "attempts": attempts, "result": result}, nil
|
||||
}
|
||||
callErr = resultErr
|
||||
}
|
||||
if !speakerSummaryPending(callErr) {
|
||||
payload := map[string]any{"operation": "minutes.speaker_insights", "complete": false, "taskUuid": id, "taskId": taskID, "attempts": attempts, "stage": "poll", "recovery": map[string]any{"taskUuid": id, "taskId": taskID, "nextAction": "dws minutes speaker summary get --ids <taskUuid>"}}
|
||||
return payload, callErr
|
||||
}
|
||||
if time.Now().Add(interval).After(deadline) {
|
||||
payload := map[string]any{"operation": "minutes.speaker_insights", "complete": false, "taskUuid": id, "taskId": taskID, "attempts": attempts, "stage": "poll", "recovery": map[string]any{"taskUuid": id, "taskId": taskID, "nextAction": "dws minutes speaker summary get --ids <taskUuid>"}}
|
||||
return payload, minutesCompositeError("minutes_speaker_insights_timeout", "poll", payload)
|
||||
}
|
||||
if err := waitMinutesInterval(rt, interval); err != nil {
|
||||
return map[string]any{"operation": "minutes.speaker_insights", "complete": false, "taskUuid": id, "taskId": taskID, "attempts": attempts}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func speakerSummaryPending(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
message := strings.ToLower(err.Error())
|
||||
return strings.Contains(message, "query empty") || strings.Contains(message, "processing") || strings.Contains(message, "not ready") || strings.Contains(message, "result is empty") || strings.Contains(message, "business error: code 000") || strings.Contains(message, "暂无")
|
||||
}
|
||||
|
||||
func executeMinutesPrepareASR(rt *shortcut.RuntimeContext) error {
|
||||
desired := uniqueStrings(rt.StrSlice("words"))
|
||||
if rt.DryRun() {
|
||||
return rt.Output(minutesDryRunPayload(contract.DryRunPreviewPlan, "minutes.prepare_asr", map[string]any{
|
||||
"desired": desired, "sync": rt.Bool("sync"), "remotePreconditions": []string{"read current hot words", "compute additions and deletions", "apply writes", "read back exact set"},
|
||||
}))
|
||||
}
|
||||
currentData, err := rt.CallMCPData("minutes", "list_my_hotwords", map[string]any{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
current, err := minutesdata.HotWords(currentData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toAdd := stringDifference(desired, current)
|
||||
toDelete := []string{}
|
||||
if rt.Bool("sync") {
|
||||
toDelete = stringDifference(current, desired)
|
||||
}
|
||||
plan := map[string]any{"operation": "minutes.prepare_asr", "before": current, "desired": desired, "add": toAdd, "delete": toDelete, "sync": rt.Bool("sync")}
|
||||
stages := []map[string]any{}
|
||||
if len(toAdd) > 0 {
|
||||
data, writeErr := rt.CallMCPWriteDataStrict("minutes", "add_personal_hot_word", map[string]any{"hotWordList": toAdd})
|
||||
if writeErr == nil {
|
||||
writeErr = minutesdata.RequireWriteAcknowledgement("hot-word add", data)
|
||||
}
|
||||
stages = append(stages, map[string]any{"name": "add", "complete": writeErr == nil, "words": toAdd})
|
||||
if writeErr != nil {
|
||||
plan["complete"], plan["stages"], plan["recovery"] = false, stages, map[string]any{"nextAction": "review current hot words before retrying"}
|
||||
return outputWorkflowResult(rt, plan, true, "minutes_prepare_asr_add_failed", "add")
|
||||
}
|
||||
}
|
||||
if len(toDelete) > 0 {
|
||||
data, writeErr := rt.CallMCPWriteDataStrict("minutes", "delete_personal_hotword", map[string]any{"hotWordList": toDelete})
|
||||
if writeErr == nil {
|
||||
writeErr = minutesdata.RequireWriteAcknowledgement("hot-word delete", data)
|
||||
}
|
||||
stages = append(stages, map[string]any{"name": "delete", "complete": writeErr == nil, "words": toDelete})
|
||||
if writeErr != nil {
|
||||
plan["complete"], plan["stages"], plan["recovery"] = false, stages, map[string]any{"nextAction": "list hot words; additions may already be applied"}
|
||||
return outputWorkflowResult(rt, plan, true, "minutes_prepare_asr_delete_failed", "delete")
|
||||
}
|
||||
}
|
||||
verifiedData, err := rt.CallMCPData("minutes", "list_my_hotwords", map[string]any{})
|
||||
if err != nil {
|
||||
plan["complete"], plan["stages"] = false, stages
|
||||
if outputErr := rt.Output(plan); outputErr != nil {
|
||||
return outputErr
|
||||
}
|
||||
return err
|
||||
}
|
||||
verified, err := minutesdata.HotWords(verifiedData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
missing := stringDifference(desired, verified)
|
||||
unexpected := []string{}
|
||||
if rt.Bool("sync") {
|
||||
unexpected = stringDifference(verified, desired)
|
||||
}
|
||||
plan["complete"], plan["verified"], plan["after"], plan["stages"] = len(missing) == 0 && len(unexpected) == 0, len(missing) == 0 && len(unexpected) == 0, verified, stages
|
||||
if len(missing) > 0 || len(unexpected) > 0 {
|
||||
plan["missing"], plan["unexpected"] = missing, unexpected
|
||||
return outputWorkflowResult(rt, plan, true, "minutes_prepare_asr_verification_failed", "verify")
|
||||
}
|
||||
return rt.Output(plan)
|
||||
}
|
||||
|
||||
func executeMinutesExportPack(rt *shortcut.RuntimeContext) error {
|
||||
id := rt.Str("id")
|
||||
artifacts := selectedWorkflowArtifacts(rt)
|
||||
bundle, failures := collectMinutesArtifactsOnce(rt, id, artifacts, rt.Int("page-limit"))
|
||||
if len(failures) > 0 {
|
||||
payload := map[string]any{"operation": "minutes.export_pack", "complete": false, "taskUuid": id, "failures": failures, "published": false}
|
||||
return outputWorkflowResult(rt, payload, true, "minutes_export_pack_incomplete", "collect")
|
||||
}
|
||||
target, relative, parent, err := prepareExportTarget(rt.Str("output"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tempDir, err := minutesMkdirTemp(parent, ".dws-minutes-export-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cleanup := true
|
||||
defer func() {
|
||||
if cleanup {
|
||||
_ = minutesRemoveAll(tempDir)
|
||||
}
|
||||
}()
|
||||
files := map[string]map[string]any{}
|
||||
for _, name := range artifacts {
|
||||
filename := name + ".json"
|
||||
value := bundle[name]
|
||||
if name == "summary" {
|
||||
filename = "summary.md"
|
||||
if err := minutesWriteFile(filepath.Join(tempDir, filename), []byte(value.(string)), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err := writeJSONFile(filepath.Join(tempDir, filename), value); err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := minutesStat(filepath.Join(tempDir, filename))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
files[name] = map[string]any{"file": filename, "sizeBytes": info.Size(), "complete": true}
|
||||
}
|
||||
if rt.Bool("include-media") {
|
||||
mediaData, callErr := rt.CallMCPData("minutes", "query_minutes_audio_url", map[string]any{"taskUuid": id})
|
||||
if callErr != nil {
|
||||
return callErr
|
||||
}
|
||||
mediaURL, parseErr := minutesdata.MediaURL(mediaData)
|
||||
if parseErr != nil {
|
||||
return parseErr
|
||||
}
|
||||
download, downloadErr := minutesDownload(rt.Command().Context(), mediaURL, localio.DownloadOptions{BaseDir: tempDir, Output: "media/", PreferredName: id + mediaExtension(mediaURL)})
|
||||
if downloadErr != nil {
|
||||
return downloadErr
|
||||
}
|
||||
files["media"] = map[string]any{"file": download.RelativePath, "sizeBytes": download.SizeBytes, "complete": true}
|
||||
}
|
||||
manifest := map[string]any{"version": 1, "operation": "minutes.export_pack", "taskUuid": id, "complete": true, "generatedAt": time.Now().UTC().Format(time.RFC3339), "files": files}
|
||||
if err := writeJSONFile(filepath.Join(tempDir, "manifest.json"), manifest); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := minutesRename(tempDir, target); err != nil {
|
||||
return fmt.Errorf("发布听记归档目录失败: %w", err)
|
||||
}
|
||||
cleanup = false
|
||||
return rt.Output(map[string]any{"operation": "minutes.export_pack", "complete": true, "taskUuid": id, "published": true, "path": filepath.ToSlash(relative), "manifest": filepath.ToSlash(filepath.Join(relative, "manifest.json")), "files": files})
|
||||
}
|
||||
|
||||
func minutesShareFlags(includePermission bool) []shortcut.Flag {
|
||||
flags := []shortcut.Flag{
|
||||
{Name: "id", Type: shortcut.FlagString, Desc: "单个听记 taskUuid"},
|
||||
{Name: "ids", Type: shortcut.FlagStringSlice, Desc: "多个听记 taskUuid,最多 50 个"},
|
||||
{Name: "member-uids", Type: shortcut.FlagStringSlice, Desc: "稳定成员钉钉 UID,最多 50 个", Required: true},
|
||||
}
|
||||
if includePermission {
|
||||
flags = append(flags,
|
||||
shortcut.Flag{Name: "permission", Type: shortcut.FlagString, Desc: "授予权限", Required: true, Enum: []string{"view", "download", "edit"}},
|
||||
shortcut.Flag{Name: "cover", Type: shortcut.FlagBool, Desc: "覆盖已有权限"},
|
||||
shortcut.Flag{Name: "sub-resources", Type: shortcut.FlagStringSlice, Desc: "可选子资源", Enum: []string{"OrigContent", "Summary", "Analysis", "Note"}},
|
||||
)
|
||||
}
|
||||
return append(flags, shortcut.Flag{Name: "failure-policy", Type: shortcut.FlagString, Default: "stop", Desc: "成员失败策略", Enum: []string{"stop", "continue"}})
|
||||
}
|
||||
|
||||
func validateMinutesShare(rt *shortcut.RuntimeContext) error {
|
||||
ids, members := minutesIDs(rt), uniqueStrings(rt.StrSlice("member-uids"))
|
||||
if len(ids) == 0 || len(ids) > 50 || len(members) == 0 || len(members) > 50 {
|
||||
return apperrors.NewValidation("听记和成员 UID 数量必须各为 1..50")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeMinutesShare(rt *shortcut.RuntimeContext) error {
|
||||
policy := map[string]float64{"edit": 2, "download": 3, "view": 4}[rt.Str("permission")]
|
||||
return executeMinutesPermissionLedger(rt, "share", "add_member_permission", func(member string) map[string]any {
|
||||
params := map[string]any{"uuids": minutesIDs(rt), "memberUids": []string{member}, "policyId": policy}
|
||||
if rt.Changed("cover") {
|
||||
params["coverPermission"] = fmt.Sprintf("%t", rt.Bool("cover"))
|
||||
}
|
||||
if values := rt.StrSlice("sub-resources"); len(values) > 0 {
|
||||
params["roleSubResourceIds"] = values
|
||||
}
|
||||
return params
|
||||
})
|
||||
}
|
||||
|
||||
func executeMinutesUnshare(rt *shortcut.RuntimeContext) error {
|
||||
return executeMinutesPermissionLedger(rt, "unshare", "remove_member_permission", func(member string) map[string]any {
|
||||
return map[string]any{"uuids": minutesIDs(rt), "memberUids": []string{member}}
|
||||
})
|
||||
}
|
||||
|
||||
func executeMinutesPermissionLedger(rt *shortcut.RuntimeContext, operation, tool string, params func(string) map[string]any) error {
|
||||
members := uniqueStrings(rt.StrSlice("member-uids"))
|
||||
plan := map[string]any{"operation": "minutes." + operation, "taskUuids": minutesIDs(rt), "memberCount": len(members), "members": members}
|
||||
if rt.DryRun() {
|
||||
return rt.Output(minutesDryRunPayload(contract.DryRunPreviewPlan, "minutes."+operation, plan))
|
||||
}
|
||||
results := []map[string]any{}
|
||||
failures := []map[string]any{}
|
||||
unattempted := []string{}
|
||||
for index, member := range members {
|
||||
data, err := rt.CallMCPWriteDataStrict("minutes", tool, params(member))
|
||||
if err == nil {
|
||||
err = minutesdata.RequireWriteAcknowledgement(operation, data)
|
||||
}
|
||||
if err != nil {
|
||||
failures = append(failures, map[string]any{"memberUid": member, "error": err.Error()})
|
||||
if rt.Str("failure-policy") == "stop" {
|
||||
unattempted = append(unattempted, members[index+1:]...)
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
results = append(results, map[string]any{"memberUid": member, "complete": true})
|
||||
}
|
||||
plan["complete"], plan["succeeded"], plan["failed"], plan["unattempted"], plan["results"], plan["failures"] = len(failures) == 0, len(results), len(failures), unattempted, results, failures
|
||||
return outputWorkflowResult(rt, plan, len(failures) > 0, "minutes_permission_partial", operation)
|
||||
}
|
||||
|
||||
func collectMinutesArtifactsOnce(rt *shortcut.RuntimeContext, id string, artifacts []string, pageLimit int) (map[string]any, []map[string]any) {
|
||||
bundle := map[string]any{}
|
||||
failures := []map[string]any{}
|
||||
for _, artifact := range artifacts {
|
||||
var value any
|
||||
var err error
|
||||
switch artifact {
|
||||
case "basic":
|
||||
var data map[string]any
|
||||
data, err = rt.CallMCPData("minutes", "get_minutes_basic_info", map[string]any{"taskUuid": id})
|
||||
if err == nil {
|
||||
value, err = minutesdata.Basic(id, data)
|
||||
}
|
||||
case "summary":
|
||||
var data map[string]any
|
||||
data, err = rt.CallMCPData("minutes", "get_minutes_ai_summary", map[string]any{"taskUuid": id})
|
||||
if err == nil {
|
||||
value, err = minutesdata.SummaryText(data)
|
||||
if err == nil && strings.TrimSpace(value.(string)) == "" {
|
||||
err = fmt.Errorf("minutes summary is explicitly empty; analysis readiness is not proven")
|
||||
}
|
||||
}
|
||||
case "keywords":
|
||||
var data map[string]any
|
||||
data, err = rt.CallMCPData("minutes", "get_minutes_keywords", map[string]any{"taskUuid": id})
|
||||
if err == nil {
|
||||
err = minutesdata.ValidateArtifact("keywords", id, data)
|
||||
value = data["result"]
|
||||
}
|
||||
case "transcript":
|
||||
var transcript minutesdata.TranscriptResult
|
||||
transcript, err = collectTranscriptForMinutes(rt, id, pageLimit)
|
||||
value = minutesdata.TranscriptPayload(id, "0", transcript)
|
||||
if err == nil && len(transcript.Paragraphs) == 0 {
|
||||
err = fmt.Errorf("minutes transcript is explicitly empty; analysis readiness is not proven")
|
||||
}
|
||||
case "todos":
|
||||
var data map[string]any
|
||||
data, err = rt.CallMCPData("minutes", "list_minutes_todos", map[string]any{"taskUuid": id})
|
||||
if err == nil {
|
||||
err = minutesdata.ValidateArtifact("todos", id, data)
|
||||
value = data["result"]
|
||||
}
|
||||
default:
|
||||
err = fmt.Errorf("unsupported artifact %q", artifact)
|
||||
}
|
||||
if err != nil {
|
||||
failures = append(failures, map[string]any{"artifact": artifact, "error": err.Error()})
|
||||
continue
|
||||
}
|
||||
bundle[artifact] = value
|
||||
}
|
||||
return bundle, failures
|
||||
}
|
||||
|
||||
func waitMinutesArtifacts(rt *shortcut.RuntimeContext, id string, artifacts []string, pageLimit int, timeout, interval time.Duration) (map[string]any, []map[string]any, int) {
|
||||
deadline := time.Now().Add(timeout)
|
||||
attempts := 0
|
||||
for {
|
||||
attempts++
|
||||
bundle, failures := collectMinutesArtifactsOnce(rt, id, artifacts, pageLimit)
|
||||
if len(failures) == 0 || time.Now().Add(interval).After(deadline) {
|
||||
return bundle, failures, attempts
|
||||
}
|
||||
if err := waitMinutesInterval(rt, interval); err != nil {
|
||||
return bundle, append(failures, map[string]any{"artifact": "wait", "error": err.Error()}), attempts
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func waitMinutesInterval(rt *shortcut.RuntimeContext, interval time.Duration) error {
|
||||
timer := time.NewTimer(interval)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-rt.Command().Context().Done():
|
||||
return rt.Command().Context().Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func outputWorkflowResult(rt *shortcut.RuntimeContext, payload map[string]any, failed bool, reason, stage string) error {
|
||||
if err := rt.Output(payload); err != nil {
|
||||
return err
|
||||
}
|
||||
if failed {
|
||||
return minutesCompositeError(reason, stage, payload)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func uniqueStrings(values []string) []string {
|
||||
seen := map[string]bool{}
|
||||
out := []string{}
|
||||
for _, value := range values {
|
||||
for _, item := range strings.Split(value, ",") {
|
||||
item = strings.TrimSpace(item)
|
||||
if item != "" && !seen[item] {
|
||||
seen[item] = true
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func stringDifference(left, right []string) []string {
|
||||
other := map[string]bool{}
|
||||
for _, value := range right {
|
||||
other[value] = true
|
||||
}
|
||||
out := []string{}
|
||||
for _, value := range left {
|
||||
if !other[value] {
|
||||
out = append(out, value)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func prepareExportTarget(output string) (target, relative, parent string, err error) {
|
||||
if err := localio.ValidateOutput(output); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
cwd, err := minutesGetwd()
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
realBase, err := minutesEvalSymlinks(cwd)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
relative = filepath.Clean(output)
|
||||
target = filepath.Join(realBase, relative)
|
||||
parent = filepath.Dir(target)
|
||||
rel, err := minutesRel(realBase, target)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return "", "", "", fmt.Errorf("LOCAL_PATH_UNSAFE: 输出目录逃逸工作目录")
|
||||
}
|
||||
if err := ensureExportParent(realBase, filepath.Dir(rel)); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
if _, err := minutesLstat(target); err == nil {
|
||||
return "", "", "", fmt.Errorf("LOCAL_FILE_EXISTS: 目标目录已存在;请选择新的输出路径")
|
||||
} else if !os.IsNotExist(err) {
|
||||
return "", "", "", err
|
||||
}
|
||||
return target, relative, parent, nil
|
||||
}
|
||||
|
||||
func ensureExportParent(base, relative string) error {
|
||||
current := base
|
||||
if relative == "." || relative == "" {
|
||||
return nil
|
||||
}
|
||||
for _, component := range strings.Split(filepath.Clean(relative), string(filepath.Separator)) {
|
||||
current = filepath.Join(current, component)
|
||||
info, err := minutesLstat(current)
|
||||
if os.IsNotExist(err) {
|
||||
if err := minutesMkdir(current, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
||||
return fmt.Errorf("LOCAL_PATH_UNSAFE: 输出父路径不是安全目录")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeJSONFile(path string, value any) error {
|
||||
raw, err := minutesMarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
return minutesWriteFile(path, raw, 0o600)
|
||||
}
|
||||
|
||||
func init() {
|
||||
shortcut.Register(finalizeMinutesShortcuts(RecordWrapUp, UploadAndAnalyze, Mindmap, SpeakerInsights, PrepareASR, ExportPack, Share, Unshare)...)
|
||||
}
|
||||
@@ -0,0 +1,667 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package minutes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageMinutesWorkflowValidationAndDefaults(t *testing.T) {
|
||||
file := filepath.Join(t.TempDir(), "source.wav")
|
||||
if err := os.WriteFile(file, []byte("audio"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
invalid := [][]string{
|
||||
{"minutes", "+record-wrap-up", "--id", "u1", "--wait-timeout", "0"},
|
||||
{"minutes", "+upload-and-analyze", "--file", file, "--complete-timeout", "0"},
|
||||
{"minutes", "+mindmap", "--id", "u1", "--timeout", "0"},
|
||||
{"minutes", "+speaker-insights", "--id", "u1", "--interval", "0"},
|
||||
{"minutes", "+export-pack", "--id", "u1", "--output", "pack", "--page-limit", "0"},
|
||||
{"minutes", "+share", "--id", "u1", "--member-uids", strings.Repeat("m,", 51), "--permission", "view"},
|
||||
}
|
||||
for _, args := range invalid {
|
||||
if payload, output, err := runMinutesAlignmentCLI(t, &minutesE2ECaller{}, args...); err == nil || payload != nil || output != "" {
|
||||
t.Fatalf("invalid workflow accepted: %v payload=%#v output=%q err=%v", args, payload, output, err)
|
||||
}
|
||||
}
|
||||
|
||||
caller := &minutesE2ECaller{}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
cmd := &cobra.Command{Use: "workflow"}
|
||||
cmd.Flags().StringSlice("artifacts", nil, "")
|
||||
rt := shortcut.RuntimeContextForTest(cmd, RecordWrapUp)
|
||||
selected := selectedWorkflowArtifacts(rt)
|
||||
selected[0] = "mutated"
|
||||
if minutesWorkflowArtifacts[0] == "mutated" || len(selected) != len(minutesWorkflowArtifacts) {
|
||||
t.Fatalf("default artifacts alias global: %#v", selected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesRecordWrapUpBranchesE2E(t *testing.T) {
|
||||
if err := runMinutesAlignmentCLIWithWriter(t, &minutesE2ECaller{}, minutesFailWriter{}, "minutes", "+record-wrap-up", "--id", "u1", "--dry-run"); err == nil {
|
||||
t.Fatal("record dry-run output failure accepted")
|
||||
}
|
||||
stopFailure := &minutesE2ECaller{failAt: map[string]int{"minutes/" + listeningNoteCmdTool: 1}}
|
||||
if _, _, err := runMinutesAlignmentCLI(t, stopFailure, "minutes", "+record-wrap-up", "--id", "u1", "--yes"); err == nil {
|
||||
t.Fatal("record stop failure accepted")
|
||||
}
|
||||
badStop := &minutesE2ECaller{responses: map[string][]string{"minutes/" + listeningNoteCmdTool: {`{"success":true,"result":{"cmd":"pause","uuid":"u1"}}`}}}
|
||||
if _, _, err := runMinutesAlignmentCLI(t, badStop, "minutes", "+record-wrap-up", "--id", "u1", "--yes"); err == nil {
|
||||
t.Fatal("record mismatched acknowledgement accepted")
|
||||
}
|
||||
success := &minutesE2ECaller{responses: map[string][]string{
|
||||
"minutes/" + listeningNoteCmdTool: {`{"success":true,"result":{"cmd":"end","uuid":"u1"}}`},
|
||||
"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1","title":"done"}}`},
|
||||
}}
|
||||
payload, output, err := runMinutesAlignmentCLI(t, success, "minutes", "+record-wrap-up", "--id", "u1", "--artifacts", "basic", "--yes")
|
||||
if err != nil || output == "" || payload["complete"] != true {
|
||||
t.Fatalf("record success payload=%#v err=%v", payload, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesUploadAndAnalyzeBranchesE2E(t *testing.T) {
|
||||
if err := runMinutesAlignmentCLIWithWriter(t, &minutesE2ECaller{}, minutesFailWriter{}, "minutes", "+upload-and-analyze", "--resume-id", "u1", "--dry-run"); err == nil {
|
||||
t.Fatal("resume dry-run output failure accepted")
|
||||
}
|
||||
if _, _, err := runMinutesAlignmentCLI(t, &minutesE2ECaller{}, "minutes", "+upload-and-analyze", "--file", filepath.Join(t.TempDir(), "missing"), "--yes"); err == nil {
|
||||
t.Fatal("upload-and-analyze invalid file accepted")
|
||||
}
|
||||
if _, _, err := runMinutesAlignmentCLI(t, &minutesE2ECaller{}, "minutes", "+upload-and-analyze", "--file", filepath.Join(t.TempDir(), "missing"), "--dry-run"); err == nil {
|
||||
t.Fatal("upload-and-analyze invalid dry-run file accepted")
|
||||
}
|
||||
|
||||
resume := &minutesE2ECaller{responses: map[string][]string{
|
||||
"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1","title":"done"}}`},
|
||||
"minutes/create_mind_graph": {`{"success":true,"result":{}}`},
|
||||
"minutes/query_mind_graph_status": {`{"success":true,"result":{"taskStatus":1,"mindGraph":"ready"}}`},
|
||||
"minutes/create_speaker_summary": {`{"success":true,"result":{"taskId":"job","status":"processing"}}`},
|
||||
"minutes/get_speaker_summary": {`{"success":true,"result":{"summaries":[{"speaker":"a","summary":"b"}]}}`},
|
||||
}}
|
||||
payload, _, err := runMinutesAlignmentCLI(t, resume, "minutes", "+upload-and-analyze", "--resume-id", "u1", "--artifacts", "basic", "--mindmap", "--speaker-insights", "--yes")
|
||||
if err != nil || payload["complete"] != true || payload["taskUuid"] != "u1" {
|
||||
t.Fatalf("resume payload=%#v err=%v", payload, err)
|
||||
}
|
||||
|
||||
partial := &minutesE2ECaller{responses: map[string][]string{
|
||||
"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1","title":"done"}}`},
|
||||
"minutes/create_mind_graph": {`{"success":true,"result":{}}`},
|
||||
"minutes/query_mind_graph_status": {`{"success":true,"result":{"taskStatus":2}}`},
|
||||
"minutes/create_speaker_summary": {`{"success":true,"result":{"taskId":"job","status":"processing"}}`},
|
||||
"minutes/get_speaker_summary": {`{"success":false,"errorMsg":"denied"}`},
|
||||
}}
|
||||
payload, output, err := runMinutesAlignmentCLI(t, partial, "minutes", "+upload-and-analyze", "--resume-id", "u1", "--artifacts", "basic", "--mindmap", "--speaker-insights", "--yes")
|
||||
if err == nil || output == "" || payload["complete"] != false || payload["recovery"] == nil {
|
||||
t.Fatalf("partial payload=%#v err=%v", payload, err)
|
||||
}
|
||||
|
||||
file := filepath.Join(t.TempDir(), "source.wav")
|
||||
if err := os.WriteFile(file, []byte("audio"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &minutesPutFile, func(context.Context, string, string, int64) (localio.UploadResult, error) {
|
||||
return localio.UploadResult{SizeBytes: 5, Attempts: 1}, nil
|
||||
})
|
||||
upload := &minutesE2ECaller{responses: map[string][]string{
|
||||
"minutes/create_upload_session": {`{"success":true,"result":{"sessionId":"s1","presignedUrl":"https://example.invalid/u"}}`},
|
||||
"minutes/complete_upload_session": {`{"success":true,"result":{"taskUuid":"u2"}}`},
|
||||
"minutes/get_minutes_basic_info": {
|
||||
`{"success":true,"result":{"taskUuid":"u2","title":"uploaded"}}`,
|
||||
`{"success":true,"result":{"taskUuid":"u2","title":"uploaded"}}`,
|
||||
},
|
||||
}}
|
||||
payload, _, err = runMinutesAlignmentCLI(t, upload, "minutes", "+upload-and-analyze", "--file", file, "--artifacts", "basic", "--yes")
|
||||
if err != nil || payload["taskUuid"] != "u2" || payload["complete"] != true {
|
||||
t.Fatalf("fresh upload payload=%#v err=%v", payload, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesMindmapBranchesE2E(t *testing.T) {
|
||||
if err := runMinutesAlignmentCLIWithWriter(t, &minutesE2ECaller{}, minutesFailWriter{}, "minutes", "+mindmap", "--id", "u1", "--dry-run"); err == nil {
|
||||
t.Fatal("mindmap dry-run output failure accepted")
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
responses map[string][]string
|
||||
failAt map[string]int
|
||||
args []string
|
||||
}{
|
||||
{name: "create call", failAt: map[string]int{"minutes/create_mind_graph": 1}},
|
||||
{name: "poll call", responses: map[string][]string{"minutes/create_mind_graph": {`{}`}}, failAt: map[string]int{"minutes/query_mind_graph_status": 1}},
|
||||
{name: "poll parse", responses: map[string][]string{"minutes/create_mind_graph": {`{}`}, "minutes/query_mind_graph_status": {`{"success":true,"result":{}}`}}},
|
||||
{name: "timeout", responses: map[string][]string{"minutes/create_mind_graph": {`{}`}, "minutes/query_mind_graph_status": {`{"success":true,"result":{"taskStatus":0}}`}}},
|
||||
{name: "resume", responses: map[string][]string{"minutes/query_mind_graph_status": {`{"success":true,"result":{"taskStatus":1}}`}}, args: []string{"--resume"}},
|
||||
}
|
||||
for _, test := range cases {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
args := []string{"minutes", "+mindmap", "--id", "u1", "--timeout", "1", "--interval", "1", "--yes"}
|
||||
args = append(args, test.args...)
|
||||
payload, output, err := runMinutesAlignmentCLI(t, &minutesE2ECaller{responses: test.responses, failAt: test.failAt}, args...)
|
||||
if test.name == "resume" {
|
||||
if err != nil || payload["complete"] != true {
|
||||
t.Fatalf("resume payload=%#v err=%v", payload, err)
|
||||
}
|
||||
} else if err == nil || output == "" {
|
||||
t.Fatalf("failure accepted payload=%#v err=%v", payload, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
outputFail := &minutesE2ECaller{responses: map[string][]string{
|
||||
"minutes/query_mind_graph_status": {`{"success":true,"result":{"taskStatus":1}}`},
|
||||
}}
|
||||
if err := runMinutesAlignmentCLIWithWriter(t, outputFail, minutesFailWriter{}, "minutes", "+mindmap", "--id", "u1", "--resume", "--yes"); err == nil {
|
||||
t.Fatal("mindmap output failure accepted")
|
||||
}
|
||||
|
||||
caller := &minutesE2ECaller{responses: map[string][]string{"minutes/query_mind_graph_status": {`{"success":true,"result":{"taskStatus":0}}`}}}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
cmd := &cobra.Command{Use: "mindmap"}
|
||||
cmd.SetContext(ctx)
|
||||
rt := shortcut.RuntimeContextForTest(cmd, Mindmap)
|
||||
if _, err := runMinutesMindmap(rt, "u1", 2*time.Hour, time.Hour, false); err == nil {
|
||||
t.Fatal("mindmap cancelled wait accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesSpeakerInsightsBranchesE2E(t *testing.T) {
|
||||
if err := runMinutesAlignmentCLIWithWriter(t, &minutesE2ECaller{}, minutesFailWriter{}, "minutes", "+speaker-insights", "--id", "u1", "--dry-run"); err == nil {
|
||||
t.Fatal("speaker dry-run output failure accepted")
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
responses map[string][]string
|
||||
failAt map[string]int
|
||||
args []string
|
||||
}{
|
||||
{name: "create call", failAt: map[string]int{"minutes/create_speaker_summary": 1}},
|
||||
{name: "create parse", responses: map[string][]string{"minutes/create_speaker_summary": {`{"success":true,"result":{}}`}}},
|
||||
{name: "poll nonpending", responses: map[string][]string{"minutes/create_speaker_summary": {`{"success":true,"result":{"taskId":"job","status":"processing"}}`}, "minutes/get_speaker_summary": {`{"success":false,"errorMsg":"denied"}`}}},
|
||||
{name: "poll parse", responses: map[string][]string{"minutes/create_speaker_summary": {`{"success":true,"result":{"taskId":"job","status":"processing"}}`}, "minutes/get_speaker_summary": {`{"success":true,"result":{}}`}}},
|
||||
{name: "timeout", responses: map[string][]string{"minutes/create_speaker_summary": {`{"success":true,"result":{"taskId":"job","status":"processing"}}`}, "minutes/get_speaker_summary": {`{"success":false,"errorMsg":"processing"}`}}},
|
||||
{name: "resume", responses: map[string][]string{"minutes/get_speaker_summary": {`{"success":true,"result":{"summaries":[{"speaker":"a","summary":"b"}]}}`}}, args: []string{"--resume", "--task-id", "job"}},
|
||||
}
|
||||
for _, test := range cases {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
args := []string{"minutes", "+speaker-insights", "--id", "u1", "--timeout", "1", "--interval", "1", "--yes"}
|
||||
args = append(args, test.args...)
|
||||
payload, output, err := runMinutesAlignmentCLI(t, &minutesE2ECaller{responses: test.responses, failAt: test.failAt}, args...)
|
||||
if test.name == "resume" {
|
||||
if err != nil || payload["complete"] != true || payload["taskId"] != "job" {
|
||||
t.Fatalf("resume payload=%#v err=%v", payload, err)
|
||||
}
|
||||
} else if err == nil || output == "" {
|
||||
t.Fatalf("failure accepted payload=%#v err=%v", payload, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
outputFail := &minutesE2ECaller{responses: map[string][]string{"minutes/get_speaker_summary": {`{"success":true,"result":{"summaries":[{"speaker":"a","summary":"b"}]}}`}}}
|
||||
if err := runMinutesAlignmentCLIWithWriter(t, outputFail, minutesFailWriter{}, "minutes", "+speaker-insights", "--id", "u1", "--resume", "--yes"); err == nil {
|
||||
t.Fatal("speaker output failure accepted")
|
||||
}
|
||||
|
||||
for _, message := range []string{"query empty", "processing", "not ready", "result is empty", "business error: code 000", "暂无"} {
|
||||
if !speakerSummaryPending(errors.New(message)) {
|
||||
t.Fatalf("pending message rejected: %q", message)
|
||||
}
|
||||
}
|
||||
if speakerSummaryPending(nil) || speakerSummaryPending(errors.New("denied")) {
|
||||
t.Fatal("non-pending speaker error accepted")
|
||||
}
|
||||
|
||||
caller := &minutesE2ECaller{responses: map[string][]string{"minutes/get_speaker_summary": {`{"success":false,"errorMsg":"processing"}`}}}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
cmd := &cobra.Command{Use: "speaker"}
|
||||
cmd.SetContext(ctx)
|
||||
rt := shortcut.RuntimeContextForTest(cmd, SpeakerInsights)
|
||||
if _, err := runMinutesSpeakerInsights(rt, "u1", 2*time.Hour, time.Hour, false, "job"); err == nil {
|
||||
t.Fatal("speaker cancelled wait accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesPrepareASRBranchesE2E(t *testing.T) {
|
||||
if err := runMinutesAlignmentCLIWithWriter(t, &minutesE2ECaller{}, minutesFailWriter{}, "minutes", "+prepare-asr", "--words", "a", "--dry-run"); err == nil {
|
||||
t.Fatal("ASR dry-run output failure accepted")
|
||||
}
|
||||
readFailure := &minutesE2ECaller{failAt: map[string]int{"minutes/list_my_hotwords": 1}}
|
||||
if _, _, err := runMinutesAlignmentCLI(t, readFailure, "minutes", "+prepare-asr", "--words", "a", "--yes"); err == nil {
|
||||
t.Fatal("ASR current read failure accepted")
|
||||
}
|
||||
parseFailure := &minutesE2ECaller{responses: map[string][]string{"minutes/list_my_hotwords": {`{"success":true,"result":{}}`}}}
|
||||
if _, _, err := runMinutesAlignmentCLI(t, parseFailure, "minutes", "+prepare-asr", "--words", "a", "--yes"); err == nil {
|
||||
t.Fatal("ASR current parse failure accepted")
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
responses map[string][]string
|
||||
failAt map[string]int
|
||||
}{
|
||||
{name: "add call", responses: map[string][]string{"minutes/list_my_hotwords": {`{"success":true,"result":{"hotWordList":[]}}`}}, failAt: map[string]int{"minutes/add_personal_hot_word": 1}},
|
||||
{name: "add ack", responses: map[string][]string{"minutes/list_my_hotwords": {`{"success":true,"result":{"hotWordList":[]}}`}, "minutes/add_personal_hot_word": {`{"result":{}}`}}},
|
||||
{name: "delete call", responses: map[string][]string{"minutes/list_my_hotwords": {`{"success":true,"result":{"hotWordList":["old"]}}`}}, failAt: map[string]int{"minutes/delete_personal_hotword": 1}},
|
||||
{name: "delete ack", responses: map[string][]string{"minutes/list_my_hotwords": {`{"success":true,"result":{"hotWordList":["old"]}}`}, "minutes/delete_personal_hotword": {`{"result":{}}`}}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
args := []string{"minutes", "+prepare-asr", "--words", "a", "--yes"}
|
||||
if strings.HasPrefix(test.name, "delete") {
|
||||
args = []string{"minutes", "+prepare-asr", "--words", "a", "--sync", "--yes"}
|
||||
test.responses["minutes/add_personal_hot_word"] = []string{`{"success":true,"result":{}}`}
|
||||
}
|
||||
payload, output, err := runMinutesAlignmentCLI(t, &minutesE2ECaller{responses: test.responses, failAt: test.failAt}, args...)
|
||||
if err == nil || output == "" || payload["complete"] != false {
|
||||
t.Fatalf("ASR failure accepted payload=%#v err=%v", payload, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
verifyCall := &minutesE2ECaller{
|
||||
responses: map[string][]string{"minutes/list_my_hotwords": {`{"success":true,"result":{"hotWordList":["a"]}}`}},
|
||||
failAt: map[string]int{"minutes/list_my_hotwords": 2},
|
||||
}
|
||||
payload, output, err := runMinutesAlignmentCLI(t, verifyCall, "minutes", "+prepare-asr", "--words", "a", "--yes")
|
||||
if err == nil || output == "" || payload["complete"] != false {
|
||||
t.Fatalf("ASR verify call payload=%#v err=%v", payload, err)
|
||||
}
|
||||
verifyCallOutput := &minutesE2ECaller{
|
||||
responses: map[string][]string{"minutes/list_my_hotwords": {`{"success":true,"result":{"hotWordList":["a"]}}`}},
|
||||
failAt: map[string]int{"minutes/list_my_hotwords": 2},
|
||||
}
|
||||
if err := runMinutesAlignmentCLIWithWriter(t, verifyCallOutput, minutesFailWriter{}, "minutes", "+prepare-asr", "--words", "a", "--yes"); err == nil {
|
||||
t.Fatal("ASR verify read output failure accepted")
|
||||
}
|
||||
verifyParse := &minutesE2ECaller{responses: map[string][]string{"minutes/list_my_hotwords": {
|
||||
`{"success":true,"result":{"hotWordList":["a"]}}`, `{"success":true,"result":{}}`,
|
||||
}}}
|
||||
if _, _, err := runMinutesAlignmentCLI(t, verifyParse, "minutes", "+prepare-asr", "--words", "a", "--yes"); err == nil {
|
||||
t.Fatal("ASR verify parse failure accepted")
|
||||
}
|
||||
verifyMismatch := &minutesE2ECaller{responses: map[string][]string{"minutes/list_my_hotwords": {
|
||||
`{"success":true,"result":{"hotWordList":["a","old"]}}`, `{"success":true,"result":{"hotWordList":["old"]}}`,
|
||||
}, "minutes/delete_personal_hotword": {`{"success":true,"result":{}}`}}}
|
||||
payload, output, err = runMinutesAlignmentCLI(t, verifyMismatch, "minutes", "+prepare-asr", "--words", "a", "--sync", "--yes")
|
||||
if err == nil || output == "" || payload["verified"] != false {
|
||||
t.Fatalf("ASR mismatch payload=%#v err=%v", payload, err)
|
||||
}
|
||||
syncSuccess := &minutesE2ECaller{responses: map[string][]string{
|
||||
"minutes/list_my_hotwords": {`{"success":true,"result":{"hotWordList":["old"]}}`, `{"success":true,"result":{"hotWordList":["a"]}}`},
|
||||
"minutes/add_personal_hot_word": {`{"success":true,"result":{}}`},
|
||||
"minutes/delete_personal_hotword": {`{"success":true,"result":{}}`},
|
||||
}}
|
||||
payload, _, err = runMinutesAlignmentCLI(t, syncSuccess, "minutes", "+prepare-asr", "--words", "a", "--sync", "--yes")
|
||||
if err != nil || payload["complete"] != true {
|
||||
t.Fatalf("ASR sync payload=%#v err=%v", payload, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesPermissionLedgerBranchesE2E(t *testing.T) {
|
||||
many := make([]string, 51)
|
||||
for index := range many {
|
||||
many[index] = fmt.Sprintf("m%d", index)
|
||||
}
|
||||
if payload, output, err := runMinutesAlignmentCLI(t, &minutesE2ECaller{}, "minutes", "+share", "--id", "u1", "--member-uids", strings.Join(many, ","), "--permission", "view", "--yes"); err == nil || payload != nil || output != "" {
|
||||
t.Fatalf("too many members accepted payload=%#v err=%v", payload, err)
|
||||
}
|
||||
if err := runMinutesAlignmentCLIWithWriter(t, &minutesE2ECaller{}, minutesFailWriter{}, "minutes", "+unshare", "--id", "u1", "--member-uids", "m1", "--dry-run"); err == nil {
|
||||
t.Fatal("unshare dry-run output failure accepted")
|
||||
}
|
||||
unshare := &minutesE2ECaller{responses: map[string][]string{"minutes/remove_member_permission": {`{"success":true,"result":{}}`}}}
|
||||
payload, _, err := runMinutesAlignmentCLI(t, unshare, "minutes", "+unshare", "--id", "u1", "--member-uids", "m1", "--yes")
|
||||
if err != nil || payload["complete"] != true || payload["succeeded"] != float64(1) {
|
||||
t.Fatalf("unshare payload=%#v err=%v", payload, err)
|
||||
}
|
||||
share := &minutesE2ECaller{responses: map[string][]string{"minutes/add_member_permission": {`{"success":true,"result":{}}`}}}
|
||||
payload, _, err = runMinutesAlignmentCLI(t, share, "minutes", "+share", "--id", "u1", "--member-uids", "m1", "--permission", "edit", "--cover", "--sub-resources", "Summary,Note", "--yes")
|
||||
if err != nil || payload["complete"] != true {
|
||||
t.Fatalf("share payload=%#v err=%v", payload, err)
|
||||
}
|
||||
args := share.arguments["minutes/add_member_permission"][0]
|
||||
if args["coverPermission"] != "true" || len(args["roleSubResourceIds"].([]string)) != 2 {
|
||||
t.Fatalf("share args=%#v", args)
|
||||
}
|
||||
continueFailure := &minutesE2ECaller{responses: map[string][]string{"minutes/remove_member_permission": {`{"result":{}}`, `{"success":true,"result":{}}`}}}
|
||||
payload, output, err := runMinutesAlignmentCLI(t, continueFailure, "minutes", "+unshare", "--id", "u1", "--member-uids", "m1,m2", "--failure-policy", "continue", "--yes")
|
||||
if err == nil || output == "" || payload["failed"] != float64(1) || payload["succeeded"] != float64(1) {
|
||||
t.Fatalf("continue ledger payload=%#v err=%v", payload, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesArtifactCollectorBranches(t *testing.T) {
|
||||
success := &minutesE2ECaller{responses: map[string][]string{
|
||||
"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1","title":"ok"}}`},
|
||||
"minutes/get_minutes_ai_summary": {`{"success":true,"result":{"fullSummary":"summary"}}`},
|
||||
"minutes/get_minutes_keywords": {`{"success":true,"result":{"keywords":[]}}`},
|
||||
"minutes/get_minutes_transcription": {`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1"}],"hasNext":false}}`},
|
||||
"minutes/list_minutes_todos": {`{"success":true,"result":{"actions":[]}}`},
|
||||
}}
|
||||
helpers.InitDepsForTest(t, success)
|
||||
rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: "collect"}, ExportPack)
|
||||
bundle, failures := collectMinutesArtifactsOnce(rt, "u1", append(minutesWorkflowArtifacts, "unknown"), 10)
|
||||
if len(bundle) != 5 || len(failures) != 1 {
|
||||
t.Fatalf("collector bundle=%#v failures=%#v", bundle, failures)
|
||||
}
|
||||
|
||||
for _, artifact := range []string{"basic", "summary", "keywords", "transcript", "todos"} {
|
||||
t.Run("call "+artifact, func(t *testing.T) {
|
||||
tool := map[string]string{
|
||||
"basic": "get_minutes_basic_info", "summary": "get_minutes_ai_summary", "keywords": "get_minutes_keywords",
|
||||
"transcript": "get_minutes_transcription", "todos": "list_minutes_todos",
|
||||
}[artifact]
|
||||
caller := &minutesE2ECaller{failAt: map[string]int{"minutes/" + tool: 1}}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: "collect"}, ExportPack)
|
||||
got, failed := collectMinutesArtifactsOnce(rt, "u1", []string{artifact}, 1)
|
||||
if len(got) != 0 || len(failed) != 1 {
|
||||
t.Fatalf("got=%#v failed=%#v", got, failed)
|
||||
}
|
||||
})
|
||||
t.Run("parse "+artifact, func(t *testing.T) {
|
||||
tool := map[string]string{
|
||||
"basic": "get_minutes_basic_info", "summary": "get_minutes_ai_summary", "keywords": "get_minutes_keywords",
|
||||
"transcript": "get_minutes_transcription", "todos": "list_minutes_todos",
|
||||
}[artifact]
|
||||
caller := &minutesE2ECaller{responses: map[string][]string{"minutes/" + tool: {`{"success":true,"result":{}}`}}}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: "collect"}, ExportPack)
|
||||
got, failed := collectMinutesArtifactsOnce(rt, "u1", []string{artifact}, 1)
|
||||
if len(got) != 0 || len(failed) != 1 {
|
||||
t.Fatalf("got=%#v failed=%#v", got, failed)
|
||||
}
|
||||
})
|
||||
}
|
||||
emptySummary := &minutesE2ECaller{responses: map[string][]string{"minutes/get_minutes_ai_summary": {`{"success":true,"result":{"fullSummary":""}}`}}}
|
||||
helpers.InitDepsForTest(t, emptySummary)
|
||||
rt = shortcut.RuntimeContextForTest(&cobra.Command{Use: "collect"}, ExportPack)
|
||||
if _, failures = collectMinutesArtifactsOnce(rt, "u1", []string{"summary"}, 1); len(failures) != 1 {
|
||||
t.Fatalf("empty summary failures=%#v", failures)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesArtifactWaitAndOutput(t *testing.T) {
|
||||
success := &minutesE2ECaller{responses: map[string][]string{"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1"}}`}}}
|
||||
helpers.InitDepsForTest(t, success)
|
||||
rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: "wait"}, ExportPack)
|
||||
if _, failures, attempts := waitMinutesArtifacts(rt, "u1", []string{"basic"}, 1, time.Second, 0); len(failures) != 0 || attempts != 1 {
|
||||
t.Fatalf("success failures=%#v attempts=%d", failures, attempts)
|
||||
}
|
||||
failing := &minutesE2ECaller{failAt: map[string]int{"minutes/get_minutes_basic_info": 1}}
|
||||
helpers.InitDepsForTest(t, failing)
|
||||
rt = shortcut.RuntimeContextForTest(&cobra.Command{Use: "wait"}, ExportPack)
|
||||
if _, failures, attempts := waitMinutesArtifacts(rt, "u1", []string{"basic"}, 1, 0, 0); len(failures) != 1 || attempts != 1 {
|
||||
t.Fatalf("timeout failures=%#v attempts=%d", failures, attempts)
|
||||
}
|
||||
cancelled := &minutesE2ECaller{responses: map[string][]string{"minutes/get_minutes_basic_info": {`{"success":true,"result":{}}`}}}
|
||||
helpers.InitDepsForTest(t, cancelled)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
cmd := &cobra.Command{Use: "wait"}
|
||||
cmd.SetContext(ctx)
|
||||
rt = shortcut.RuntimeContextForTest(cmd, ExportPack)
|
||||
if _, failures, _ := waitMinutesArtifacts(rt, "u1", []string{"basic"}, 1, 2*time.Hour, time.Hour); len(failures) != 2 || failures[1]["artifact"] != "wait" {
|
||||
t.Fatalf("cancel failures=%#v", failures)
|
||||
}
|
||||
cmd = &cobra.Command{Use: "wait"}
|
||||
cmd.SetContext(context.Background())
|
||||
rt = shortcut.RuntimeContextForTest(cmd, ExportPack)
|
||||
if err := waitMinutesInterval(rt, 0); err != nil {
|
||||
t.Fatalf("timer wait: %v", err)
|
||||
}
|
||||
|
||||
cmd.SetOut(minutesFailWriter{})
|
||||
if err := outputWorkflowResult(rt, map[string]any{"complete": true}, false, "", ""); err == nil {
|
||||
t.Fatal("workflow output failure accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesExportPackBranchesE2E(t *testing.T) {
|
||||
withCWD := func(t *testing.T) string {
|
||||
t.Helper()
|
||||
old, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
work := t.TempDir()
|
||||
if err := os.Chdir(work); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(old) })
|
||||
return work
|
||||
}
|
||||
basicCaller := func() *minutesE2ECaller {
|
||||
return &minutesE2ECaller{responses: map[string][]string{"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1","title":"ok"}}`}}}
|
||||
}
|
||||
|
||||
t.Run("collect failure", func(t *testing.T) {
|
||||
withCWD(t)
|
||||
payload, output, err := runMinutesAlignmentCLI(t, &minutesE2ECaller{responses: map[string][]string{"minutes/get_minutes_basic_info": {`{"success":true,"result":{}}`}}}, "minutes", "+export-pack", "--id", "u1", "--output", "pack", "--artifacts", "basic")
|
||||
if err == nil || output == "" || payload["published"] != false {
|
||||
t.Fatalf("collect payload=%#v err=%v", payload, err)
|
||||
}
|
||||
})
|
||||
t.Run("target exists", func(t *testing.T) {
|
||||
work := withCWD(t)
|
||||
if err := os.Mkdir(filepath.Join(work, "pack"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := runMinutesAlignmentCLI(t, basicCaller(), "minutes", "+export-pack", "--id", "u1", "--output", "pack", "--artifacts", "basic"); err == nil {
|
||||
t.Fatal("existing export accepted")
|
||||
}
|
||||
})
|
||||
t.Run("mkdir temp", func(t *testing.T) {
|
||||
withCWD(t)
|
||||
testseam.Swap(t, &minutesMkdirTemp, func(string, string) (string, error) { return "", errors.New("mkdir temp") })
|
||||
if _, _, err := runMinutesAlignmentCLI(t, basicCaller(), "minutes", "+export-pack", "--id", "u1", "--output", "pack", "--artifacts", "basic"); err == nil {
|
||||
t.Fatal("mkdir temp failure accepted")
|
||||
}
|
||||
})
|
||||
t.Run("summary write", func(t *testing.T) {
|
||||
withCWD(t)
|
||||
testseam.Swap(t, &minutesWriteFile, func(string, []byte, os.FileMode) error { return errors.New("write") })
|
||||
caller := &minutesE2ECaller{responses: map[string][]string{"minutes/get_minutes_ai_summary": {`{"success":true,"result":{"fullSummary":"summary"}}`}}}
|
||||
if _, _, err := runMinutesAlignmentCLI(t, caller, "minutes", "+export-pack", "--id", "u1", "--output", "pack", "--artifacts", "summary"); err == nil {
|
||||
t.Fatal("summary write failure accepted")
|
||||
}
|
||||
})
|
||||
t.Run("json write", func(t *testing.T) {
|
||||
withCWD(t)
|
||||
testseam.Swap(t, &minutesWriteFile, func(string, []byte, os.FileMode) error { return errors.New("write") })
|
||||
if _, _, err := runMinutesAlignmentCLI(t, basicCaller(), "minutes", "+export-pack", "--id", "u1", "--output", "pack", "--artifacts", "basic"); err == nil {
|
||||
t.Fatal("JSON write failure accepted")
|
||||
}
|
||||
})
|
||||
t.Run("stat", func(t *testing.T) {
|
||||
withCWD(t)
|
||||
testseam.Swap(t, &minutesStat, func(string) (os.FileInfo, error) { return nil, errors.New("stat") })
|
||||
if _, _, err := runMinutesAlignmentCLI(t, basicCaller(), "minutes", "+export-pack", "--id", "u1", "--output", "pack", "--artifacts", "basic"); err == nil {
|
||||
t.Fatal("stat failure accepted")
|
||||
}
|
||||
})
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
responses map[string][]string
|
||||
failAt map[string]int
|
||||
download func(context.Context, string, localio.DownloadOptions) (localio.DownloadResult, error)
|
||||
wantError bool
|
||||
}{
|
||||
{name: "media call", failAt: map[string]int{"minutes/query_minutes_audio_url": 1}, wantError: true},
|
||||
{name: "media parse", responses: map[string][]string{"minutes/query_minutes_audio_url": {`{"success":true,"result":{}}`}}, wantError: true},
|
||||
{name: "media download", responses: map[string][]string{"minutes/query_minutes_audio_url": {`{"success":true,"result":{"downloadUrl":"https://example.invalid/a.mp3"}}`}}, download: func(context.Context, string, localio.DownloadOptions) (localio.DownloadResult, error) {
|
||||
return localio.DownloadResult{}, errors.New("download")
|
||||
}, wantError: true},
|
||||
{name: "media success", responses: map[string][]string{"minutes/query_minutes_audio_url": {`{"success":true,"result":{"downloadUrl":"https://example.invalid/a.mp3"}}`}}, download: func(_ context.Context, _ string, options localio.DownloadOptions) (localio.DownloadResult, error) {
|
||||
path := filepath.Join(options.BaseDir, "media", "u1.mp3")
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return localio.DownloadResult{}, err
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("media"), 0o600); err != nil {
|
||||
return localio.DownloadResult{}, err
|
||||
}
|
||||
return localio.DownloadResult{RelativePath: "media/u1.mp3", SizeBytes: 5}, nil
|
||||
}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
withCWD(t)
|
||||
if test.download != nil {
|
||||
testseam.Swap(t, &minutesDownload, test.download)
|
||||
}
|
||||
responses := map[string][]string{"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1"}}`}}
|
||||
for key, values := range test.responses {
|
||||
responses[key] = values
|
||||
}
|
||||
payload, _, err := runMinutesAlignmentCLI(t, &minutesE2ECaller{responses: responses, failAt: test.failAt}, "minutes", "+export-pack", "--id", "u1", "--output", "pack", "--artifacts", "basic", "--include-media")
|
||||
if (err != nil) != test.wantError {
|
||||
t.Fatalf("payload=%#v err=%v", payload, err)
|
||||
}
|
||||
if !test.wantError && payload["published"] != true {
|
||||
t.Fatalf("payload=%#v", payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("manifest write", func(t *testing.T) {
|
||||
withCWD(t)
|
||||
realWrite := minutesWriteFile
|
||||
testseam.Swap(t, &minutesWriteFile, func(path string, data []byte, mode os.FileMode) error {
|
||||
if filepath.Base(path) == "manifest.json" {
|
||||
return errors.New("manifest")
|
||||
}
|
||||
return realWrite(path, data, mode)
|
||||
})
|
||||
if _, _, err := runMinutesAlignmentCLI(t, basicCaller(), "minutes", "+export-pack", "--id", "u1", "--output", "pack", "--artifacts", "basic"); err == nil {
|
||||
t.Fatal("manifest failure accepted")
|
||||
}
|
||||
})
|
||||
t.Run("rename", func(t *testing.T) {
|
||||
withCWD(t)
|
||||
testseam.Swap(t, &minutesRename, func(string, string) error { return errors.New("rename") })
|
||||
if _, _, err := runMinutesAlignmentCLI(t, basicCaller(), "minutes", "+export-pack", "--id", "u1", "--output", "pack", "--artifacts", "basic"); err == nil {
|
||||
t.Fatal("rename failure accepted")
|
||||
}
|
||||
})
|
||||
t.Run("output", func(t *testing.T) {
|
||||
withCWD(t)
|
||||
if err := runMinutesAlignmentCLIWithWriter(t, basicCaller(), minutesFailWriter{}, "minutes", "+export-pack", "--id", "u1", "--output", "pack", "--artifacts", "basic"); err == nil {
|
||||
t.Fatal("export output failure accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesExportPathAndJSONFaults(t *testing.T) {
|
||||
if _, _, _, err := prepareExportTarget("../escape"); err == nil {
|
||||
t.Fatal("unsafe target accepted")
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
run func(*testing.T)
|
||||
}{
|
||||
{name: "getwd", run: func(t *testing.T) {
|
||||
testseam.Swap(t, &minutesGetwd, func() (string, error) { return "", errors.New("getwd") })
|
||||
if _, _, _, err := prepareExportTarget("pack"); err == nil {
|
||||
t.Fatal("getwd failure accepted")
|
||||
}
|
||||
}},
|
||||
{name: "eval", run: func(t *testing.T) {
|
||||
testseam.Swap(t, &minutesEvalSymlinks, func(string) (string, error) { return "", errors.New("eval") })
|
||||
if _, _, _, err := prepareExportTarget("pack"); err == nil {
|
||||
t.Fatal("eval failure accepted")
|
||||
}
|
||||
}},
|
||||
{name: "rel", run: func(t *testing.T) {
|
||||
testseam.Swap(t, &minutesRel, func(string, string) (string, error) { return "", errors.New("rel") })
|
||||
if _, _, _, err := prepareExportTarget("pack"); err == nil {
|
||||
t.Fatal("rel failure accepted")
|
||||
}
|
||||
}},
|
||||
{name: "escape", run: func(t *testing.T) {
|
||||
testseam.Swap(t, &minutesRel, func(string, string) (string, error) { return "../escape", nil })
|
||||
if _, _, _, err := prepareExportTarget("pack"); err == nil {
|
||||
t.Fatal("rel escape accepted")
|
||||
}
|
||||
}},
|
||||
{name: "target lstat", run: func(t *testing.T) {
|
||||
testseam.Swap(t, &minutesLstat, func(string) (os.FileInfo, error) { return nil, errors.New("lstat") })
|
||||
if _, _, _, err := prepareExportTarget("pack"); err == nil {
|
||||
t.Fatal("lstat failure accepted")
|
||||
}
|
||||
}},
|
||||
{name: "parent", run: func(t *testing.T) {
|
||||
testseam.Swap(t, &minutesRel, func(string, string) (string, error) { return "parent/pack", nil })
|
||||
testseam.Swap(t, &minutesLstat, func(string) (os.FileInfo, error) { return nil, errors.New("parent") })
|
||||
if _, _, _, err := prepareExportTarget("pack"); err == nil {
|
||||
t.Fatal("unsafe parent accepted")
|
||||
}
|
||||
}},
|
||||
} {
|
||||
t.Run(test.name, test.run)
|
||||
}
|
||||
|
||||
base := t.TempDir()
|
||||
if err := ensureExportParent(base, "."); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Run("mkdir", func(t *testing.T) {
|
||||
testseam.Swap(t, &minutesLstat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
|
||||
testseam.Swap(t, &minutesMkdir, func(string, os.FileMode) error { return errors.New("mkdir") })
|
||||
if err := ensureExportParent(base, "a"); err == nil {
|
||||
t.Fatal("mkdir failure accepted")
|
||||
}
|
||||
})
|
||||
t.Run("lstat", func(t *testing.T) {
|
||||
testseam.Swap(t, &minutesLstat, func(string) (os.FileInfo, error) { return nil, errors.New("lstat") })
|
||||
if err := ensureExportParent(base, "a"); err == nil {
|
||||
t.Fatal("lstat failure accepted")
|
||||
}
|
||||
})
|
||||
t.Run("file", func(t *testing.T) {
|
||||
path := filepath.Join(base, "file")
|
||||
if err := os.WriteFile(path, []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ensureExportParent(base, "file/child"); err == nil {
|
||||
t.Fatal("file parent accepted")
|
||||
}
|
||||
})
|
||||
t.Run("create", func(t *testing.T) {
|
||||
if err := ensureExportParent(base, "created/nested"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("marshal", func(t *testing.T) {
|
||||
testseam.Swap(t, &minutesMarshalIndent, func(any, string, string) ([]byte, error) { return nil, errors.New("marshal") })
|
||||
if err := writeJSONFile(filepath.Join(t.TempDir(), "x.json"), map[string]any{}); err == nil {
|
||||
t.Fatal("marshal failure accepted")
|
||||
}
|
||||
})
|
||||
t.Run("write", func(t *testing.T) {
|
||||
testseam.Swap(t, &minutesWriteFile, func(string, []byte, os.FileMode) error { return errors.New("write") })
|
||||
if err := writeJSONFile(filepath.Join(t.TempDir(), "x.json"), map[string]any{}); err == nil {
|
||||
t.Fatal("write failure accepted")
|
||||
}
|
||||
})
|
||||
path := filepath.Join(t.TempDir(), "ok.json")
|
||||
if err := writeJSONFile(path, map[string]any{"ok": true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil || !bytes.HasSuffix(raw, []byte("\n")) || !json.Valid(raw) {
|
||||
t.Fatalf("JSON output=%q err=%v", raw, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package minutesdata
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageMinutesResponseCompletion(t *testing.T) {
|
||||
for _, data := range []map[string]any{
|
||||
{},
|
||||
{"success": false, "message": "denied"},
|
||||
{"errorCode": "E", "msg": "bad"},
|
||||
{"dingOpenErrcode": float64(1)},
|
||||
} {
|
||||
if err := ValidateEnvelope(data); err == nil {
|
||||
t.Fatalf("invalid envelope accepted: %#v", data)
|
||||
}
|
||||
}
|
||||
for _, value := range []any{nil, "", "0", float64(0), 0, int64(0), json.Number("0")} {
|
||||
if !isZeroCode(value) {
|
||||
t.Fatalf("zero code rejected: %#v", value)
|
||||
}
|
||||
}
|
||||
if isZeroCode(true) {
|
||||
t.Fatal("unsupported code treated as zero")
|
||||
}
|
||||
if got := envelopeMessage(map[string]any{}); got != "no backend error message" {
|
||||
t.Fatalf("fallback message = %q", got)
|
||||
}
|
||||
|
||||
list, err := ParseListPage(map[string]any{
|
||||
"hasNext": true, "nextCursor": "outer",
|
||||
"data": map[string]any{"result": map[string]any{"records": []any{map[string]any{"taskUUID": "u1"}}}},
|
||||
})
|
||||
if err != nil || !list.HasMoreKnown || list.NextToken != "outer" || len(list.Items) != 1 {
|
||||
t.Fatalf("nested list = %#v, %v", list, err)
|
||||
}
|
||||
if _, err := ParseListPage(map[string]any{"result": map[string]any{"items": []any{map[string]any{}, "bad"}}}); err == nil {
|
||||
t.Fatal("non-object list item accepted")
|
||||
}
|
||||
transcript, err := ParseTranscriptPage(map[string]any{
|
||||
"hasMore": false,
|
||||
"data": map[string]any{"data": map[string]any{"paragraphList": []any{map[string]any{"sentenceId": "p"}}}},
|
||||
})
|
||||
if err != nil || !transcript.HasMoreKnown || transcript.HasMore || len(transcript.Items) != 1 {
|
||||
t.Fatalf("nested transcript = %#v, %v", transcript, err)
|
||||
}
|
||||
if _, err := ParseTranscriptPage(map[string]any{"result": map[string]any{"paragraphList": []any{"bad"}}}); err == nil {
|
||||
t.Fatal("non-object paragraph accepted")
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
data map[string]any
|
||||
}{
|
||||
{"no result", map[string]any{"success": true}},
|
||||
{"basic missing", map[string]any{"result": map[string]any{}}},
|
||||
{"basic mismatch", map[string]any{"result": map[string]any{"taskUuid": "other"}}},
|
||||
{"summary missing", map[string]any{"result": map[string]any{}}},
|
||||
{"keywords wrong", map[string]any{"result": map[string]any{"keywords": "bad"}}},
|
||||
{"todos wrong", map[string]any{"result": map[string]any{"actions": "bad", "dingtalkTodoList": "bad"}}},
|
||||
{"other empty", map[string]any{"result": map[string]any{}}},
|
||||
} {
|
||||
artifact := map[string]string{"no result": "basic", "basic missing": "basic", "basic mismatch": "basic", "summary missing": "summary", "keywords wrong": "keywords", "todos wrong": "todos", "other empty": "custom"}[tc.name]
|
||||
if err := ValidateArtifact(artifact, "u1", tc.data); err == nil {
|
||||
t.Fatalf("%s accepted", tc.name)
|
||||
}
|
||||
}
|
||||
if err := ValidateArtifact("basic", "u1", map[string]any{}); err == nil {
|
||||
t.Fatal("artifact envelope failure ignored")
|
||||
}
|
||||
if err := ValidateArtifact("todos", "", map[string]any{"result": map[string]any{"dingtalkTodoList": []any{}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ValidateArtifact("custom", "", map[string]any{"result": map[string]any{"value": true}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Basic("u1", map[string]any{"result": map[string]any{}}); err == nil {
|
||||
t.Fatal("invalid basic accepted")
|
||||
}
|
||||
if basic, err := Basic("u1", map[string]any{"result": map[string]any{"uuid": "u1"}}); err != nil || basic["uuid"] != "u1" {
|
||||
t.Fatalf("basic = %#v, %v", basic, err)
|
||||
}
|
||||
if _, err := SummaryText(map[string]any{"result": map[string]any{}}); err == nil {
|
||||
t.Fatal("invalid summary accepted")
|
||||
}
|
||||
if _, err := SummaryText(map[string]any{"result": map[string]any{"fullSummary": 1}}); err == nil {
|
||||
t.Fatal("non-string summary accepted")
|
||||
}
|
||||
if text, err := SummaryText(map[string]any{"result": map[string]any{"fullSummary": "ok"}}); err != nil || text != "ok" {
|
||||
t.Fatalf("summary = %q, %v", text, err)
|
||||
}
|
||||
|
||||
for _, data := range []map[string]any{{}, {"result": "bad"}, {"result": map[string]any{}}} {
|
||||
if _, err := MediaURL(data); err == nil {
|
||||
t.Fatalf("invalid media accepted: %#v", data)
|
||||
}
|
||||
}
|
||||
if value, err := MediaURL(map[string]any{"result": map[string]any{"audioUrl": " https://example.invalid/a "}}); err != nil || value == "" {
|
||||
t.Fatalf("media = %q, %v", value, err)
|
||||
}
|
||||
for _, data := range []map[string]any{{}, {"result": "bad"}, {"result": map[string]any{"sessionId": "s"}}} {
|
||||
if _, _, err := UploadSession(data); err == nil {
|
||||
t.Fatalf("invalid upload session accepted: %#v", data)
|
||||
}
|
||||
}
|
||||
if session, url, err := UploadSession(map[string]any{"result": map[string]any{"uploadId": 7, "uploadUrl": "https://example.invalid"}}); err != nil || session != "7" || url == "" {
|
||||
t.Fatalf("upload session = %q/%q, %v", session, url, err)
|
||||
}
|
||||
if _, err := CompletedTaskUUID(map[string]any{}); err == nil {
|
||||
t.Fatal("invalid completed envelope accepted")
|
||||
}
|
||||
if _, err := CompletedTaskUUID(map[string]any{"result": map[string]any{}}); err == nil {
|
||||
t.Fatal("missing completed UUID accepted")
|
||||
}
|
||||
if id, err := CompletedTaskUUID(map[string]any{"data": map[string]any{"result": map[string]any{"task_uuid": "done"}}}); err != nil || id != "done" {
|
||||
t.Fatalf("completed UUID = %q, %v", id, err)
|
||||
}
|
||||
|
||||
rows, err := ProjectList(Page{Items: []map[string]any{{
|
||||
"uuid": "u", "name": "n", "creatorNick": "c", "createTime": 1,
|
||||
"deadline": 2, "link": "l", "state": "ready",
|
||||
}}})
|
||||
if err != nil || rows[0]["title"] != "n" || rows[0]["creator"] != "c" || rows[0]["url"] != "l" {
|
||||
t.Fatalf("projected fallback fields = %#v, %v", rows, err)
|
||||
}
|
||||
if got := TaskUUID(map[string]any{"taskUUID": json.Number("9")}); got != "9" {
|
||||
t.Fatalf("numeric task UUID = %q", got)
|
||||
}
|
||||
if id, err := LatestTaskUUID(Page{}); err != nil || id != "" {
|
||||
t.Fatalf("empty latest = %q, %v", id, err)
|
||||
}
|
||||
if _, err := LatestTaskUUID(Page{Items: []map[string]any{{"startTime": 1}}}); err == nil {
|
||||
t.Fatal("latest missing UUID accepted")
|
||||
}
|
||||
latest, err := LatestTaskUUID(Page{Items: []map[string]any{
|
||||
{"uuid": "skip", "createTime": "bad"},
|
||||
{"uuid": "int", "createTime": 1},
|
||||
{"uuid": "int64", "gmtCreate": int64(2)},
|
||||
{"uuid": "number", "startTime": json.Number("3")},
|
||||
{"uuid": "rfc", "createTimeStart": time.Now().UTC().Format(time.RFC3339Nano)},
|
||||
}})
|
||||
if err != nil || latest != "rfc" {
|
||||
t.Fatalf("typed latest = %q, %v", latest, err)
|
||||
}
|
||||
if key, err := StableItemKey(map[string]any{"text": "same"}); err != nil || !strings.HasPrefix(key, "json:") {
|
||||
t.Fatalf("JSON stable key = %q, %v", key, err)
|
||||
}
|
||||
if _, err := StableItemKey(map[string]any{"bad": func() {}}); err == nil {
|
||||
t.Fatal("unencodable stable key accepted")
|
||||
}
|
||||
|
||||
for _, value := range []any{"value", json.Number("4"), float64(5), 6, int64(7)} {
|
||||
if got := stringField(map[string]any{"x": value}, "missing", "x"); got == "" {
|
||||
t.Fatalf("stringField(%#v) empty", value)
|
||||
}
|
||||
}
|
||||
if got := stringField(map[string]any{"x": true}, "x"); got != "" {
|
||||
t.Fatalf("unsupported string field = %q", got)
|
||||
}
|
||||
for _, value := range []any{float64(1), 2, int64(3), json.Number("4"), "5", "2026-08-11T00:00:00Z"} {
|
||||
if _, ok := timestamp(map[string]any{"x": value}, "x"); !ok {
|
||||
t.Fatalf("timestamp(%#v) rejected", value)
|
||||
}
|
||||
}
|
||||
if _, ok := timestamp(map[string]any{"x": json.Number("bad")}, "x"); ok {
|
||||
t.Fatal("invalid timestamp number accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesPagerCompletion(t *testing.T) {
|
||||
if _, err := CollectTranscript(nil, "", false, 1); err == nil {
|
||||
t.Fatal("nil transcript caller accepted")
|
||||
}
|
||||
defaulted, err := CollectTranscript(func(string) (map[string]any, error) {
|
||||
return map[string]any{"result": map[string]any{"paragraphList": []any{}}}, nil
|
||||
}, "", false, 0)
|
||||
if err != nil || !defaulted.Complete {
|
||||
t.Fatalf("default max pages = %#v, %v", defaulted, err)
|
||||
}
|
||||
invalid, err := CollectTranscript(func(string) (map[string]any, error) { return map[string]any{}, nil }, "", false, 1)
|
||||
if err == nil || invalid.FailurePage != 1 || invalid.FailureMessage == "" {
|
||||
t.Fatalf("invalid page = %#v, %v", invalid, err)
|
||||
}
|
||||
badKey, err := CollectTranscript(func(string) (map[string]any, error) {
|
||||
return map[string]any{"result": map[string]any{"paragraphList": []any{map[string]any{"bad": func() {}}}}}, nil
|
||||
}, "", false, 1)
|
||||
if err == nil || badKey.FailurePage != 1 {
|
||||
t.Fatalf("bad key = %#v, %v", badKey, err)
|
||||
}
|
||||
one, err := CollectTranscript(func(string) (map[string]any, error) {
|
||||
return map[string]any{"result": map[string]any{"paragraphList": []any{}, "hasNext": false}}, nil
|
||||
}, "", true, 1)
|
||||
if err != nil || !one.Complete {
|
||||
t.Fatalf("complete single page = %#v, %v", one, err)
|
||||
}
|
||||
limited, err := CollectTranscript(func(token string) (map[string]any, error) {
|
||||
return map[string]any{"result": map[string]any{"paragraphList": []any{}, "hasNext": true, "nextToken": token + "n"}}, nil
|
||||
}, "", false, 1)
|
||||
if err == nil || limited.FailurePage != 2 || limited.FailureMessage == "" {
|
||||
t.Fatalf("page limit = %#v, %v", limited, err)
|
||||
}
|
||||
payload := TranscriptPayload("u", "1", TranscriptResult{
|
||||
Paragraphs: []map[string]any{{"id": "p"}}, Pages: 1, Duplicates: 2,
|
||||
NextToken: "n", FailurePage: 2, FailureMessage: "failed",
|
||||
})
|
||||
if payload["nextToken"] != "n" || payload["failurePage"] != 2 || payload["failure"] != "failed" {
|
||||
t.Fatalf("transcript payload = %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesWorkflowCompletion(t *testing.T) {
|
||||
for _, data := range []map[string]any{{}, {"other": true}, {"success": "yes"}, {"success": false}} {
|
||||
if err := RequireWriteAcknowledgement("write", data); err == nil {
|
||||
t.Fatalf("invalid acknowledgement accepted: %#v", data)
|
||||
}
|
||||
}
|
||||
if err := RequireWriteAcknowledgement("write", map[string]any{"result": map[string]any{"updated": true}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
cmd, id string
|
||||
data map[string]any
|
||||
}{
|
||||
{"pause", "u", map[string]any{}},
|
||||
{"pause", "u", map[string]any{"success": true}},
|
||||
{"pause", "u", map[string]any{"success": true, "result": map[string]any{"cmd": "resume", "uuid": "u"}}},
|
||||
{"pause", "u", map[string]any{"success": true, "result": map[string]any{"cmd": "pause", "uuid": "other"}}},
|
||||
} {
|
||||
if _, err := RecordResult(tc.cmd, tc.id, tc.data); err == nil {
|
||||
t.Fatalf("invalid record accepted: %#v", tc)
|
||||
}
|
||||
}
|
||||
if _, err := RecordResult("create", "", map[string]any{"success": true, "result": map[string]any{"cmd": "create"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, data := range []map[string]any{{}, {"result": "bad"}, {"result": map[string]any{}}, {"result": map[string]any{"taskStatus": 3}}} {
|
||||
if _, _, err := MindGraphStatus(data); err == nil {
|
||||
t.Fatalf("invalid mind graph accepted: %#v", data)
|
||||
}
|
||||
}
|
||||
for _, value := range []any{0, "1", json.Number("2")} {
|
||||
status, _, err := MindGraphStatus(map[string]any{"result": map[string]any{"taskStatus": value}})
|
||||
if err != nil || status < 0 || status > 2 {
|
||||
t.Fatalf("mind status %#v = %d, %v", value, status, err)
|
||||
}
|
||||
}
|
||||
for _, data := range []map[string]any{{}, {"success": true}, {"success": true, "result": map[string]any{}}} {
|
||||
if _, _, err := SpeakerSummaryTask(data); err == nil {
|
||||
t.Fatalf("invalid speaker task accepted: %#v", data)
|
||||
}
|
||||
}
|
||||
if id, status, err := SpeakerSummaryTask(map[string]any{"result": map[string]any{"taskID": int64(7), "status": "READY"}}); err != nil || id != "7" || status != "ready" {
|
||||
t.Fatalf("speaker task = %q/%q, %v", id, status, err)
|
||||
}
|
||||
for _, data := range []map[string]any{{}, {"result": nil}, {"result": []any(nil)}, {"result": "bad"}} {
|
||||
if _, err := SpeakerSummaryResult(data); err == nil {
|
||||
t.Fatalf("invalid speaker result accepted: %#v", data)
|
||||
}
|
||||
}
|
||||
if value, err := SpeakerSummaryResult(map[string]any{"result": []any{}}); err != nil || value == nil {
|
||||
t.Fatalf("empty speaker list = %#v, %v", value, err)
|
||||
}
|
||||
if value, err := SpeakerSummaryResult(map[string]any{"result": map[string]any{"summary": "ok"}}); err != nil || value == nil {
|
||||
t.Fatalf("speaker map = %#v, %v", value, err)
|
||||
}
|
||||
for _, data := range []map[string]any{{}, {"result": "bad"}, {"result": map[string]any{}}, {"result": map[string]any{"hotWordList": "bad"}}, {"result": map[string]any{"hotWordList": []any{true}}}} {
|
||||
if _, err := HotWords(data); err == nil {
|
||||
t.Fatalf("invalid hot words accepted: %#v", data)
|
||||
}
|
||||
}
|
||||
words, err := HotWords(map[string]any{"result": map[string]any{"hotWordList": []any{" A ", "A", map[string]any{"content": "B"}}}})
|
||||
if err != nil || len(words) != 2 {
|
||||
t.Fatalf("deduplicated hot words = %#v, %v", words, err)
|
||||
}
|
||||
for _, value := range []any{math.NaN(), math.Inf(1), 1.5, float64(-1), float64(3), json.Number("bad"), "bad", true} {
|
||||
if _, err := intValue(value); err == nil {
|
||||
t.Fatalf("invalid int value accepted: %#v", value)
|
||||
}
|
||||
}
|
||||
if _, err := intValue(int64(1)); err == nil || !strings.Contains(err.Error(), "unsupported") {
|
||||
t.Fatalf("unsupported int64 result = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package minutesdata
|
||||
|
||||
import "fmt"
|
||||
|
||||
// TranscriptCaller fetches one transcript page for the supplied nextToken.
|
||||
// The empty token means the first page.
|
||||
type TranscriptCaller func(nextToken string) (map[string]any, error)
|
||||
|
||||
// TranscriptResult is the complete or partial result of transcript pagination.
|
||||
type TranscriptResult struct {
|
||||
Paragraphs []map[string]any
|
||||
Pages int
|
||||
Duplicates int
|
||||
Complete bool
|
||||
NextToken string
|
||||
FailurePage int
|
||||
FailureMessage string
|
||||
}
|
||||
|
||||
// CollectTranscript follows hasNext/nextToken, de-duplicates paragraphs and
|
||||
// fails closed on cursor drift. When a later call fails it returns the partial
|
||||
// paragraphs plus the error so callers can publish an honest recovery ledger.
|
||||
func CollectTranscript(call TranscriptCaller, initialToken string, singlePage bool, maxPages int) (TranscriptResult, error) {
|
||||
if call == nil {
|
||||
return TranscriptResult{}, fmt.Errorf("minutes transcript caller is nil")
|
||||
}
|
||||
if maxPages <= 0 {
|
||||
maxPages = 1000
|
||||
}
|
||||
result := TranscriptResult{Complete: false, NextToken: initialToken}
|
||||
seenTokens := map[string]bool{}
|
||||
seenItems := map[string]bool{}
|
||||
token := initialToken
|
||||
for pageIndex := 1; pageIndex <= maxPages; pageIndex++ {
|
||||
if seenTokens[token] {
|
||||
result.NextToken = token
|
||||
result.FailurePage = pageIndex
|
||||
result.FailureMessage = "cursor stalled or cycled"
|
||||
return result, fmt.Errorf("minutes transcript cursor stalled or cycled at page %d", pageIndex)
|
||||
}
|
||||
seenTokens[token] = true
|
||||
data, err := call(token)
|
||||
if err != nil {
|
||||
result.NextToken = token
|
||||
result.FailurePage = pageIndex
|
||||
result.FailureMessage = err.Error()
|
||||
return result, fmt.Errorf("fetch minutes transcript page %d: %w", pageIndex, err)
|
||||
}
|
||||
page, err := ParseTranscriptPage(data)
|
||||
if err != nil {
|
||||
result.NextToken = token
|
||||
result.FailurePage = pageIndex
|
||||
result.FailureMessage = err.Error()
|
||||
return result, fmt.Errorf("validate minutes transcript page %d: %w", pageIndex, err)
|
||||
}
|
||||
result.Pages++
|
||||
for _, item := range page.Items {
|
||||
key, err := StableItemKey(item)
|
||||
if err != nil {
|
||||
result.FailurePage = pageIndex
|
||||
result.FailureMessage = err.Error()
|
||||
return result, err
|
||||
}
|
||||
if seenItems[key] {
|
||||
result.Duplicates++
|
||||
continue
|
||||
}
|
||||
seenItems[key] = true
|
||||
result.Paragraphs = append(result.Paragraphs, item)
|
||||
}
|
||||
result.NextToken = page.NextToken
|
||||
if singlePage {
|
||||
result.Complete = !page.HasMore
|
||||
return result, nil
|
||||
}
|
||||
if !page.HasMore {
|
||||
result.Complete = true
|
||||
result.NextToken = ""
|
||||
return result, nil
|
||||
}
|
||||
token = page.NextToken
|
||||
}
|
||||
result.FailurePage = maxPages + 1
|
||||
result.FailureMessage = "page safety limit exceeded"
|
||||
return result, fmt.Errorf("minutes transcript exceeded page safety limit %d", maxPages)
|
||||
}
|
||||
|
||||
// TranscriptPayload converts a pagination result into the stable public view.
|
||||
func TranscriptPayload(taskUUID, direction string, result TranscriptResult) map[string]any {
|
||||
paragraphs := make([]any, 0, len(result.Paragraphs))
|
||||
for _, paragraph := range result.Paragraphs {
|
||||
paragraphs = append(paragraphs, paragraph)
|
||||
}
|
||||
payload := map[string]any{
|
||||
"taskUuid": taskUUID,
|
||||
"direction": direction,
|
||||
"complete": result.Complete,
|
||||
"pages": result.Pages,
|
||||
"paragraphCount": len(paragraphs),
|
||||
"duplicateCount": result.Duplicates,
|
||||
"paragraphList": paragraphs,
|
||||
}
|
||||
if result.NextToken != "" {
|
||||
payload["nextToken"] = result.NextToken
|
||||
}
|
||||
if result.FailurePage > 0 {
|
||||
payload["failurePage"] = result.FailurePage
|
||||
}
|
||||
if result.FailureMessage != "" {
|
||||
payload["failure"] = result.FailureMessage
|
||||
}
|
||||
return payload
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package minutesdata
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageTranscriptCollectsPagesAndDeduplicatesE2E(t *testing.T) {
|
||||
calls := []string{}
|
||||
result, err := CollectTranscript(func(token string) (map[string]any, error) {
|
||||
calls = append(calls, token)
|
||||
if token == "" {
|
||||
return decode(t, `{"result":{"paragraphList":[{"paragraphId":"p1"},{"paragraphId":"p2"}],"hasNext":true,"nextToken":"n2"}}`), nil
|
||||
}
|
||||
return decode(t, `{"result":{"paragraphList":[{"paragraphId":"p2"},{"paragraphId":"p3"}],"hasNext":false,"nextToken":""}}`), nil
|
||||
}, "", false, 10)
|
||||
if err != nil || !result.Complete || result.Pages != 2 || len(result.Paragraphs) != 3 || result.Duplicates != 1 || fmt.Sprint(calls) != "[ n2]" {
|
||||
t.Fatalf("collect = %#v calls=%v err=%v", result, calls, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTranscriptPartialReadNeverBecomesSuccessE2E(t *testing.T) {
|
||||
result, err := CollectTranscript(func(token string) (map[string]any, error) {
|
||||
if token == "" {
|
||||
return decode(t, `{"result":{"paragraphList":[{"paragraphId":"p1"}],"hasNext":true,"nextToken":"n2"}}`), nil
|
||||
}
|
||||
return nil, errors.New("backend failed")
|
||||
}, "", false, 10)
|
||||
if err == nil || result.Complete || result.Pages != 1 || len(result.Paragraphs) != 1 || result.FailurePage != 2 {
|
||||
t.Fatalf("partial read = %#v err=%v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTranscriptCursorCycleAndSinglePageE2E(t *testing.T) {
|
||||
cycle, err := CollectTranscript(func(string) (map[string]any, error) {
|
||||
return decode(t, `{"result":{"paragraphList":[],"hasNext":true,"nextToken":"same"}}`), nil
|
||||
}, "same", false, 10)
|
||||
if err == nil || cycle.Complete {
|
||||
t.Fatalf("cursor cycle = %#v err=%v", cycle, err)
|
||||
}
|
||||
one, err := CollectTranscript(func(string) (map[string]any, error) {
|
||||
return decode(t, `{"result":{"paragraphList":[{"paragraphId":"p1"}],"hasNext":true,"nextToken":"n2"}}`), nil
|
||||
}, "", true, 10)
|
||||
if err != nil || one.Complete || one.Pages != 1 || one.NextToken != "n2" {
|
||||
t.Fatalf("single page = %#v err=%v", one, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
// Package minutesdata owns response validation and pagination facts shared by
|
||||
// Minutes shortcuts. A missing or malformed collection is never treated as an
|
||||
// empty successful result.
|
||||
package minutesdata
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Page is one validated Minutes collection page.
|
||||
type Page struct {
|
||||
Items []map[string]any
|
||||
HasMore bool
|
||||
HasMoreKnown bool
|
||||
NextToken string
|
||||
}
|
||||
|
||||
var listKeys = []string{"itemList", "minutesList", "items", "list", "records", "dataList"}
|
||||
|
||||
// ParseListPage validates list_by_keyword_and_time_range and returns its
|
||||
// explicit collection. An explicit [] is success; an absent/null/wrongly typed
|
||||
// collection is a contract error.
|
||||
func ParseListPage(data map[string]any) (Page, error) {
|
||||
if err := validateEnvelope(data); err != nil {
|
||||
return Page{}, err
|
||||
}
|
||||
containers := collectionContainers(data)
|
||||
for _, container := range containers {
|
||||
for _, key := range listKeys {
|
||||
value, exists := container[key]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
items, err := mapItems(value, key)
|
||||
if err != nil {
|
||||
return Page{}, err
|
||||
}
|
||||
hasMore, known := boolField(container, "hasMore", "hasNext")
|
||||
if !known {
|
||||
hasMore, known = boolField(data, "hasMore", "hasNext")
|
||||
}
|
||||
next := stringField(container, "nextToken", "nextCursor", "pageToken")
|
||||
if next == "" {
|
||||
next = stringField(data, "nextToken", "nextCursor", "pageToken")
|
||||
}
|
||||
if hasMore && next == "" {
|
||||
return Page{}, fmt.Errorf("minutes list declares another page but omits nextToken")
|
||||
}
|
||||
return Page{Items: items, HasMore: hasMore, HasMoreKnown: known, NextToken: next}, nil
|
||||
}
|
||||
}
|
||||
return Page{}, fmt.Errorf("minutes list response has no recognized collection (want result.itemList)")
|
||||
}
|
||||
|
||||
// ParseTranscriptPage validates get_minutes_transcription. The real response
|
||||
// collection is result.paragraphList and pagination uses hasNext/nextToken.
|
||||
func ParseTranscriptPage(data map[string]any) (Page, error) {
|
||||
if err := validateEnvelope(data); err != nil {
|
||||
return Page{}, err
|
||||
}
|
||||
containers := collectionContainers(data)
|
||||
for _, container := range containers {
|
||||
value, exists := container["paragraphList"]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
items, err := mapItems(value, "paragraphList")
|
||||
if err != nil {
|
||||
return Page{}, err
|
||||
}
|
||||
hasMore, known := boolField(container, "hasNext", "hasMore")
|
||||
if !known {
|
||||
hasMore, known = boolField(data, "hasNext", "hasMore")
|
||||
}
|
||||
next := stringField(container, "nextToken", "nextCursor")
|
||||
if next == "" {
|
||||
next = stringField(data, "nextToken", "nextCursor")
|
||||
}
|
||||
if hasMore && next == "" {
|
||||
return Page{}, fmt.Errorf("minutes transcript declares another page but omits nextToken")
|
||||
}
|
||||
return Page{Items: items, HasMore: hasMore, HasMoreKnown: known, NextToken: next}, nil
|
||||
}
|
||||
return Page{}, fmt.Errorf("minutes transcript response has no result.paragraphList collection")
|
||||
}
|
||||
|
||||
// ValidateArtifact rejects successful-looking but structurally empty artifact
|
||||
// responses. This is intentionally artifact-specific: a generic {} cannot
|
||||
// prove that the requested Minutes business data was returned.
|
||||
func ValidateArtifact(name, taskUUID string, data map[string]any) error {
|
||||
if err := validateEnvelope(data); err != nil {
|
||||
return err
|
||||
}
|
||||
result, ok := data["result"].(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("minutes %s response has no result object", name)
|
||||
}
|
||||
switch name {
|
||||
case "basic":
|
||||
actual := TaskUUID(result)
|
||||
if actual == "" {
|
||||
return fmt.Errorf("minutes basic response has no task UUID")
|
||||
}
|
||||
if taskUUID != "" && actual != taskUUID {
|
||||
return fmt.Errorf("minutes basic response task UUID mismatch")
|
||||
}
|
||||
case "summary":
|
||||
if _, exists := result["fullSummary"]; !exists {
|
||||
return fmt.Errorf("minutes summary response has no fullSummary field")
|
||||
}
|
||||
case "keywords":
|
||||
if _, err := mapSliceField(result, "keywords"); err != nil {
|
||||
return fmt.Errorf("minutes keywords response: %w", err)
|
||||
}
|
||||
case "todos":
|
||||
if _, actionsErr := mapSliceField(result, "actions"); actionsErr != nil {
|
||||
if _, todosErr := mapSliceField(result, "dingtalkTodoList"); todosErr != nil {
|
||||
return fmt.Errorf("minutes todos response has neither actions nor dingtalkTodoList array")
|
||||
}
|
||||
}
|
||||
default:
|
||||
if len(result) == 0 {
|
||||
return fmt.Errorf("minutes %s response result is empty", name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateEnvelope exposes the common backend failure check for composite
|
||||
// write/read steps that apply their own operation-specific shape validation.
|
||||
func ValidateEnvelope(data map[string]any) error {
|
||||
return validateEnvelope(data)
|
||||
}
|
||||
|
||||
// Basic returns the validated basic-info result.
|
||||
func Basic(taskUUID string, data map[string]any) (map[string]any, error) {
|
||||
if err := ValidateArtifact("basic", taskUUID, data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return data["result"].(map[string]any), nil
|
||||
}
|
||||
|
||||
// SummaryText returns the full summary, including an explicitly empty string.
|
||||
func SummaryText(data map[string]any) (string, error) {
|
||||
if err := ValidateArtifact("summary", "", data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
value, ok := data["result"].(map[string]any)["fullSummary"].(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("minutes summary fullSummary has type %T, want string", data["result"].(map[string]any)["fullSummary"])
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// MediaURL returns the actual audio/video download URL. An empty result is not
|
||||
// accepted as "no media" because it cannot distinguish processing, expiry and
|
||||
// permission failures.
|
||||
func MediaURL(data map[string]any) (string, error) {
|
||||
if err := validateEnvelope(data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
result, ok := data["result"].(map[string]any)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("minutes media response has no result object")
|
||||
}
|
||||
value := stringField(result, "videoUrl", "audioUrl", "downloadUrl", "url")
|
||||
if value == "" {
|
||||
return "", fmt.Errorf("minutes media response has no audio/video URL")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// UploadSession extracts the two facts required to continue a local upload.
|
||||
func UploadSession(data map[string]any) (sessionID, presignedURL string, err error) {
|
||||
if err := validateEnvelope(data); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
result, ok := data["result"].(map[string]any)
|
||||
if !ok {
|
||||
return "", "", fmt.Errorf("minutes upload create response has no result object")
|
||||
}
|
||||
sessionID = stringField(result, "sessionId", "uploadId")
|
||||
presignedURL = stringField(result, "presignedUrl", "uploadUrl")
|
||||
if sessionID == "" || presignedURL == "" {
|
||||
return "", "", fmt.Errorf("minutes upload create response is missing sessionId or presignedUrl")
|
||||
}
|
||||
return sessionID, presignedURL, nil
|
||||
}
|
||||
|
||||
// CompletedTaskUUID extracts the created Minutes task UUID from upload complete.
|
||||
func CompletedTaskUUID(data map[string]any) (string, error) {
|
||||
if err := validateEnvelope(data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
containers := collectionContainers(data)
|
||||
for _, container := range containers {
|
||||
if uuid := TaskUUID(container); uuid != "" {
|
||||
return uuid, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("minutes upload complete response has no task UUID")
|
||||
}
|
||||
|
||||
// ProjectList converts validated list items into the stable Shortcut view.
|
||||
// Every row must carry a real task UUID; malformed rows fail the entire page
|
||||
// instead of disappearing and masquerading as an empty result.
|
||||
func ProjectList(page Page) ([]map[string]any, error) {
|
||||
out := make([]map[string]any, 0, len(page.Items))
|
||||
for index, item := range page.Items {
|
||||
uuid := TaskUUID(item)
|
||||
if uuid == "" {
|
||||
return nil, fmt.Errorf("minutes list item %d has no task UUID", index)
|
||||
}
|
||||
row := map[string]any{"taskUuid": uuid}
|
||||
copyFirst(row, "title", item, "title", "name")
|
||||
copyFirst(row, "creator", item, "creator", "creatorName", "createUserName", "creatorNick")
|
||||
copyFirst(row, "startTime", item, "startTime", "gmtStart", "beginTime", "createTime")
|
||||
copyFirst(row, "endTime", item, "endTime", "gmtEnd", "deadline")
|
||||
copyFirst(row, "url", item, "url", "shareUrl", "link")
|
||||
copyFirst(row, "status", item, "status", "taskStatus", "state")
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// TaskUUID returns only spellings known to identify a Minutes task. A generic
|
||||
// id/minutesId is deliberately not accepted because record-control consumes
|
||||
// this value directly as the task UUID.
|
||||
func TaskUUID(item map[string]any) string {
|
||||
return stringField(item, "taskUuid", "taskUUID", "task_uuid", "uuid")
|
||||
}
|
||||
|
||||
// LatestTaskUUID selects the newest item by an explicit comparable timestamp.
|
||||
// It never silently assumes that an undocumented response order is newest-first.
|
||||
func LatestTaskUUID(page Page) (string, error) {
|
||||
if len(page.Items) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
bestUUID := ""
|
||||
var bestTime float64
|
||||
haveTime := false
|
||||
for index, item := range page.Items {
|
||||
uuid := TaskUUID(item)
|
||||
if uuid == "" {
|
||||
return "", fmt.Errorf("minutes list item %d has no task UUID", index)
|
||||
}
|
||||
value, ok := timestamp(item, "createTime", "gmtCreate", "startTime", "createTimeStart")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if !haveTime || value > bestTime {
|
||||
haveTime = true
|
||||
bestTime = value
|
||||
bestUUID = uuid
|
||||
}
|
||||
}
|
||||
if !haveTime {
|
||||
return "", fmt.Errorf("minutes list is non-empty but no item has a comparable creation/start time")
|
||||
}
|
||||
return bestUUID, nil
|
||||
}
|
||||
|
||||
// StableItemKey returns a deterministic de-duplication key for a transcript
|
||||
// paragraph. Stable IDs win; otherwise the canonical JSON object is used.
|
||||
func StableItemKey(item map[string]any) (string, error) {
|
||||
if id := stringField(item, "paragraphId", "sentenceId", "segmentId", "id"); id != "" {
|
||||
return "id:" + id, nil
|
||||
}
|
||||
raw, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("encode transcript paragraph key: %w", err)
|
||||
}
|
||||
return "json:" + string(raw), nil
|
||||
}
|
||||
|
||||
func validateEnvelope(data map[string]any) error {
|
||||
if len(data) == 0 {
|
||||
return fmt.Errorf("minutes response is empty")
|
||||
}
|
||||
if success, ok := data["success"].(bool); ok && !success {
|
||||
return fmt.Errorf("minutes backend reported failure: %s", envelopeMessage(data))
|
||||
}
|
||||
for _, key := range []string{"errorCode", "dingOpenErrcode"} {
|
||||
value, exists := data[key]
|
||||
if !exists || isZeroCode(value) {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("minutes backend reported %s=%v: %s", key, value, envelopeMessage(data))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func collectionContainers(data map[string]any) []map[string]any {
|
||||
containers := []map[string]any{data}
|
||||
for _, key := range []string{"result", "data"} {
|
||||
if inner, ok := data[key].(map[string]any); ok {
|
||||
containers = append(containers, inner)
|
||||
for _, nestedKey := range []string{"result", "data"} {
|
||||
if nested, ok := inner[nestedKey].(map[string]any); ok {
|
||||
containers = append(containers, nested)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return containers
|
||||
}
|
||||
|
||||
func mapItems(value any, field string) ([]map[string]any, error) {
|
||||
raw, ok := value.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("minutes response field %s has type %T, want array", field, value)
|
||||
}
|
||||
items := make([]map[string]any, 0, len(raw))
|
||||
for index, value := range raw {
|
||||
item, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("minutes response field %s item %d has type %T, want object", field, index, value)
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func mapSliceField(data map[string]any, field string) ([]any, error) {
|
||||
value, exists := data[field]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("missing %s", field)
|
||||
}
|
||||
items, ok := value.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("field %s has type %T, want array", field, value)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func copyFirst(dst map[string]any, dstKey string, src map[string]any, keys ...string) {
|
||||
for _, key := range keys {
|
||||
if value, ok := src[key]; ok && value != nil {
|
||||
dst[dstKey] = value
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func boolField(data map[string]any, keys ...string) (bool, bool) {
|
||||
for _, key := range keys {
|
||||
if value, ok := data[key].(bool); ok {
|
||||
return value, true
|
||||
}
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
|
||||
func stringField(data map[string]any, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
value, exists := data[key]
|
||||
if !exists || value == nil {
|
||||
continue
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
if strings.TrimSpace(typed) != "" {
|
||||
return strings.TrimSpace(typed)
|
||||
}
|
||||
case json.Number:
|
||||
return typed.String()
|
||||
case float64:
|
||||
return strconv.FormatFloat(typed, 'f', -1, 64)
|
||||
case int:
|
||||
return strconv.Itoa(typed)
|
||||
case int64:
|
||||
return strconv.FormatInt(typed, 10)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func timestamp(data map[string]any, keys ...string) (float64, bool) {
|
||||
for _, key := range keys {
|
||||
value, exists := data[key]
|
||||
if !exists || value == nil {
|
||||
continue
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
return typed, true
|
||||
case int:
|
||||
return float64(typed), true
|
||||
case int64:
|
||||
return float64(typed), true
|
||||
case json.Number:
|
||||
if parsed, err := typed.Float64(); err == nil {
|
||||
return parsed, true
|
||||
}
|
||||
case string:
|
||||
if parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64); err == nil {
|
||||
return parsed, true
|
||||
}
|
||||
if parsed, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(typed)); err == nil {
|
||||
return float64(parsed.UnixNano()), true
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func isZeroCode(value any) bool {
|
||||
switch typed := value.(type) {
|
||||
case nil:
|
||||
return true
|
||||
case string:
|
||||
trimmed := strings.TrimSpace(typed)
|
||||
return trimmed == "" || trimmed == "0"
|
||||
case float64:
|
||||
return typed == 0
|
||||
case int:
|
||||
return typed == 0
|
||||
case int64:
|
||||
return typed == 0
|
||||
case json.Number:
|
||||
return typed.String() == "0"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func envelopeMessage(data map[string]any) string {
|
||||
for _, key := range []string{"errorMsg", "message", "msg"} {
|
||||
if value := stringField(data, key); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return "no backend error message"
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package minutesdata
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func decode(t *testing.T, raw string) map[string]any {
|
||||
t.Helper()
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||
t.Fatalf("decode fixture: %v", err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageListExplicitEmptyAndRealItemListE2E(t *testing.T) {
|
||||
empty, err := ParseListPage(decode(t, `{"success":true,"result":{"itemList":[],"hasMore":false,"nextToken":""}}`))
|
||||
if err != nil || len(empty.Items) != 0 || empty.HasMore {
|
||||
t.Fatalf("explicit empty = %#v, %v", empty, err)
|
||||
}
|
||||
page, err := ParseListPage(decode(t, `{"success":true,"result":{"itemList":[{"uuid":"u1","title":"n","startTime":"2026-08-10T01:00:00Z"}],"hasMore":true,"nextToken":"next"}}`))
|
||||
if err != nil || len(page.Items) != 1 || !page.HasMore || page.NextToken != "next" {
|
||||
t.Fatalf("real itemList = %#v, %v", page, err)
|
||||
}
|
||||
rows, err := ProjectList(page)
|
||||
if err != nil || len(rows) != 1 || rows[0]["taskUuid"] != "u1" {
|
||||
t.Fatalf("projection = %#v, %v", rows, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageListUnknownOrMalformedIsNotEmptySuccessE2E(t *testing.T) {
|
||||
tests := []string{
|
||||
`{}`,
|
||||
`{"success":true}`,
|
||||
`{"success":false,"errorMsg":"denied"}`,
|
||||
`{"success":true,"result":{"itemList":null}}`,
|
||||
`{"success":true,"result":{"itemList":{}}}`,
|
||||
`{"success":true,"result":{"itemList":["bad"]}}`,
|
||||
`{"success":true,"result":{"hasMore":true,"itemList":[]}}`,
|
||||
}
|
||||
for _, raw := range tests {
|
||||
if page, err := ParseListPage(decode(t, raw)); err == nil {
|
||||
t.Fatalf("unknown response accepted as %#v: %s", page, raw)
|
||||
}
|
||||
}
|
||||
page, err := ParseListPage(decode(t, `{"success":true,"result":{"itemList":[{"title":"missing uuid"}]}}`))
|
||||
if err != nil {
|
||||
t.Fatalf("parse list envelope: %v", err)
|
||||
}
|
||||
if rows, err := ProjectList(page); err == nil || rows != nil {
|
||||
t.Fatalf("malformed item projected: %#v, %v", rows, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTranscriptShapeAndPaginationContractE2E(t *testing.T) {
|
||||
page, err := ParseTranscriptPage(decode(t, `{"success":true,"result":{"paragraphList":[{"paragraphId":"p1"}],"hasNext":true,"nextToken":"n2"}}`))
|
||||
if err != nil || len(page.Items) != 1 || !page.HasMore || page.NextToken != "n2" {
|
||||
t.Fatalf("transcript page = %#v, %v", page, err)
|
||||
}
|
||||
if key, err := StableItemKey(page.Items[0]); err != nil || key != "id:p1" {
|
||||
t.Fatalf("stable key = %q, %v", key, err)
|
||||
}
|
||||
for _, raw := range []string{
|
||||
`{"success":true,"result":{"paragraphList":null}}`,
|
||||
`{"success":true,"result":{"paragraphList":[],"hasNext":true}}`,
|
||||
`{"success":true,"result":{}}`,
|
||||
} {
|
||||
if _, err := ParseTranscriptPage(decode(t, raw)); err == nil {
|
||||
t.Fatalf("invalid transcript accepted: %s", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLatestRequiresComparableTimestampE2E(t *testing.T) {
|
||||
page, err := ParseListPage(decode(t, `{"result":{"itemList":[{"uuid":"old","startTime":1},{"uuid":"new","startTime":"2"}]}}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := LatestTaskUUID(page); err != nil || got != "new" {
|
||||
t.Fatalf("latest = %q, %v", got, err)
|
||||
}
|
||||
page, err = ParseListPage(decode(t, `{"result":{"itemList":[{"uuid":"u"}]}}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := LatestTaskUUID(page); err == nil || !strings.Contains(err.Error(), "comparable") {
|
||||
t.Fatalf("missing time accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageArtifactEmptyResultIsNotSuccessE2E(t *testing.T) {
|
||||
valid := map[string]string{
|
||||
"basic": `{"success":true,"result":{"taskUuid":"u"}}`,
|
||||
"summary": `{"success":true,"result":{"fullSummary":""}}`,
|
||||
"keywords": `{"success":true,"result":{"keywords":[]}}`,
|
||||
"todos": `{"success":true,"result":{"actions":[]}}`,
|
||||
}
|
||||
for name, raw := range valid {
|
||||
if err := ValidateArtifact(name, "u", decode(t, raw)); err != nil {
|
||||
t.Fatalf("valid %s rejected: %v", name, err)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"basic", "summary", "keywords", "todos"} {
|
||||
if err := ValidateArtifact(name, "u", decode(t, `{"success":true,"result":{}}`)); err == nil {
|
||||
t.Fatalf("empty %s result accepted", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package minutesdata
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// RequireWriteAcknowledgement accepts only an explicit successful envelope or
|
||||
// a non-empty result object. A bare {} cannot prove a remote write succeeded.
|
||||
func RequireWriteAcknowledgement(operation string, data map[string]any) error {
|
||||
if err := validateEnvelope(data); err != nil {
|
||||
return err
|
||||
}
|
||||
if success, exists := data["success"]; exists {
|
||||
value, ok := success.(bool)
|
||||
if !ok || !value {
|
||||
return fmt.Errorf("minutes %s response has invalid success acknowledgement", operation)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if result, ok := data["result"].(map[string]any); ok && len(result) > 0 {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("minutes %s response has no explicit successful acknowledgement", operation)
|
||||
}
|
||||
|
||||
// RecordResult validates the gateway's observed listening-note command result.
|
||||
func RecordResult(expectedCmd, taskUUID string, data map[string]any) (map[string]any, error) {
|
||||
if err := RequireWriteAcknowledgement("record "+expectedCmd, data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, ok := data["result"].(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("minutes record %s response has no result object", expectedCmd)
|
||||
}
|
||||
cmd := stringField(result, "cmd")
|
||||
if cmd != expectedCmd {
|
||||
return nil, fmt.Errorf("minutes record response cmd %q does not match %q", cmd, expectedCmd)
|
||||
}
|
||||
if expectedCmd != "create" {
|
||||
actual := TaskUUID(result)
|
||||
if actual == "" || actual != taskUUID {
|
||||
return nil, fmt.Errorf("minutes record %s response task UUID mismatch", expectedCmd)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// MindGraphStatus extracts the documented 0=processing, 1=success, 2=failed
|
||||
// state and rejects empty status responses.
|
||||
func MindGraphStatus(data map[string]any) (int, map[string]any, error) {
|
||||
if err := validateEnvelope(data); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
result, ok := data["result"].(map[string]any)
|
||||
if !ok {
|
||||
return 0, nil, fmt.Errorf("minutes mind graph response has no result object")
|
||||
}
|
||||
value, exists := result["taskStatus"]
|
||||
if !exists {
|
||||
return 0, nil, fmt.Errorf("minutes mind graph response has no taskStatus")
|
||||
}
|
||||
status, err := intValue(value)
|
||||
if err != nil || status < 0 || status > 2 {
|
||||
return 0, nil, fmt.Errorf("minutes mind graph taskStatus is invalid: %v", value)
|
||||
}
|
||||
return status, result, nil
|
||||
}
|
||||
|
||||
// SpeakerSummaryTask extracts the recovery handle returned by task creation.
|
||||
func SpeakerSummaryTask(data map[string]any) (taskID, status string, err error) {
|
||||
if err := RequireWriteAcknowledgement("speaker summary create", data); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
result, ok := data["result"].(map[string]any)
|
||||
if !ok {
|
||||
return "", "", fmt.Errorf("minutes speaker summary create response has no result object")
|
||||
}
|
||||
taskID = stringField(result, "taskId", "taskID")
|
||||
status = strings.ToLower(stringField(result, "status"))
|
||||
if taskID == "" {
|
||||
return "", "", fmt.Errorf("minutes speaker summary create response has no taskId")
|
||||
}
|
||||
return taskID, status, nil
|
||||
}
|
||||
|
||||
// SpeakerSummaryResult requires a concrete result payload. The API returns a
|
||||
// business error while processing, so an empty object is never ready.
|
||||
func SpeakerSummaryResult(data map[string]any) (any, error) {
|
||||
if err := validateEnvelope(data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, exists := data["result"]
|
||||
if !exists || result == nil {
|
||||
return nil, fmt.Errorf("minutes speaker summary response has no result")
|
||||
}
|
||||
switch value := result.(type) {
|
||||
case map[string]any:
|
||||
if len(value) == 0 {
|
||||
return nil, fmt.Errorf("minutes speaker summary result is empty")
|
||||
}
|
||||
case []any:
|
||||
if value == nil {
|
||||
return nil, fmt.Errorf("minutes speaker summary result is null")
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("minutes speaker summary result has type %T", result)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// HotWords extracts the explicit current personal hot-word set. [] is valid;
|
||||
// a missing/null/wrongly typed list is not.
|
||||
func HotWords(data map[string]any) ([]string, error) {
|
||||
if err := validateEnvelope(data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, ok := data["result"].(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("minutes hot-word response has no result object")
|
||||
}
|
||||
raw, exists := result["hotWordList"]
|
||||
if !exists || raw == nil {
|
||||
return nil, fmt.Errorf("minutes hot-word response has no hotWordList array")
|
||||
}
|
||||
items, ok := raw.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("minutes hotWordList has type %T, want array", raw)
|
||||
}
|
||||
out := make([]string, 0, len(items))
|
||||
seen := map[string]bool{}
|
||||
for index, item := range items {
|
||||
word := ""
|
||||
switch value := item.(type) {
|
||||
case string:
|
||||
word = value
|
||||
case map[string]any:
|
||||
word = stringField(value, "hotWord", "word", "name", "content")
|
||||
}
|
||||
word = strings.TrimSpace(word)
|
||||
if word == "" {
|
||||
return nil, fmt.Errorf("minutes hotWordList item %d has no word", index)
|
||||
}
|
||||
if !seen[word] {
|
||||
seen[word] = true
|
||||
out = append(out, word)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func intValue(value any) (int, error) {
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
if math.IsNaN(typed) || math.IsInf(typed, 0) || math.Trunc(typed) != typed || typed < 0 || typed > 2 {
|
||||
return 0, fmt.Errorf("invalid status number %v", typed)
|
||||
}
|
||||
return int(typed), nil
|
||||
case int:
|
||||
return typed, nil
|
||||
case json.Number:
|
||||
parsed, err := strconv.Atoi(string(typed))
|
||||
return parsed, err
|
||||
case string:
|
||||
return strconv.Atoi(strings.TrimSpace(typed))
|
||||
default:
|
||||
return 0, fmt.Errorf("unsupported number type %T", value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package minutesdata
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageMinutesWorkflowShapesRejectUnknownE2E(t *testing.T) {
|
||||
if err := RequireWriteAcknowledgement("write", map[string]any{}); err == nil {
|
||||
t.Fatal("bare write response accepted")
|
||||
}
|
||||
if _, err := HotWords(map[string]any{"success": true, "result": map[string]any{}}); err == nil {
|
||||
t.Fatal("missing hot-word list accepted")
|
||||
}
|
||||
if _, _, err := MindGraphStatus(map[string]any{"success": true, "result": map[string]any{}}); err == nil {
|
||||
t.Fatal("missing mind graph status accepted")
|
||||
}
|
||||
if _, _, err := MindGraphStatus(map[string]any{"success": true, "result": map[string]any{"taskStatus": 1.5}}); err == nil {
|
||||
t.Fatal("fractional mind graph status accepted")
|
||||
}
|
||||
if _, err := SpeakerSummaryResult(map[string]any{"success": true, "result": map[string]any{}}); err == nil {
|
||||
t.Fatal("empty speaker summary accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesWorkflowObservedShapesE2E(t *testing.T) {
|
||||
record, err := RecordResult("pause", "u1", map[string]any{"success": true, "result": map[string]any{"cmd": "pause", "uuid": "u1"}})
|
||||
if err != nil || record["cmd"] != "pause" {
|
||||
t.Fatalf("record=%#v err=%v", record, err)
|
||||
}
|
||||
status, _, err := MindGraphStatus(map[string]any{"success": true, "result": map[string]any{"taskStatus": float64(1)}})
|
||||
if err != nil || status != 1 {
|
||||
t.Fatalf("mind status=%d err=%v", status, err)
|
||||
}
|
||||
status, _, err = MindGraphStatus(map[string]any{"success": true, "result": map[string]any{"taskStatus": json.Number("1")}})
|
||||
if err != nil || status != 1 {
|
||||
t.Fatalf("number-preserving mind status=%d err=%v", status, err)
|
||||
}
|
||||
taskID, state, err := SpeakerSummaryTask(map[string]any{"success": true, "result": map[string]any{"taskId": "job-1", "status": "processing"}})
|
||||
if err != nil || taskID != "job-1" || state != "processing" {
|
||||
t.Fatalf("speaker task=%q state=%q err=%v", taskID, state, err)
|
||||
}
|
||||
words, err := HotWords(map[string]any{"success": true, "result": map[string]any{"hotWordList": []any{"DWS", map[string]any{"hotWord": "听记"}}}})
|
||||
if err != nil || len(words) != 2 || words[1] != "听记" {
|
||||
t.Fatalf("words=%#v err=%v", words, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// 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 shortcut
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
const maxAutoPageDelayMS = int64(math.MaxInt64) / int64(time.Millisecond)
|
||||
|
||||
// AutoPageControlFlags returns the shared item and pacing controls used by
|
||||
// cursor-based shortcuts. Callers retain ownership of --page-all and their
|
||||
// product-specific --page-limit defaults.
|
||||
func AutoPageControlFlags() []Flag {
|
||||
const evidence = "--max-items/--page-delay 仅与 --page-all 一起使用;值必须大于等于 0"
|
||||
return []Flag{
|
||||
{Name: "max-items", Type: FlagInt, Desc: "自动翻页最多返回条数(默认 0 表示不限制)。" + evidence},
|
||||
{Name: "page-delay", Type: FlagInt, Desc: "自动翻页每页之间等待毫秒数(默认 0 表示不等待)。" + evidence},
|
||||
}
|
||||
}
|
||||
|
||||
// AutoPageControlConstraints publishes the runtime-only relationship between
|
||||
// the pagination switch and its shared controls into Help and Schema.
|
||||
func AutoPageControlConstraints() []Constraint {
|
||||
return []Constraint{{
|
||||
Kind: ConstraintCustom,
|
||||
Flags: []string{"page-all", "max-items", "page-delay"},
|
||||
Description: "--max-items/--page-delay 仅与 --page-all 一起使用;值必须大于等于 0",
|
||||
}}
|
||||
}
|
||||
|
||||
// ValidateAutoPageControls enforces the shared contract while preserving the
|
||||
// historical behavior that defaulted controls do not activate pagination.
|
||||
func ValidateAutoPageControls(rt *RuntimeContext) error {
|
||||
if !rt.Bool("page-all") {
|
||||
if rt.Changed("max-items") || rt.Changed("page-delay") {
|
||||
return fmt.Errorf("--max-items/--page-delay 仅与 --page-all 一起使用")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if rt.Int("max-items") < 0 {
|
||||
return fmt.Errorf("--max-items 必须大于等于 0")
|
||||
}
|
||||
delayMS := rt.Int("page-delay")
|
||||
if delayMS < 0 {
|
||||
return fmt.Errorf("--page-delay 必须大于等于 0")
|
||||
}
|
||||
if int64(delayMS) > maxAutoPageDelayMS {
|
||||
return fmt.Errorf("--page-delay 不能大于 %d 毫秒", maxAutoPageDelayMS)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AutoPageRequestSize caps the next lower-page request to the remaining item
|
||||
// budget. A cursor returned for that request is therefore safe to resume from:
|
||||
// the CLI did not intentionally discard a suffix of the lower page.
|
||||
func AutoPageRequestSize(rt *RuntimeContext, pageSize, itemCount int) int {
|
||||
maxItems := rt.Int("max-items")
|
||||
if maxItems <= 0 {
|
||||
return pageSize
|
||||
}
|
||||
remaining := maxItems - itemCount
|
||||
if remaining > 0 && remaining < pageSize {
|
||||
return remaining
|
||||
}
|
||||
return pageSize
|
||||
}
|
||||
|
||||
// WaitAutoPageDelay waits between successful pages and remains cancellable so
|
||||
// a throttled pagination run cannot ignore command cancellation.
|
||||
func WaitAutoPageDelay(rt *RuntimeContext) error {
|
||||
delayMS := rt.Int("page-delay")
|
||||
if delayMS <= 0 {
|
||||
return nil
|
||||
}
|
||||
timer := time.NewTimer(time.Duration(int64(delayMS)) * time.Millisecond)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-rt.Command().Context().Done():
|
||||
return rt.Command().Context().Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user