Compare commits

...
Author SHA1 Message Date
Dennis 0beb1c6b0c fix(whiteboard): compare readback numbers exactly 2026-08-23 00:54:46 +08:00
Dennis 3b38d4c8da docs(whiteboard): fix shortcut file source syntax 2026-08-23 00:30:58 +08:00
Dennis d68e340a5b fix(whiteboard): preserve interactive confirmation in examples 2026-08-23 00:30:56 +08:00
Dennis 98799effba fix(shortcuts): close sheet and whiteboard review gaps 2026-08-23 00:30:54 +08:00
Dennis 9239f9070a test(ci): share shortcut schema boundary fixture 2026-08-23 00:30:52 +08:00
Dennis 202c5ce697 feat(shortcuts): harden Sheet Whiteboard and Markdown routes 2026-08-23 00:30:49 +08:00
github-actions[bot] 8ab2ac5e7c Merge pull request #994 from FloralTide/codex/fix-event-shutdown-lifecycle
fix(event): clean up shutdown lifecycle
2026-08-21 19:24:44 +08:00
炳昱 b85a342e9f fix(npm): preserve interactive terminal ownership 2026-08-21 19:09:59 +08:00
炳昱 ad72cf4b3d fix(npm): signal the vendor process group 2026-08-21 18:15:19 +08:00
炳昱 89154b3952 fix(npm): avoid duplicate terminal signals 2026-08-21 17:39:48 +08:00
炳昱 b01febf52e Merge remote-tracking branch 'official-upstream/main' into codex/fix-event-shutdown-lifecycle 2026-08-21 17:23:25 +08:00
github-actions[bot] 74b7690cbb Merge pull request #1078 from liyuan333/feat/doc-read-public-and-history-version
feat(doc): read password-protected public docs and historical versions
2026-08-21 17:19:39 +08:00
liyuan333 8312c4f30e Merge branch 'main' into feat/doc-read-public-and-history-version 2026-08-21 16:50:25 +08:00
赤川 35c6fd95e1 Merge pull request #1092 from DingTalk-Real-AI/codex/add-secondary-dingtalk-webhook
ci: notify a secondary DingTalk webhook
2026-08-21 16:24:19 +08:00
chichuan 564ff8563f ci: notify a secondary DingTalk webhook 2026-08-21 16:22:46 +08:00
github-actions[bot] f7229091ae Merge pull request #1053 from anxiangbo/feat/20260817_agoal_search
Feat/20260817 agoal search
2026-08-21 07:50:26 +00:00
liyuan333 77aa813467 Merge branch 'main' into feat/doc-read-public-and-history-version 2026-08-21 15:42:53 +08:00
anxiangbo 1f595571c0 Merge branch 'main' into feat/20260817_agoal_search 2026-08-21 15:27:26 +08:00
github-actions[bot] cd90d1c322 Merge pull request #1075 from Justper/oa_attachment_upload_dws
Oa attachment upload dws
2026-08-21 14:46:26 +08:00
liyuan 49ab53ea0d 评审问题修复 2026-08-21 14:29:05 +08:00
昭逸 78433198fb Merge branch 'oa_attachment_upload_dws' of github.com:Justper/dingtalk-workspace-cli into oa_attachment_upload_dws
to #666
2026-08-21 14:23:27 +08:00
昭逸 4863152a4a Merge remote-tracking branch 'upstream/main' into oa_attachment_upload_dws
to #666
2026-08-21 14:20:33 +08:00
昭逸 2057fec3b0 fix(oa): normalize spaceId/fileSize to match ResultSpec integer declaration to #666
- validateOAAttachmentCommitResult 改为返回归一化后的 result map
- string 型 spaceId 通过 ParseInt 转 int64,json.Number 同理,非法字符串报错
- fileSize 的 json.Number 同样归一化为 int64
- 新增归一化行为测试 + 输出契约测试,覆盖率 100% to #666
2026-08-21 14:19:53 +08:00
anxiangbo 1308d08862 Merge branch 'DingTalk-Real-AI:main' into feat/20260817_agoal_search 2026-08-21 14:00:30 +08:00
github-actions[bot] 8b56e9bc9e Merge pull request #1073 from maoqxxmm/codex/sheet-revision-changeset
feat(sheet): add revision and changeset inspection
2026-08-21 05:56:10 +00:00
毛球 87e141f2de Merge branch 'main' into codex/sheet-revision-changeset 2026-08-21 13:38:56 +08:00
github-actions[bot] 9b521f0392 chore: update beta formula for v1.0.60-beta.1 [skip ci] 2026-08-21 05:17:50 +00:00
YanChangzhi 4dcd528bc1 Merge branch 'main' into oa_attachment_upload_dws 2026-08-21 13:11:34 +08:00
赤川 0bbb3a9d32 Merge pull request #1087 from DingTalk-Real-AI/codex/changelog-v1.0.60-beta.1
chore: prepare v1.0.60-beta.1 changelog
2026-08-21 12:48:22 +08:00
chichuan 0ebd840ba9 chore: prepare v1.0.60-beta.1 changelog 2026-08-21 12:35:38 +08:00
github-actions[bot] 9d356cd664 Merge pull request #1076 from hlzjsong/refresh_org_slot_fix
refresh org slot not only identity
2026-08-21 04:20:48 +00:00
YanChangzhi b00f43ee06 Merge branch 'main' into oa_attachment_upload_dws 2026-08-21 12:14:19 +08:00
赤川 23167ef974 Merge branch 'main' into refresh_org_slot_fix 2026-08-21 11:46:45 +08:00
毛球 8b003aef16 Merge branch 'main' into codex/sheet-revision-changeset 2026-08-21 11:43:45 +08:00
github-actions[bot] 11934eed05 Merge pull request #1081 from DingTalk-Real-AI/codex/fix-report-requiredness-governance
feat(policy): govern optional-to-required flag migrations
2026-08-21 11:40:11 +08:00
YanChangzhi 23e1085a37 Merge branch 'main' into oa_attachment_upload_dws 2026-08-21 11:16:29 +08:00
赤川 a352615e77 Merge branch 'main' into refresh_org_slot_fix 2026-08-21 11:15:06 +08:00
xiatian d210da501a Merge remote-tracking branch 'upstream/main' into codex/sheet-revision-changeset 2026-08-21 11:14:03 +08:00
anxiangbo 6d9781fe15 Merge branch 'main' into feat/20260817_agoal_search 2026-08-21 11:04:19 +08:00
xiatian 7640ba7614 fix(sheet): validate changeset audit integrity 2026-08-21 10:59:14 +08:00
anxiangbo c790fe3c3b Merge branch 'main' into feat/20260817_agoal_search 2026-08-21 10:44:53 +08:00
昭逸 4886bdb3f5 Merge remote-tracking branch 'upstream/main' into oa_attachment_upload_dws
to #666
2026-08-21 10:41:06 +08:00
昭逸 4aef07ccd3 fix(oa): validate commit response required fields before reporting success to #666
- 新增 validateOAAttachmentCommitResult 校验 spaceId/fileName/fileSize/fileId 必需字段
- commit 步不再使用通用 callOAAttachmentResultCtx,改为专用校验后才存储成功结果
- 补充 malformed commit 响应回归测试,覆盖率 100%
2026-08-21 10:40:46 +08:00
昭逸 dc37dd2c34 fix(oa): remove DDAttachment from unsupported table and use testseam.Swap to #666
- 从 oa-form-components.md (mono/multi) 的"API 不支持的控件"表中移除 DDAttachment,避免 Agent 误判为不支持
- computeFileMD5 测试注入改为 testseam.Swap,符合仓库包变量注入约定
2026-08-21 09:56:48 +08:00
hlzjsong 02aad9020a Merge branch 'main' into refresh_org_slot_fix 2026-08-21 09:26:39 +08:00
昭逸 d59d1091dc Merge remote-tracking branch 'upstream/main' into oa_attachment_upload_dws
to #666
2026-08-21 08:51:13 +08:00
昭逸 60d6bfeec4 Merge branch 'oa_attachment_upload_dws' of github.com:Justper/dingtalk-workspace-cli into oa_attachment_upload_dws
to #666
2026-08-21 08:50:28 +08:00
昭逸 bf89acb3d2 fix ci fail to #666 2026-08-21 08:50:13 +08:00
xiatian d24b71614a Merge remote-tracking branch 'upstream/main' into codex/sheet-revision-changeset 2026-08-21 01:23:17 +08:00
xiatian 9ab4bd10a5 Merge remote-tracking branch 'upstream/main' into codex/sheet-revision-changeset 2026-08-21 01:05:47 +08:00
赤川 01af71a5ae Merge branch 'main' into oa_attachment_upload_dws 2026-08-20 23:47:06 +08:00
赤川 47e3c2b3c5 Merge branch 'main' into refresh_org_slot_fix 2026-08-20 23:19:33 +08:00
xiatian e8ecff586a fix(sheet): classify malformed revision responses 2026-08-20 23:06:56 +08:00
xiatian 57cca7ef71 fix(sheet): validate revision result contracts 2026-08-20 22:02:26 +08:00
昭逸 4372a8c5ba Merge remote-tracking branch 'upstream/main' into oa_attachment_upload_dws
to #666
2026-08-20 21:44:16 +08:00
昭逸 422dc0fde3 fix ci fail to #666 2026-08-20 21:44:05 +08:00
muling.cs 3d59411a1a refresh slot repair org 2026-08-20 21:24:06 +08:00
毛球 f2a608d146 Merge branch 'main' into codex/sheet-revision-changeset 2026-08-20 20:44:17 +08:00
xiatian 43ba466783 fix(sheet): require fresh confirmation for version revert 2026-08-20 20:28:35 +08:00
xiatian 60a474f30c fix(sheet): fail closed on invalid revision results 2026-08-20 19:15:48 +08:00
xiatian 6e8fec5684 chore(policy): retire consumed flag migrations 2026-08-20 17:48:59 +08:00
liyuan 470aa42d7b chore: keep release note in .changes fragment, restore CHANGELOG.md 2026-08-20 17:35:03 +08:00
liyuan a74d96bb96 feat(doc): read password-protected public docs and historical versions 2026-08-20 17:32:11 +08:00
liyuan 9798a60728 feat(doc): read password-protected public docs and historical versions 2026-08-20 17:13:46 +08:00
毛球 e028d443d5 Merge branch 'main' into codex/sheet-revision-changeset 2026-08-20 17:11:29 +08:00
xiatian 76a5559ca2 fix(sheet): align revision dry-run contract 2026-08-20 16:59:18 +08:00
昭逸 c4a1018213 删除无关文件 to #666 2026-08-20 16:55:18 +08:00
muling.cs e95ac52ff4 refresh org slot not only identity 2026-08-20 16:41:35 +08:00
昭逸 09c0cd7849 merge uptream to #666 2026-08-20 16:26:32 +08:00
xiatian 5a368a9ab8 Merge remote-tracking branch 'upstream/main' into codex/sheet-revision-changeset 2026-08-20 16:11:37 +08:00
昭逸 7f4318a10d fix审批skill中附件描述 to #666 2026-08-20 15:39:14 +08:00
xiatian 5232f632c8 Merge remote-tracking branch 'upstream/main' into codex/sheet-revision-changeset 2026-08-20 15:35:19 +08:00
xiatian 615a775fdf feat(sheet): add revision changeset inspection 2026-08-20 15:34:45 +08:00
anxiangbo b10da77e09 Merge branch 'main' into feat/20260817_agoal_search 2026-08-20 15:22:19 +08:00
昭逸 cefc5c005c 附件上传dws合并为一个 to #666 2026-08-20 15:14:39 +08:00
昭逸 103b05413e 审批附件相关dws help补充 to #666 2026-08-20 10:40:35 +08:00
昭逸 169bbe88c0 上传附件dws to #666 2026-08-20 10:24:35 +08:00
anxiangbo 30782020ad Merge branch 'main' into feat/20260817_agoal_search 2026-08-20 10:03:21 +08:00
赤川 d87cdef00b Merge branch 'main' into codex/fix-event-shutdown-lifecycle 2026-08-19 20:04:42 +08:00
anxb 999e7a7b9d feat: agoal新增dws2 2026-08-19 15:58:28 +08:00
anxb 94cee4388e Merge remote-tracking branch 'refs/remotes/origin/main' into feat/20260817_agoal_search 2026-08-19 15:38:58 +08:00
anxb dcc7e72ec1 feat: agoal新增dws 2026-08-19 15:05:21 +08:00
炳昱 5a001f33b6 fix(event): clean up shutdown lifecycle 2026-08-13 17:34:20 +08:00
107 changed files with 8102 additions and 830 deletions

No files matched your search

@@ -0,0 +1,6 @@
---
category: Added
---
- **Whiteboard shortcuts** (#1082) — adds strict query and confirmed update workflows with stable-target receipts and exact readback verification.
- **Sheet shortcut hardening** (#1082) — makes worksheet listing and cell-range reads fail closed on malformed, ambiguous, or truncated responses, publishes a closed reviewed output shape, and preserves non-executing `--dry-run` previews for range reads.
@@ -0,0 +1,5 @@
---
category: Added
---
- **Agoal scorecard search-entities** — `dws agoal scorecard search-entities` searches scorecard metrics and key items by keyword, returning matching entity info (scorecard ID, entity ID, entity type, title, owning team) with optional `--page`/`--page-size` pagination.
@@ -0,0 +1,13 @@
---
category: Added
---
- **Doc public-link and historical-version reads** — `dws doc read` forwards
the reviewed `password` (internet-public documents with password protection)
and `historyVersion` (read content as of a listed historical version; `0`
denotes the document's initial version) parameters on the markdown, JSONML,
and scope read paths via `--password` / `--version`; `dws doc +fetch` gains
`--password` and `--version` with the same `historyVersion` forwarding, while
`--revision` stays rejected with explicit guidance: revision is the document
edit revision returned by JSONML reads for `+update --expected-revision`
conditional writes, not a historical version number.
@@ -0,0 +1,5 @@
---
category: Added
---
- **OA approval attachment upload** — `dws oa approval attachment upload --file <path>` uploads a local file as an approval attachment in one command: it initializes the upload credential (MCP `oa/init_attachment_upload_info`), HTTP PUTs the file to OSS, then commits it (MCP `oa/commit_attachment_upload_info`). `--file-name` defaults to the file's base name and `--md5` is auto-computed when omitted.
@@ -0,0 +1,11 @@
---
category: Fixed
---
- **OAuth refresh falls back to the organization mirror** — when the server rejects the
current identity's `refresh_token` with the reviewed `invalidParameter.authCode.notFound`
business code, `dws` now retries once with the still-valid token mirrored in the same
organization's slot (same corp, matching or backfilled user identity) before giving up,
and writes the rotated credential back to both the identity and the organization slots so
the fallback stays usable on later refreshes. Transient failures and direct-mode HTTP
rejections without a reviewed business code do not trigger the fallback.
+5
View File
@@ -0,0 +1,5 @@
---
category: Added
---
- **Sheet revision changesets** — adds read-only commands for querying the current workbook revision and reviewing Agent-readable changes between revisions, with guidance for distinguishing revisions from saved history versions and safely selecting rollback targets.
+15 -9
View File
@@ -14,9 +14,12 @@ jobs:
uses: actions/github-script@v7
with:
script: |
const webhook = process.env.DINGTALK_WEBHOOK;
if (!webhook) {
console.log('⚠️ DINGTALK_WEBHOOK not set, skipping notification');
const webhooks = [
process.env.DINGTALK_WEBHOOK,
process.env.DINGTALK_WEBHOOK_SECONDARY
].filter(Boolean);
if (webhooks.length === 0) {
console.log('⚠️ No DingTalk webhook configured, skipping notification');
return;
}
@@ -39,12 +42,15 @@ jobs:
}
};
await fetch(webhook, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(message)
});
await Promise.all(webhooks.map(webhook =>
fetch(webhook, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(message)
})
));
console.log('✅ DingTalk notification sent');
console.log(`✅ DingTalk notification sent to ${webhooks.length} webhook(s)`);
env:
DINGTALK_WEBHOOK: ${{ secrets.DINGTALK_WEBHOOK }}
DINGTALK_WEBHOOK_SECONDARY: ${{ secrets.DINGTALK_WEBHOOK_SECONDARY }}
+23
View File
@@ -6,6 +6,29 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
## [1.0.60-beta.1] - 2026-08-21
### Changed
- **OA, DING, and Report shortcuts** — hardens response, identity, pagination, and confirmation contracts; publishes verified form search, receiver status, and report read workflows while withholding shortcuts that lack trustworthy downstream evidence.
- **Stable release sealing** — directly preparing a stable release now renders and archives release fragments merged after its beta baseline, avoiding a forced extra beta solely to consume pending notes.
### Fixed
- **Calendar empty windows** (#1074) — returns a legitimate empty result when the service emits its exact exhausted empty-event sentinel.
- **Task update verification** (#1074) — compares due-time readback as exact milliseconds so committed updates are no longer reported as failures.
- **Comment reaction validation** (#1074) — narrows accepted reaction input to reviewed DingTalk emoji names and rejects Unicode emoji and unsupported names such as `like` and `heart` before the RPC.
- **OAuth refresh falls back to the organization mirror** — when the server rejects the
current identity's `refresh_token` with the reviewed `invalidParameter.authCode.notFound`
business code, `dws` now retries once with the still-valid token mirrored in the same
organization's slot (same corp, matching or backfilled user identity) before giving up,
and writes the rotated credential back to both the identity and the organization slots so
the fallback stays usable on later refreshes. Transient failures and direct-mode HTTP
rejections without a reviewed business code do not trigger the fallback.
## [1.0.59] - 2026-08-20
This release promotes the sealed `v1.0.59-beta.5` contents to stable.
+11 -11
View File
@@ -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.59-beta.5"
version "1.0.60-beta.1"
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.59-beta.5/dws-darwin-arm64.tar.gz"
sha256 "274d56599a8e33ccca86a139424cab95a54ba311d6b643bccb2d3e6608cd16b4"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-darwin-arm64.tar.gz"
sha256 "8ef11c79b5c86ec275dd82334232e7582f9e2ba99a66307d7681e42e8f53767b"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.5/dws-darwin-amd64.tar.gz"
sha256 "0a0a00a77ca24c102204cd6b7de3de58f406a7e0fad2dd965a4c1fe903c34f39"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-darwin-amd64.tar.gz"
sha256 "67612f1dac735984b026c7f8a0dc057beec4cdd029f0a97798bf90aa923eb2d3"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.5/dws-linux-arm64.tar.gz"
sha256 "1e7af6393979c2fa433af9207722989749f11ea8e09ff9bcd5696e505d6d7f88"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-linux-arm64.tar.gz"
sha256 "67a8d4f4e0a7d22a9cc53cb91d8c97ecd1152665ce669f68560d86cec5987dd2"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.5/dws-linux-amd64.tar.gz"
sha256 "4896e71a1417acc3d8834fa99f0e81511e020ff952e57c0562cc255e90acec80"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-linux-amd64.tar.gz"
sha256 "a5fae548b495842779df4291cbcf06d8a2e5ddddf68a41cad1bab1e5c64a1d59"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.5/dws-skills.zip"
sha256 "11000b9c3566e3b38e3037b6b3069d55c8f50725b3ed9cd67714a7ebb794cd47"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-skills.zip"
sha256 "9fe12683139a626d32a801dd44158a698f142b61339282e0fc24d4e3a5e97e87"
end
def install
+63 -5
View File
@@ -13,13 +13,71 @@ if (!fs.existsSync(binaryPath)) {
process.exit(1);
}
const result = childProcess.spawnSync(binaryPath, process.argv.slice(2), {
// Interactive commands must remain in the terminal's foreground session so
// prompts can use /dev/tty. Non-interactive launches use a separate process
// group, allowing a signal sent only to this wrapper to reach the full vendor
// process tree exactly once.
const isolateVendorProcessGroup = process.platform !== "win32" && !process.stdin.isTTY;
const child = childProcess.spawn(binaryPath, process.argv.slice(2), {
stdio: "inherit",
detached: isolateVendorProcessGroup,
});
if (result.error) {
console.error(result.error.message);
process.exit(1);
let spawnFailed = false;
let forwardedSignal = null;
const forwardedSignals = ["SIGINT", "SIGTERM"];
function forwardSignal(signal) {
forwardedSignal = signal;
if (child.exitCode === null && child.signalCode === null) {
if (process.platform === "win32") {
child.kill(signal);
return;
}
if (!isolateVendorProcessGroup) {
// Ctrl-C is generated for the whole foreground process group, including
// the vendor. SIGTERM is not terminal-generated and still needs an
// explicit handoff when a process manager targets only this wrapper.
if (signal === "SIGTERM") {
child.kill(signal);
}
return;
}
try {
// detached makes the vendor PID the leader of its POSIX process group.
// Signal the whole group so any subprocesses inherit the same shutdown.
process.kill(-child.pid, signal);
} catch (error) {
// The group may have completed between the state check and kill.
if (error.code !== "ESRCH") {
throw error;
}
}
}
}
process.exit(result.status === null ? 1 : result.status);
const signalHandlers = new Map(
forwardedSignals.map((signal) => [signal, () => forwardSignal(signal)]),
);
for (const signal of forwardedSignals) {
process.on(signal, signalHandlers.get(signal));
}
child.on("error", (error) => {
spawnFailed = true;
console.error(error.message);
});
child.on("close", (code, signal) => {
for (const forwarded of forwardedSignals) {
process.removeListener(forwarded, signalHandlers.get(forwarded));
}
const exitSignal = forwardedSignal || signal;
if (exitSignal && process.platform !== "win32") {
process.kill(process.pid, exitSignal);
return;
}
process.exitCode = spawnFailed || code === null ? 1 : code;
});
@@ -1,310 +0,0 @@
# Attendance Shortcut 下游业务能力需求规格
> 日期:2026-08-18
> Rebased executable 基线:`69bda96e49c7a478729b5f9232677fd9055e5d7d`;最终 clean PR HEAD 的 live SHA 与发布复核结果记录在 PR 证据中
> 对比基线:Lark CLI 1.0.87
> 范围:Attendance Shortcut only;不改 DWS 产品 Skill 的路由、流程或业务逻辑。仓库 policy 强制的可见 Shortcut 自动生成块单独机械同步。
## 1. 执行摘要
- Attendance 共审核 35 个源码 Shortcut;8 个具备 Agent 公开条件,27 个保持 unavailable。为守住已发布 CLI 的 argv/Help 兼容,其中 11 个历史可见入口继续以 compatibility-visible 形式可发现,但仍从 Agent public Catalog 排除、保持 legacy 输出且不发布 Result/Pagination;其余 16 个保持 hidden。公开数量按「严格响应合同 + 稳定身份 + 安全真实 fixture」的发布门计算,不把空数组或仅退出码 0 计为通过。
- 这 11 个 compatibility-visible 入口在完整 Schema 中保留历史 `availability=available` 与既有 workflow property,仅表示旧调用仍可执行;它们的 Shortcut 语义状态仍为 `public=false/unavailable`,默认 Shortcut 列表与 Agent public Catalog 均不发布。底层 MCP 字段名由 Execute 的显式 adapter 负责,不能在未经过版本化迁移时重定向已发布 Schema property。
- `+check-result` 已覆盖 Lark CLI 当前唯一 Attendance 用户任务 `attendance user_tasks query`;DWS inventory 还包含打卡流水、审批、班次、规则、设置、假期和个人视图等更宽能力。排班查询入口虽然保留历史 CLI 兼容,但因 `DS-ATTENDANCE-008` 当前保持 Agent-unavailable。
- 已确认 8 组下游需求:补卡规则详情返回空结果、报表合同不足、打卡结果分页缺少服务端确定终止证据、缺少安全可回收的管理员/写操作 fixture、6 个读场景缺少请求绑定字段或 nonempty/zero 双态 fixture、班次详情不回显稳定 ID、个人设置缺少逐场景权限发现与安全 fixture,以及排班查询对合法非空/空请求均返回 `exit 0 + literal null`。
- 审批模板的同类型多模板问题已在上游修复:以 `processCode` 作为资源身份,`approveType` 只做请求绑定,并要求 `submitUrl` 非空。班次详情与个人设置仍有下游合同/权限前置,不能以请求 echo 或部分场景成功伪造整体可用。
| ID | 优先级 | 类型 | 用户任务 | 当前状态 | 建议 Owner | 解锁的 Shortcut |
|---|---|---|---|---|---|---|
| `DS-ATTENDANCE-001` | P1 | business-service defect / contract insufficient | 搜索后读取补卡规则详情 | unavailable | Attendance Wukong 规则服务 | `+get-adjustment-rule` |
| `DS-ATTENDANCE-002` | P1 | business-service defect / contract insufficient | 发现报表列并查询考勤/假期报表 | unavailable | Attendance 报表服务 / MCP adapter | `+list-report-columns`, `+query-report-data`, `+query-report-leave` |
| `DS-ATTENDANCE-003` | P2 | contract insufficient | 可靠翻完打卡结果 | partial | Attendance 打卡查询服务 | `+check-result` 完整分页 |
| `DS-ATTENDANCE-004` | P1 | tenant-or-fixture / permission | 验证考勤组、全局设置、余额和写操作 | blocked / unavailable | Attendance 产品测试基础设施 / 权限 Owner | 14 个读写 Shortcut |
| `DS-ATTENDANCE-005` | P1 | response contract / tenant-or-fixture | 可验证地读取摘要、假期、签到和个人考勤 | blocked / unavailable | Attendance 查询服务 / 产品测试基础设施 | 6 个读 Shortcut |
| `DS-ATTENDANCE-006` | P1 | response contract | 用搜索得到的班次 ID 精确读取同一班次详情 | unavailable | Attendance Wukong 班次服务 | `+get-class` |
| `DS-ATTENDANCE-007` | P1 | capability / permission fixture | 可发现地读取全部个人设置场景 | blocked / unavailable | Attendance 设置服务 / 权限 Owner / 测试基础设施 | `+get-self-setting` |
| `DS-ATTENDANCE-008` | P1 | response contract | 可验证地读取员工排班 | unavailable | Attendance Wukong 排班服务 / MCP adapter | `+get-schedule` |
## 2. 用户任务与能力缺口总览
| 用户任务 / Golden Route | DWS Shortcut | Lark CLI 对应 | 当前能力 | 缺口分类 | 临时处置 |
|---|---|---|---|---|---|
| 批量查询员工打卡结果 | `attendance +check-result` | `attendance user_tasks query` | covered;框架分页 token 由当前页保守派生 | contract insufficient | 声明 `Pagination(kind=cursor,cursor_parameter=offset)`;续页只放 `meta.pagination`,业务 `data` 仅含 `count/records` |
| 搜索并读取班次 | `+search-class` → `+get-class` | 无同级入口 | partial | response contract | 只公开搜索;详情因不回显请求 classId 而 unavailable |
| 搜索并读取补卡规则 | `+search-adjustment-rule` → `+get-adjustment-rule` | 无同级入口 | partial | business-service defect | 只公开搜索;详情 unavailable |
| 发现字段并查询考勤报表 | `+list-report-columns` → `+query-report-data` | 无同级入口 | unavailable | contract insufficient | 两个入口均不进入 Agent Catalog;历史 `+query-report-data` 仅保留 CLI 兼容可见性 |
| 查询假期报表 | `+query-report-leave` | 无同级入口 | unavailable | business-service defect | hidden/unavailable |
| 搜索并读取考勤组 | `+search-group` → `+get-group` | 无同级入口 | blocked | tenant-or-fixture | 无已知非空安全 fixture;历史 `+search-group` 仅保留 CLI 兼容可见性,二者都不进入 Agent Catalog |
| 查询企业全局设置和假期余额 | `+get-global-setting`, `+get-leave-balance` | 无同级入口 | blocked | permission / fixture | hidden/unavailable |
| 查询个人设置 | `+get-self-setting` | 无同级入口 | partial | capability / permission fixture | 前五个场景已验证;全部场景发布前保持 Agent-unavailable,仅保留历史 CLI 兼容可见性 |
| 查询员工排班 | `+get-schedule` | 无同级入口 | unavailable | response contract | 合法非空与保证零命中请求均收到 `exit 0 + literal null`;旧 CLI 兼容可见,但不进入 Agent Catalog |
| 修改排班、班次、考勤组、假期和打卡结果 | 9 个写 Shortcut | 无同级入口 | unsafe to verify | tenant-or-fixture / contract insufficient | hidden/unavailable,不以 dry-run 记通过 |
## 3. 下游需求明细
### `DS-ATTENDANCE-001` — 让搜索得到的补卡规则可被稳定读取
#### A. 用户任务与现状
- 用户任务:先按名称浏览补卡规则,再用结果中的稳定主键读取完整规则。
- canonical Shortcut:`attendance +search-adjustment-rule`、`attendance +get-adjustment-rule`。
- atomic/raw route:`attendance adjustment search`、`attendance adjustment get`。
- Exact Shortcut 与 atomic/raw 均使用搜索返回的同一候选主键;搜索明确成功且非空,详情调用明确 `success=true`,但 `result=null`。
- 已排除上游空数组投影、整数解析和候选字段遗漏:多个可作为候选的数值字段均未得到非空详情;加班规则的相邻搜索→详情闭环正常。
- 置信度:高。仍需下游确认“搜索 ID 与详情 ID 不同”还是详情服务未返回对象。
- 安全证据句柄:`ATT-DETAIL-NULL-01`;仓库不保存 raw body、资源 ID 或 trace。
#### B. 需要下游提供的合同
- 明确 `get_adjustment_rule` 列表项中哪个字段是 `get_adjustment_rule_detail.adjustmentId` 的稳定主键;名称和类型必须在 Schema 中一致。
- 对存在且有权限的规则返回 `success=true` 和非空对象 `result`,对象必须回显同一稳定规则 ID。
- 对不存在、已删除、无权限、租户未开通分别返回稳定的 typed error;不得以 `success=true + result=null` 表示任一失败。
- 如详情接口不受支持,提供可发现的 capability/feature 状态,或在搜索结果中返回足以完成详情任务的完整对象并声明字段稳定性。
- 改动应 additive/versioned;旧字段保留兼容期,禁止静默改变现有 ID 的语义。
#### C. 验收标准
1. 创建或选择隔离规则,atomic search 非空并取得稳定 ID。
2. atomic detail 和 exact `+get-adjustment-rule` 均返回同一 ID 的非空对象。
3. 不存在 ID、无权限和已删除 ID 分别返回非零 typed error。
4. 上游恢复公开后,搜索→详情 E2E 通过且仓库/远端无测试残留。
#### D. 临时处置
`+get-adjustment-rule` 保持 Agent-unavailable 并从公开 Catalog 排除;旧 CLI 入口仅为 argv/Help 兼容继续可见,`+search-adjustment-rule` 不再承诺详情入口可用。
### `DS-ATTENDANCE-002` — 提供可发现、可验证的考勤报表合同
#### A. 用户任务与现状
- Golden Route:列出企业可查询报表列 → 选择稳定列 ID → 查询一批员工的列值;另一路径按假期类型查询时长报表。
- canonical Shortcut:`+list-report-columns`、`+query-report-data`、`+query-report-leave`。
- atomic/raw operations:`get_report_columns`、`get_report_columns_value`、`get_leave_time_by_leave_names`。
- 观察:列发现与假期报表调用均退出码 0 且 payload 为 JSON `null`;使用未经验证的列 ID 查询列值仅得到显式空数组,不能证明列 ID 有效或查询正确。
- 已排除上游投影丢失:原子调用本身即返回 `null`;Shortcut 现已拒绝把 `null` 当作合法空集合。
- 置信度:高。权限/租户功能可能是触发条件,但接口没有返回可区分的状态。
- 安全证据句柄:`ATT-REPORT-NULL-01`。
#### B. 需要下游提供的合同
- `get_report_columns`:成功时必须返回显式列数组;每项含稳定 `columnId`、显示名、值类型、单位、支持的日期/人员范围和是否需要管理员权限。
- 合法无列必须是 `success=true + result=[]`;未开通、无权限和服务异常必须是不同 typed error,不得返回裸 `null`。
- `get_report_columns_value`:返回值必须绑定请求的用户集合、列 ID 和时间范围;未知列返回 `COLUMN_NOT_FOUND`,不能静默得到空数组。
- `get_leave_time_by_leave_names`:返回显式数组并包含稳定用户身份、假期类型标识、单位和数值;合法零记录为显式空数组。
- 列值和假期报表若分页,必须提供 page/cursor、hasMore 和终止证据;批量用户存在部分失败时返回逐项 ledger 与整体 partial status。
- 提供安全 capability discovery:租户是否开通、调用身份所需权限、最大用户数、最大列数、最大时间跨度。
#### C. 验收标准
1. 管理员测试租户中列发现有已知非空和明确空租户两组 E2E。
2. 使用发现的同一 `columnId` 执行 atomic 与 exact Shortcut,返回与请求用户/区间绑定的非空值。
3. 未知列、无权限、未开通和超范围分别产生稳定非零错误。
4. 假期报表至少覆盖已知非空、合法空和未知假期类型。
5. 分页/partial 分支和远端零残留通过。
#### D. 临时处置
三个报表 Shortcut 均保持 Agent-unavailable;其中历史 `+query-report-data` 只保留 CLI 兼容可见性。不得用 `null`、请求 echo 或未验证列产生的空数组标记 PASS。
### `DS-ATTENDANCE-003` — 为打卡结果提供确定的分页终止证据
#### A. 用户任务与现状
- `+check-result` 已真实返回非空打卡结果并覆盖 Lark 任务;当前接口只接受 `offset/limit`,响应缺少稳定总量、hasMore 或 nextOffset。
- DWS 只能在返回条数小于 limit 时证明结束;满页时保守输出 `meta.pagination.endpoint_exhausted=false` 和 `next_token=offset+count`,不能声明全量完成。`complete/nextOffset/limit` 仅保留在 legacy 兼容输出,unified 业务 `data` 不冒充分页协议。
- 安全证据句柄:`ATT-CHECK-PAGE-01`。
#### B. 需要下游提供的合同
- 响应增加 `hasMore` 与 `nextOffset`,或 `totalCount`;这些字段必须与同一快照/排序一致。
- 固定稳定排序键和同 offset 重放语义;说明并发新增/修改是否可能造成重复或漏项。
- 空页且 `hasMore=true` 必须仍给出前进 token/offset;重复或倒退 offset 为协议错误。
- 声明最大 limit、最大时间跨度和超过上限的 typed validation error。
#### C. 验收标准与临时处置
- 验收覆盖多页、最后一页、零记录、满页但仍有下一页、重复 token/offset 和并发变更。
- 下游完成前,DWS 使用框架 `PaginationSpec` 和 `meta.pagination`表达保守续页;`cursor_parameter=offset` 表示调用者将 `next_token` 作为下一次 `--offset`,不表示下游已提供服务端 opaque cursor。满页始终不会被当作已完整。
### `DS-ATTENDANCE-004` — 建立可回收的 Attendance 管理员与写操作测试资源
#### A. 用户任务与现状
- 受影响读取:`+search-group`、`+get-group`、`+get-group-filtered`、`+get-global-setting`、`+get-leave-balance`。
- 受影响写入:`+import-schedule`、`+create-class`、`+update-class`、`+update-group-members`、`+create-group`、`+update-group`、`+update-leave-type`、`+save-leave-balance`、`+boss-check`。
- 当前安全身份没有已知非空考勤组 fixture;全局设置被权限拒绝;余额读取没有可验证结果。写操作会影响真实员工规则,且部分资源缺删除/恢复能力,因此未执行生产数据写入。
- 这不是对业务接口必然有 bug 的结论,而是可测试性和权限前置不足。
- 安全证据句柄:`ATT-FIXTURE-GAP-01`。
#### B. 需要的测试基础设施与合同
- 提供隔离租户或专用测试组织,包含:管理员测试身份、两个无业务含义测试成员、一个可删除考勤组、一个可删除班次、一个可恢复假期类型、可控排班与打卡结果。
- 只授予完成相应接口所需的最小 scopes;提供 capability discovery,区分权限不足、功能未开通和资源不存在。
- 写接口返回稳定资源 ID、逐项结果、幂等/commit-unknown 语义;所有更新支持精确读回。
- 为不可删除的企业设置提供 snapshot/restore 或专用 reset API;余额和 BOSS 改签必须能恢复原值。
- Fixture 有 TTL、Owner 和自动清理告警;日志只保留受控 evidence handle,不输出业务内容或身份值。
#### C. 验收标准与临时处置
1. 考勤组搜索有已知非空和保证零命中;详情绑定同一 ID。
2. create→get→update→restore/delete 覆盖班次、考勤组与排班。
3. 成员、余额和打卡结果写入均有 before/after 精确读回并恢复原值。
4. 未确认时远程写调用为 0;任一 partial/commit-unknown 非零退出。
5. 测试结束远端和本地均零残留。
在完整 fixture 到位前,相关 Shortcut 保持 hidden/unavailable。
### `DS-ATTENDANCE-005` — 为 6 个读场景提供请求绑定与双态 fixture
#### A. 用户任务与现状
- `+get-summary`:真实响应只含统计项,不回显请求 user、period 或 statsType,上游无法证明返回属于哪个请求。
- `+list-leave-types`:当前安全租户只有已知非空列表,而命令无筛选参数;不能用越界分页或错误请求伪造合法空结果。
- `+get-leave-records`、`+get-checkin-record`:当前只取得合法空结果,缺少已知非空流水 fixture,无法排除响应投影或请求绑定错误。
- `+my-attendance`、`+this-month`:上游已严格验证当前用户 profile 与每条打卡 ID,但当前期间仅有合法空数组,缺少同一身份下的已知非空 fixture。
- 安全证据句柄:`ATT-READ-FIXTURE-GAP-01`;不保存 raw body、用户 ID 或打卡时间。
#### B. 需要下游提供的合同与 fixture
- 摘要响应回显稳定 userId、统计周期起止和 statsType,或返回可校验的请求摘要;任一字段不一致必须 typed failure。
- 提供隔离的「无假期类型」测试租户,以显式 `success=true + result=[]` 证明 `+list-leave-types` 的合法空语义。
- 提供可创建、读取并清理的假期变更流水、签到流水和打卡流水;每项都必须包含稳定 ID、请求用户和时间范围回显。
- 为 nonempty 与 guaranteed-zero 提供独立 fixture;未知用户、无权限、未开通和合法空集合必须可区分,不得都返回裸 `null` 或无标识空数组。
#### C. 验收标准与临时处置
1. 每个集合叶子都用 exact Shortcut 和 owning atomic/raw 在同一参数下各证明一次已知非空和一次合法保证零命中。
2. 非空项的稳定 ID、用户和时间绑定在两层结果中一致;空结果仍有显式业务 success 和正确集合容器。
3. malformed/null/success=false/错身份/超范围均非零失败,且不会继续调用后续考勤接口。
在上述证据完整前,6 个 Shortcut 均保持 Agent-unavailable,并仅为历史 argv/Help 保留 CLI 兼容可见性;已实现的严格校验不等于已获得发布证据。
### `DS-ATTENDANCE-006` — 让班次详情回显可验证的稳定身份
#### A. 用户任务与现状
- 用户任务:先用 `+search-class` 浏览班次并取得稳定 `classId`,再用同一 ID 读取班次详情。
- canonical Shortcut:`+search-class`、`+get-class`;atomic/raw route:`attendance class search`、`attendance class get`。
- 在 clean discovery HEAD 上,搜索 exact/raw 均返回同一组非空正整数 `classId`;使用其中真实 ID 调用 raw detail,服务端返回 `success=true` 和非空 `shiftVO`,但对象没有 `id` 或 `classId`。
- 上游不能把请求 ID 注入响应来伪造 readback,也不能仅凭“非空详情”证明详情属于请求资源。因此 `+get-class` 保持 unavailable。
- 安全证据句柄:`ATT-CLASS-ID-ECHO-GAP-01`;不保存 raw body、资源 ID 或 trace。
#### B. 需要下游提供的合同
- `get_class_detail` 成功对象必须回显与请求精确一致的稳定 `id`/`classId`,类型与 `get_class_list` 列表身份字段一致。
- 存在、已删除、不存在、无权限和租户未开通必须返回可区分的 typed terminal 状态;不得以非空但无身份对象表示可验证成功。
- 明确班次 ID 的租户作用域、生命周期和搜索→详情一致性;如详情存在版本号,也应返回稳定版本字段以支持更新前读回。
- 改动需 additive/versioned;现有详情业务字段保持兼容。
#### C. 验收标准与临时处置
1. exact/raw 搜索得到同一非空 `classId`,同 ID detail 均返回身份精确匹配的非空对象。
2. 不存在、已删除和无权限分别非零 typed failure,不能成为 `success=true + result=null` 或无身份对象。
3. 上游 `+get-class` 的 missing/false/null/malformed/wrong-ID 回归与真实 E2E 全部通过。
下游补齐稳定 ID 回显前,`+get-class` 保持 hidden/unavailable;`+search-class` 仍可独立公开。
### `DS-ATTENDANCE-007` — 提供个人设置逐场景 capability 与权限安全 fixture
#### A. 用户任务与现状
- `+get-self-setting` 公开参数包含 6 个场景。clean discovery HEAD 上,前 5 个场景的 exact/raw 均能精确绑定请求 userId、场景字段和已观测类型;`bossAttendStatNotify` 在两层均返回稳定业务错误 `NO_PERMISSION`。
- 当前接口没有 capability discovery 告知调用身份可读哪些场景,也没有可安全授权的隔离 fixture。只验证 5/6 不能宣称整个公开枚举可用。
- 这不是把权限错误误判为业务空结果;exact/raw 均非零退出。上游保留严格 user/scene/type 校验,但发布面整体降级。
- 安全证据句柄:`ATT-SELF-SETTING-PERMISSION-GAP-01`。
#### B. 需要下游提供的合同与 fixture
- 提供 capability discovery,返回当前调用身份逐场景的 readable/forbidden/unsupported 状态、所需最小 scope/角色和租户功能开通状态。
- 为 6 个场景提供字段名、类型、可空性和版本化语义;成功必须回显请求 userId,并明确返回对应场景字段。
- 提供隔离测试身份或可撤销的临时最小权限授权 fixture,使 6 个场景均能完成 exact/raw 同场景验证;测试后权限必须回收。
- 无权限、场景不支持、用户不存在和设置未配置必须返回不同 typed error;不得统一为 `null`、空对象或无标识空成功。
#### C. 验收标准与临时处置
1. capability discovery 与 6 个场景实际调用一致,不遗漏权限前置。
2. 每个场景 exact/raw 的 userId、场景字段、类型和对象内容一致;`null`、错类型、错用户均非零。
3. bogus user、invalid scene、无权限和未开通均返回可区分非零错误。
4. 权限 fixture 全程最小化、可撤销,结束后无授权残留。
能力发现和安全 fixture 到位前,`+get-self-setting` 保持 Agent-unavailable;旧 CLI 入口仅保留兼容可见性。
### `DS-ATTENDANCE-008` — 让排班查询返回可判定的成功集合或业务错误
#### A. 用户任务与现状
- 用户任务:按员工和日期范围读取逐日排班,用稳定排班 ID 继续执行只读分析或受控的 BOSS 改签。
- canonical Shortcut:`attendance +get-schedule`;owning raw route:`attendance-wukong/getScheduleByRange`。
- 两次独立 clean HEAD 的真实验证中,已知历史非空区间与保证零命中的未来区间都得到同一结果:owning raw 进程退出 0,但响应为 literal `null`;Exact Shortcut 均以 `response_validation/empty_tool_response` 非零拒绝。
- 这既不能证明排班非空,也不能证明合法为空。上游严格校验已避免把 `null` 投影成 `[]`,但在下游提供可判定合同前无法公开该能力。
- 安全证据句柄:`ATT-SCHEDULE-NULL-01`;仓库不保存用户、日期、排班 ID、raw body 或 trace。
#### B. 需要下游提供的合同
- 成功查询必须返回显式排班数组;每项包含稳定非空排班 ID、请求用户身份、业务日期、班次身份和是否休息等字段。
- 合法零结果必须返回 `success=true + result=[]`(或等价的已审核显式集合),不得以裸 `null`、缺字段或空 body 表示。
- 无权限、用户不存在、租户未开通、日期范围非法和服务异常必须返回可区分的 typed nonzero error;不得继续用进程退出 0 掩盖业务失败。
- 如服务存在分页,必须提供页大小、前进 token/页号、hasMore/total 和明确终止证据;同一请求的 item identity 不得跨页重复。
#### C. 验收标准与临时处置
1. 已知非空 fixture 的 raw 与 exact 均返回同一显式数组,稳定 ID 集合、用户和日期绑定一致。
2. 保证零命中 fixture 的 raw 与 exact 均返回显式空数组,并有明确终止证据。
3. `null`、缺集合、错型 item、重复/空 ID、错用户和越界日期全部非零;错误 reason 可稳定区分。
4. 新 clean HEAD 完成 nonempty/zero 双层 E2E,仓库和远端均无测试残留。
下游修复前,`+get-schedule` 保持 `public=false/unavailable`、legacy 输出且不发布 Result/Pagination;旧 CLI/Help/full Schema 仅为历史兼容继续可发现,不代表 Agent 可用。
## 4. Lark 对齐与平台差异
| Lark 用户任务 | 所需下游能力 | 可精确对齐 | 平台差异 | DWS 推荐结论 |
|---|---|---|---|---|
| `attendance user_tasks query` 查询打卡结果 | 现有 `query_check_result`;最好补分页终止证据 | yes,分页完整性 partial | Lark 当前没有同级的排班、规则、报表和企业设置任务 | 保留 `+check-result` 为主对齐入口,报告分页边界 |
无法对齐的不是 DWS 缺入口,而是部分钉钉管理面缺少可验证下游合同或安全 fixture;不能为追求同名率伪造成功。
## 5. 超越 Lark 的产品机会
| 产品原生能力 | 所需下游支持 | 可形成的 DWS Shortcut | 安全/验证要求 | 优先级 |
|---|---|---|---|---|
| 异常考勤处置队列 | 稳定异常记录 ID、原因、关联审批、处理状态、分页和可恢复更正 | `attendance +exceptions` / `+resolve-exception` | 读写分离;更正确认;写后同 ID 终态读回;可恢复 | P2 |
| 跨员工考勤汇总 | 可按组织/成员批量聚合迟到、缺卡、加班、请假并给出统计口径版本 | `attendance +team-summary` | 最小权限、聚合脱敏、口径版本、分页完整性 | P2 |
| 规则影响预览 | 更新班次/考勤组/假期前返回受影响成员与日期范围,不提交写入 | `attendance +rule-impact-preview` | 只读、稳定影响计数、无副作用、与最终写请求同参数语义 | P1 |
## 6. 无需下游变更的上游修复
| Shortcut | 上游根因 | 已完成修复 | 回归证据 |
|---|---|---|---|
| 最终保留公开的 Attendance 集合查询 | 容错 projector 可能把缺字段、错型或坏元素投成 `[]` | 共享严格 success/result/collection 校验;显式空数组才合法;稳定 ID 和请求用户/时间/类型必须绑定 | 单元负向矩阵与最终 clean runtime tree 的 8 个公开入口真实 nonempty/zero、详情或模板 exact/raw 双层复核均完成 |
| `+check-record` | 初版误用业务归属日 `workDate` 校验按 `checkDateFrom/checkDateTo` 发起的实际打卡查询,导致跨午夜下班卡被静默丢弃 | 改用 `userCheckTime` 严格绑定请求日期范围;`workDate` 只作为班次归属日原样保留。完整 raw 集合仍必须先通过显式 collection、全量正整数唯一 ID、请求用户和实际打卡时间校验;任何实际时间越界都整次 fail-closed,不再静默过滤 | 最终 live 复核 exact/raw 均为 157 条且完整对象一致;旧轮 `workDate=start-24h`、`userCheckTime` 在范围内的跨午夜 OffDuty 记录明确保留;fresh zero 双层显式空,不由过滤制造 |
| `+check-result`, `+list-approve` | 初版把裸日期 `--end` 解析为当天 00:00,可能拒绝结束日白天的结果;旧 end-of-day 语义还会漏最后 999ms | 裸日期结束边界改为本地下一日 00:00 前 1ms;显式 datetime 保持精确值;结束日中午与最后 1ms 可接受,下一日 00:00 非零拒绝 | Execute 回归覆盖结束日中午/最后毫秒/下一日并锁定 reason;最终 live 的 `+check-result` 有真实 end-date item,`+list-approve` end-date 单日 probe exact/raw 一致 |
| `+get-approve-template` | 把请求维度 `approveType` 误作集合唯一身份,会拒绝同一类型下多个合法模板 | 改用非空唯一 `processCode` 作为资源身份;`approveType` 仅做请求精确绑定;每项 `submitUrl` 必须非空;允许 TRAVEL/OUT 同类型多项 | missing/wrong/duplicate processCode、wrong approveType、missing/blank submitUrl 负向矩阵;clean HEAD 上 5 个类型 exact/raw 全通过,TRAVEL/OUT 双项集合一致 |
| `+search-class`, `+search-adjustment-rule`, `+search-overtime-rule` | 嵌套 `shiftVO/entityVO` 导致身份投影风险 | 固定审核路径、展开 wrapper、要求正整数且不重复的稳定 ID,严格校验分页矛盾与无前进页 | 坏 item/空 ID/重复 ID/分页矛盾单元回归通过;clean HEAD 上 nonempty/guaranteed-zero 与 raw 对照通过,班次/加班规则另完成实际多页前进与终止 |
| `+get-overtime-rule` | 能力存在但缺少请求 ID 与响应对象的强绑定 | 详情对象要求非空且 `id` 与请求精确一致 | missing/false/null/malformed/wrong-ID/valid Execute 级矩阵;clean HEAD 上 exact/raw 同真实搜索 ID 对象一致,raw 对不存在 ID 返回错对象时 exact 非零拒绝 |
| `+get-class` | 上游已严格要求 `shiftVO.id`,但真实下游详情不回显任何 ID | 没有注入请求 ID 或放宽校验;按真实合同降级 unavailable | discovery HEAD 上真实搜索→raw detail 非空但 ID 缺失;等待 `DS-ATTENDANCE-006`,修复后再重跑 |
| `+get-self-setting` | 仅检查场景 key 存在会让 `null` 伪成功;用户外围空白可造成下传/比较漂移 | 用户输入只归一化一次并以同值下传/比较;场景字段必须非空且符合已观测 object/boolean/integer 类型;因 1/6 场景权限不可验证而整体 unavailable | 5 个 scene exact/raw 对照通过;boss scene exact/raw 均 `NO_PERMISSION`,等待 `DS-ATTENDANCE-007`,不把部分场景成功当整体 PASS |
| `+my-attendance`, `+this-month` | 旧的当前用户解析可跳过 malformed row,也可把 success=false 中的 stale result 当身份 | 改为严格 business success/result/唯一用户身份,坏 profile 后考勤 raw 调用为 0;每条打卡要求唯一正整数 ID | 静态/Execute 回归已通过;因当前只有合法空集合而保持 unavailable,不记 live PASS |
### 6.1 clean-HEAD live 发布门状态
| 叶子 | clean executable HEAD 双层证据 | 发布状态 |
|---|---|---|
| `+check-result` | exact/raw known-nonempty 以 20/20/8 三页前进并终止;48 个 ID、用户绑定与逐页对象一致;合法未来日显式空双层一致 | `PASS`;最终 SHA 见 PR 证据 |
| `+check-record` | exact/raw 均 157 条且完整对象、稳定 ID 集合一致;跨午夜 `workDate=start-24h`、`userCheckTime` 在范围内的记录已保留;fresh zero 两层均为显式空 | `PASS`;最终 SHA 见 PR 证据 |
| `+list-approve` | exact/raw known-nonempty 为 7 条,稳定 ID、用户、类型、日期范围及完整数组一致;合法未来日显式空双层一致 | `PASS`;最终 SHA 见 PR 证据 |
| `+get-schedule` | 两次独立 clean HEAD 的 known-nonempty 与 guaranteed-zero 均为 raw `exit 0 + literal null`,Exact Shortcut 均非零 `empty_tool_response`;没有把未知结果投影成空数组 | unavailable;等待 `DS-ATTENDANCE-008`,旧 CLI 仅兼容可见 |
| `+search-class`, `+search-adjustment-rule`, `+search-overtime-rule` | exact/raw known-nonempty 与随机唯一词 guaranteed-zero 通过;稳定 ID 集合与分页终止一致,班次为 5/5/3 三页,加班规则为 1/1/1 三页 | `PASS`;最终 SHA 见 PR 证据 |
| `+get-overtime-rule` | 使用本轮真实搜索取得的 ID,exact 与 raw 单项对象一致;不存在 ID 的 raw 返回错 ID 对象时 exact 非零拒绝 | `PASS`;最终 SHA 见 PR 证据 |
| `+get-approve-template` | 5 个 approveType 全部 exact/raw 通过,数量 1/1/1/2/2;TRAVEL/OUT 多项 `processCode` 非空唯一且集合一致,类型绑定和提交入口有效 | `PASS`;最终 SHA 见 PR 证据 |
| `+get-class` | raw 非空但不回显请求 ID | unavailable;等待下游合同,不以旧调用记 PASS |
| `+get-self-setting` | 5 个场景通过,1 个场景 `NO_PERMISSION` | unavailable;等待 capability/权限 fixture,不以部分结果记 PASS |
pre-rebase discovery 轮次的多页加班规则 raw 验证曾一次返回字面量 `null` 且进程退出 0;该次结果没有计为 PASS,重试后才完成同场景双层分页核对。这是 owning atomic/raw 的下游/renderer 终态合同风险:atomic 不应把 transport/null 失败表示为零退出。Shortcut 自身对 `null` 仍严格非零,不会把它投影为空集合;后续最终轮次未再出现该 transient。
上述 8 个公开入口均在最终 clean runtime tree 从零重跑,未继承 discovery PASS;最终可执行 SHA 写入 PR 证据,本文只保留脱敏业务断言。`+get-schedule` 的四次 raw `null` 与 Exact 非零结果作为降级证据保留,不计入公开通过数。
## 7. 安全与脱敏声明
- 本文不含真实用户、组织、租户、profile、规则、排班、考勤组或打卡记录 ID。
- 本文不含 trace/request ID、token、签名 URL、邮箱、电话、业务标题正文或真实日程内容。
- Raw 响应仅在仓库外临时目录中处理并已删除;本文只保留不可反查的证据句柄和聚合事实。
- 进入 Git 前必须扫描最终树、未跟踪文件和 `origin/main..HEAD` 全部历史。
@@ -1,198 +0,0 @@
# Mail Shortcut 下游业务能力需求规格
> 日期:2026-08-18
> Rebased executable 基线:`3fc3be37c67d14f60273a702a7a6b38f6ba32d4c`;最终 clean PR HEAD 的 live SHA 与发布复核结果记录在 PR 证据中
> 对比基线:lark-cli 1.0.87
> 范围:Shortcut only;不改 `skills/multi` 或 `skills/mono` 的路由、流程或业务逻辑。仓库 policy 强制的可见 Shortcut 自动生成块单独机械同步。
> 发布属性:仓库安全版本;不包含真实邮箱、人员、组织、邮件内容、资源 ID 或请求标识。
## 1. 执行摘要
本轮对 18 个 Mail Shortcut 完成严格 success、固定集合路径、稳定 ID、分页完整性和统一 Result 收口。8 个公开只读入口已在相同 runtime tree 逐条完成 Shortcut 与原子层的真实数据双层复核;`+unread-mail`、`+recent-mail`、`+thread-list`、`+tag-list`、`+template-list`、`+contact-list` 因缺少可控 guaranteed-zero fixture 保持 Agent-unavailable,但为守住既有 argv/Help 合同继续以 compatibility-visible 形式留在 CLI;4 个草稿/模板写入口因无法证明清理终态同样不进入公开 Catalog。
上述 6 个 compatibility-visible 入口在完整 Schema 中保留历史 `availability=available` 与既有 workflow property,仅表示旧调用仍可执行;其 Shortcut 语义状态仍为 `public=false/unavailable`,默认 Shortcut 列表与 Agent public Catalog 均不发布。底层 `folderId`、`size` 等 MCP 字段继续由 Execute 显式适配,不能在未经过版本化迁移时改写已发布 Schema property。
仍不能诚实对齐的任务集中在草稿/模板清理终态、发送终态、回复/转发草稿语义、批量修改/删除逐项结果、回执、签名、事件监听、模板附件事务和联系人创建身份回执。它们不是再包一层 Shortcut 就能解决,需要下游业务接口或安全测试 fixture 补足可验证合同。
| ID | 优先级 | 类型 | 用户任务 | 当前状态 | 下游 Owner | 解锁的 Shortcut |
|---|---|---|---|---|---|---|
| `DS-Mail-001` | P0 | contract insufficient | 发信/发送草稿并确认最终投递 | partial | Mail service / adapter | `+send`、`+draft-send` |
| `DS-Mail-002` | P0 | missing capability | 回复、回复全部、转发默认保存草稿 | partial | Mail service | `+reply`、`+reply-all`、`+forward` |
| `DS-Mail-003` | P0 | contract insufficient | 批量修改、移动、软删除邮件 | partial | Mail service / adapter | `+message-modify`、`+message-trash` |
| `DS-Mail-004` | P1 | missing capability | 处理已读回执与邮箱签名 | unavailable | Mail service | `+send-receipt`、`+decline-receipt`、`+signature` |
| `DS-Mail-005` | P1 | missing capability | 持续监听新邮件 | unavailable | Event + Mail service | `+watch` |
| `DS-Mail-006` | P1 | contract insufficient | 带附件/内联图片的模板创建更新 | partial | Mail + Drive adapters | 完整 `+template-create/update` |
| `DS-Mail-007` | P1 | adapter defect | 创建联系人并取得稳定身份 | blocked | Mail adapter | `+contact-create/update/delete` |
| `DS-Mail-008` | P1 | adapter defect | 一致的成功、空结果与分页合同 | partial | Mail adapter | 全部 list/search Shortcut |
| `DS-Mail-009` | P1 | tenant-or-fixture | 安全验证发送、回执、分享和监听 | blocked | Product QA / tenant admin | 全部高影响 Mail Shortcut |
| `DS-Mail-010` | P0 | contract insufficient | 草稿/模板可证明的清理终态 | blocked | Mail service / adapter | `+draft-create/edit`、`+template-create/update` |
## 2. 用户任务与能力缺口总览
| 用户任务 / Golden Route | DWS Shortcut | Lark CLI 对应 | 当前能力 | 缺口分类 | 临时处置 |
|---|---|---|---|---|---|
| 浏览/筛选摘要 | `+triage`、`+search-mail` | `+triage` | covered | 无 | 公开,严格分页 |
| 固定未读/近期列表 | `+unread-mail`、`+recent-mail` | Lark 对应任务入口 | blocked | 固定查询/文件夹缺可控 guaranteed-zero fixture | 保持 unavailable |
| 读取一封、多封、会话 | `+message`、`+messages`、`+thread` | 同名入口 | covered | 无 | 公开,精确 ID 读回 |
| 新建/编辑草稿 | `+draft-create`、`+draft-edit` | 同名入口 | blocked | 两次 batch-delete 后同 ID 仍可读,无法证明零残留 | 保持 unavailable |
| 创建/更新基础模板 | `+template-create`、`+template-update` | 同名入口 | blocked | delete 后 get 没有 typed nonfound;from/isDraft 也不可读回 | 保持 unavailable |
| 发送新邮件/已有草稿 | 无公开 Shortcut;存在 raw send | `+send`、`+draft-send` | partial | 终态、逐项结果、幂等不足 | 保持 raw,不宣称对齐 |
| 回复/回复全部/转发 | 无公开 Shortcut;raw 路径会立即发送 | `+reply`、`+reply-all`、`+forward` | partial | 缺少默认草稿与邮件头保真合同 | 保持 raw,不宣称对齐 |
| 修改/删除邮件 | 无公开 Shortcut;存在 raw batch route | `+message-modify`、`+message-trash` | partial | 无逐项 ledger 和严格终态 | 保持 raw,不宣称对齐 |
| 发送/拒绝已读回执 | 无 | `+send-receipt`、`+decline-receipt` | unavailable | 专用业务接口与标签合同缺失 | 明确不可用 |
| 邮箱签名 | 无 | `+signature` | unavailable | 签名读取接口缺失 | 明确不可用 |
| 分享邮件到聊天 | raw 高风险入口 | `+share-to-chat` | partial | 缺安全 fixture、逐目标结果与读回 | 不公开 Shortcut |
| HTML lint | 无 | `+lint-html` | unavailable | 缺统一邮件 HTML 规则包 | 下游或本地规则能力需求 |
| 监听新邮件 | 无公开 Mail Shortcut | `+watch` | unavailable | 订阅生命周期和安全事件合同不足 | 不公开 Shortcut |
| 文件夹/标签/联系人/企业邮箱用户 | `+folder-list`、`+user-search`、`+find-mail-user` 公开;其余列表不公开 | 无同名任务入口 | partial DWS extra | 标签/模板/联系人/会话列表缺安全双态 fixture | 无双态证据的入口保持 unavailable |
## 3. 下游需求明细
### `DS-Mail-001` — 可验证的发送生命周期
- 用户任务:发送新邮件或一个/多个草稿,并知道每一封最终是成功、失败、部分成功还是状态未知。
- 当前证据:raw 发送可返回业务 success 或发送标识,但不能统一证明最终投递;批量草稿发送没有逐项 ledger、请求顺序、未知提交和安全重试合同。
- 所需接口合同:
- 创建/发送必须返回稳定 `messageId` 与 `internetMessageId`,并明确 `accepted/pending/sent/partial_failure/failure/unknown`。
- 提供按同一身份查询发送状态的接口;状态必须绑定请求邮件与收件人集合。
- 批量发送返回逐项结果,任何一项失败时整体不得退出 0 冒充全成功。
- 支持幂等键,或明确 unknown commit 不可自动重试。
- 失败错误区分参数、权限、风控、限流、收件人拒收和提交未知。
- 验收:安全自发自收 fixture 完成 draft-create → exact get → send → 状态终态 → sent-folder exact read;批量中注入一项失败,验证 ledger 与非零整体结果;清理无测试草稿残留。
### `DS-Mail-002` — 回复/转发的草稿优先与 MIME 保真
- 用户任务:回复、回复全部或转发一封邮件,默认保存草稿,只有再次确认才发送。
- 当前证据:DWS raw route 会创建回复/转发草稿后立即发送,无法对齐 Lark 的默认草稿语义;上游也无法证明 `In-Reply-To`、`References`、原始引用块和收件人集合正确。
- 所需接口合同:
- 独立 `create_reply_draft`、`create_reply_all_draft`、`create_forward_draft`,返回稳定草稿 ID,不隐式发送。
- 服务端生成并可读回线程关系头、回复全部去重后的 To/CC、转发引用块和附件继承结果。
- 发送必须复用 `DS-Mail-001` 的确认、终态和幂等合同。
- 验收:用隔离自发邮件分别创建三类草稿,精确 ID 读回核对父邮件、参与人集合和引用语义;未确认时远程发送调用为 0;确认发送后状态终态可验证。
### `DS-Mail-003` — 邮件修改、移动和删除的逐项终态
- 用户任务:批量标记已读/未读、增删标签、移动文件夹、软删除邮件。
- 当前证据:raw batch route 多数只给聚合 success;删除后邮件仍可能可读,无法区分“移入已删除文件夹”“永久删除”“延迟可见”或“未生效”。
- 所需接口合同:
- 每个输入 messageId 返回 `applied/already_applied/failed/unknown` 与稳定原因码。
- 修改/移动后详情或摘要必须可读回 `isRead/tags/folderId`;删除返回明确 tombstone 或 folder transition。
- 软删除和永久删除使用不同操作,危险级别与确认要求可声明。
- 任何部分失败整体 outcome 为 `partial_failure` 且进程非零。
- 验收:创建隔离邮件,执行 mark-unread/read、标签增删、移动与软删除,每步同 ID 读回;错误 ID 与合法 ID 混合时逐项 ledger 完整且整体非零。
### `DS-Mail-004` — 已读回执与签名
- 用户任务:识别邮件是否请求回执;确认后发送标准回执,或拒绝并清除提示;列出和查看默认签名。
- 当前证据:现有 Mail 接口没有稳定暴露回执请求标签、专用发送/拒绝操作或签名读取资源,上游无法安全组合普通回复替代。
- 所需接口合同:
- 消息详情公开稳定回执请求状态和请求者身份类型。
- 专用 send/decline receipt 操作,幂等且返回状态;正文由服务端生成,不能让上游伪造。
- 签名列表/详情返回稳定 ID、默认发送场景、HTML/文本内容和敏感字段标注。
- 验收:预置请求回执邮件,未确认零写调用;发送/拒绝后状态读回且重复调用幂等;签名已知非空与合法空均可证明。
### `DS-Mail-005` — 新邮件监听的订阅生命周期
- 用户任务:在限定时间内监听新邮件,得到稳定、可恢复、可去重的事件流。
- 当前证据:通用事件基础设施不能证明 Mail scope、订阅状态、ready marker、断线续传和消息读取权限形成完整任务链。
- 所需接口合同:订阅/查询/退订;明确 user/bot 身份、scope 和租户开关;ready marker;事件 `eventId/messageId/mailbox/time`;断线 cursor、去重和界限参数;心跳不冒充业务事件。
- 验收:隔离邮箱订阅后注入一封测试邮件,只收到一次并能以 messageId 精确读取;超时、权限缺失、断线重连和退订后零事件均有确定结果。
### `DS-Mail-006` — 模板附件与内联图片事务
- 用户任务:创建或更新含普通附件、内联图片和 HTML 的模板,同时保留未修改 MIME 结构。
- 当前证据:本轮只对齐名称、主题、正文核心字段;现有多步上传缺少模板级事务、附件稳定 ID、失败回滚和更新时的结构保真证明。
- 所需接口合同:创建/更新草稿会话、附件上传会话、content-id 映射、提交/取消;返回逐附件 ledger;更新提供版本或 etag,避免 last-write-wins 覆盖;失败可回滚且无孤儿文件。
- 验收:普通附件和内联图片各一,创建后按模板 ID 读取附件 ID/名称/大小/content-id;更新正文不丢附件;中途失败自动取消并证明零孤儿资源。
### `DS-Mail-007` — 联系人写操作的稳定身份
- 用户任务:创建、更新、删除个人邮件联系人并验证精确对象。
- 当前证据:真实 create 返回 `success=true` 但没有 contactId;上游只能用随机显示名再扫列表定位,无法用于一般用户输入,因为名称/邮箱可能重复。
- 所需接口合同:create 返回稳定 contactId;get-by-id;update/delete 返回同 ID 与版本;列表支持 exact email 或 ID filter;重复联系人规则明确。
- 验收:创建回执直接得到 ID,get-by-id 精确核对,更新同 ID,删除后 not-found/tombstone;重复邮箱和同名联系人有稳定结果而非猜测。
### `DS-Mail-008` — 统一成功、空结果与分页协议
- 用户任务:可靠地区分“确实没有结果”“还有下一页”“服务异常或响应漂移”。
- 当前证据:同一产品的 success 同时出现布尔和字符串;hasMore 也出现两种编码;搜索终页用 `$`,部分列表用空串;零命中邮件会返回 `total=0` 加一个只有空收件人字段的占位对象。当前租户又没有空邮箱或空邮件文件夹,不能为无筛选列表证明 guaranteed-zero。
- 所需接口合同:
- success 与 hasMore 统一为布尔;所有列表显式数组,合法空只返回 `[]`。
- 统一 `nextCursor` 与 `endpointExhausted`;终页不使用业务哨兵对象或魔法值。
- 每项稳定 ID 必填;total 使用整数;服务错误必须 `success=false` 和稳定错误码。
- 保留兼容期,但提供 capability/version 让上游安全切换。
- 验收:每个列表/搜索执行已知非空、保证零命中、坏 item、缺集合、错型、hasMore 无游标、重复游标;只有显式合法空成功。
### `DS-Mail-009` — 安全租户与真实 E2E fixture
- 用户任务:在不触达真实业务收件人和内容的前提下验证所有高影响 Mail Shortcut。
- 所需 fixture:隔离自发自收邮箱、可控第二收件人、回执请求邮件、可分享的测试聊天、安全事件订阅、测试签名、可回收附件;所有资源用随机无业务含义标记并有自动清理。
- 权限:最小 Mail read/write/event、Drive attachment、IM share scopes 分离;可测试 user/bot 差异和缺权限错误。
- 验收:stdout 只输出 PASS 标签与聚合计数;原始 JSON 只在临时目录;finally 清理;远端零测试草稿/模板/联系人/邮件/订阅残留;仓库和历史扫描无身份数据。
### `DS-Mail-010` — 草稿/模板可证明的清理终态
- 用户任务:用可回收 fixture 验证草稿与模板写 Shortcut,不留下无法确认的远端测试对象。
- 当前证据:草稿创建/更新回执和 exact-ID 读回成功,但同一 ID 连续两次 batch-delete 后仍可读;模板 delete 返回成功后,get 仅为未分类失败,既非 typed nonfound 也不能证明 tombstone。
- 所需接口合同:分离软删除与永久删除;返回稳定 ID、终态和幂等证据;get-by-id 对已永久删除对象返回稳定 `not_found/deleted` 错误或已审核 tombstone,不得空 body、通用失败或继续返回对象。
- 验收:create/update → exact-ID readback → permanent delete → exact Shortcut + raw get 双层 typed absence;有界轮询后仍可读或终态未知时整体非零,且不得发布 Shortcut。
- 临时处置:四个写 Shortcut 保持 `public=false` / `unavailable`,直到安全 fixture 与 typed absence 同时可证明。
## 4. Lark 对齐与平台差异
| Lark 用户任务 | 可精确对齐 | 平台差异 | DWS 推荐结论 |
|---|---|---|---|
| `+message` / `+messages` / `+thread` / `+triage` | yes | DWS 额外自动解析邮箱和收件箱,并严格发布完整性 | 已公开 |
| `+draft-create` / `+draft-edit` | blocked | 核心写回可证,但删除后同 ID 仍可读,无安全清理终态 | 不公开,保持 unavailable |
| `+template-create` / `+template-update` | blocked | 核心字段可读回,但 from/isDraft 不可验且删除后缺 typed nonfound | 不公开,保持 unavailable |
| `+send` / `+draft-send` | no | DWS raw 偏立即发送且缺统一终态/逐项 ledger | 暂不公开 Shortcut |
| `+reply` / `+reply-all` / `+forward` | no | DWS raw 会立即发送,Lark 默认保存草稿 | 暂不公开 Shortcut |
| `+message-modify` / `+message-trash` | no | 聚合 success 不足以证明逐项终态 | 暂不公开 Shortcut |
| `+send-receipt` / `+decline-receipt` | no | 缺专用接口和可验证标签 | platform unavailable |
| `+signature` | no | 缺签名读取资源 | platform unavailable |
| `+watch` | no | 缺完整订阅生命周期与安全 fixture | fixture + capability blocked |
| `+share-to-chat` | partial | raw 可调用但缺逐目标验证和安全 fixture | 保持 raw |
| `+lint-html` | no | DWS 未提供统一规则包 | downstream/local capability needed |
## 5. 超越 Lark 的产品机会
| 产品原生能力 | 可形成的 DWS Shortcut | 安全/验证要求 | 优先级 |
|---|---|---|---|
| 文件夹、标签与联系人目录 | `+organize`:规则化移动、标记与标签组合 | 逐项 ledger、写后读回、补偿恢复 | P1 |
| 收信规则、白名单、黑名单、自动回复 | `+inbox-policy-audit` | 只读汇总优先;写操作强确认和版本化 | P2 |
| 邮箱日历 | `+mail-calendar-conflicts` | 与主 Calendar 的 ownership boundary 明确,禁止双写 | P2 |
| 发送状态与召回 | `+delivery-audit` | 终态、收件人粒度、召回结果和不可逆提示 | P1 |
| 附件导出与分享 | `+archive-message` | 精确 messageId、原子本地写入、敏感路径与清理 | P2 |
## 6. 无需下游变更的上游修复
| Shortcut | 上游根因 | 已完成修复 | 回归证据 |
|---|---|---|---|
| 全部 list/search | 容忍式探测任意 result/data/list/items,坏元素静默丢弃 | 固定已观测路径、严格 success/数组/item/ID;无双态 fixture 的 leaf 不发布 | deterministic 响应矩阵;live 证据逐 leaf 记录,不作泛化 |
| `+search-mail` / `+triage` | `$` 终止游标被误作下一页;零命中占位对象被当邮件 | 明确 `$` 终页;仅窄规则归一化已观测哨兵 | 各完成 known-nonempty 20;3 个 fresh 零命中 raw 均为 `total=0` + 无稳定 ID/正文且收件字段全空的 reviewed sentinel + terminal cursor,exact 才归一化为显式 `[]`;不把该下游特例描述成 raw 空数组 |
| `+search-mail` / `+triage` 自动邮箱解析 | 严格化时只接受顶层对象数组,会拒绝历史已观测的字符串数组和 `result/data.emailAccounts` 包装 | 仅接受三个审核路径 `emailAccounts` / `result.emailAccounts` / `data.emailAccounts`,每项可为非空邮箱字符串或含非空 `email` 的对象;缺集合、错型、坏项或多路径冲突全部 fail-closed;空发件人也不再投影为空字符串成功 | top/result/data × string/object、blank/wrong/multiple-path 与 sender missing/null/wrong-type 回归覆盖;最终 live 未传 `--email` 执行 `+search-mail`/`+triage`,owning 响应为顶层 object-item 形态并成功解析 |
| `+unread-mail` / `+recent-mail` / `+thread-list` | 固定条件或文件夹不能保证零命中 | 严格响应代码已完成,但没有空邮箱/空文件夹证据时关闭发布 | BLOCKED fixture;不得修改真实邮件状态造空 |
| `+user-search` / `+find-mail-user` | `hasMore`/`nextCursor` 未交付;零命中被误报 validation error | 发布 complete/nextCursor;合法空成功 | 各完成 known-nonempty 20 + fresh raw 显式空;stable identity set 与 raw pagination/meta 精确一致;`+user-search` 同轮实跑历史 string `--limit` |
| `+tag-list` / `+template-list` / `+contact-list` | 无 query 的列表容易把末页/删除后列表误作合法空 | 严格响应代码已完成;无专用空邮箱和 typed cleanup 时关闭发布 | BLOCKED fixture;不把临时资源从列表消失记为零态 PASS |
| `+message(s)` / `+thread` | 缺任务层完整读取和身份绑定 | 自动邮箱解析、精确请求 ID 读回、保序多读 | `+message`/`+thread` 与同稳定 ID raw 完整对象一致;`+messages` 用两个不同 ID 验证输入顺序与逐对象一致 |
| 草稿/模板写 | 仅写回执会产生假成功 | 稳定 ID + exact get + 请求字段核对;清理无法证明时保持 unavailable | deterministic 回执/读回矩阵 PASS;live cleanup BLOCKED |
### 6.1 clean executable HEAD 双层证据
| 公开入口 | exact Shortcut + owning raw 证据 | 状态 |
|---|---|---|
| `+search-mail`, `+triage` | 各 20 条 known-nonempty;3 个独立 fresh 零命中由 raw `total=0`、无稳定 ID/正文的单 sentinel 与 terminal cursor 共同证明,exact 严格归一化为显式空;稳定 message ID 集合和分页状态一致 | `PASS_WITH_REVIEWED_ZERO_ENCODING`;最终 SHA 见 PR 证据 |
| `+user-search`, `+find-mail-user` | 各 20 条 known-nonempty 与 raw 显式 fresh zero;条件身份集合和分页状态一致 | `PASS`;最终 SHA 见 PR 证据 |
| `+folder-list` | 顶层 5 条 nonempty;本轮先由 raw 验证同一父文件夹确实为空,再由 Shortcut 返回显式空;ID 集合一致 | `PASS`;最终 SHA 见 PR 证据 |
| `+message`, `+messages`, `+thread` | 单邮件/会话同稳定 ID 完整对象一致;批量用两个不同 ID 验证请求顺序和逐对象一致 | `PASS`;最终 SHA 见 PR 证据 |
8 个公开入口均在最终 clean runtime tree 从零重跑;其中 6 个使用标准 raw 显式空或精确对象证据,2 个邮件搜索使用上述审核过的下游零命中 sentinel 编码。最终可执行 SHA 写入 PR 证据,本文只保留脱敏业务断言。
## 7. 安全与脱敏声明
- 本文不含用户、组织、租户、profile、邮箱、人员姓名、邮件/会话/模板/联系人/聊天真实 ID。
- 本文不含邮件主题正文、收发件人、trace/request ID、token、签名 URL、电话或真实业务时间。
- 真实 E2E 原始响应仅在仓库外临时目录解析;普通输出只保留能力标签、计数和布尔断言。
- 临时草稿虽已执行两次 batch-delete 但仍可按同 ID 读取;临时模板删除后也未获得 typed nonfound。两者都不记为清理 PASS,四个写 Shortcut 因此保持 unavailable。
- 当前邮箱没有已验证的空邮件文件夹或专用空邮箱;因此 `+unread-mail`、`+recent-mail`、`+thread-list`、`+tag-list`、`+template-list`、`+contact-list` 不记 live 双态 PASS,并保持 unavailable。
- 最终提交前仍需扫描最终树、未跟踪文件和 `origin/main..HEAD` 全部历史。
+32 -6
View File
@@ -1,6 +1,6 @@
{
"generated_at": "2026-08-20T11:51:44.156046",
"count": 416,
"generated_at": "2026-08-21T10:07:48.251313",
"count": 418,
"results": [
{
"suite": "semantic",
@@ -3634,18 +3634,24 @@
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "sheet",
"command": "+list-sheets",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格要求 success=true、显式 sheets 数组、非空且唯一的 sheetId 与标题;提供完整标题本地精确筛选,因此可分别证明已知非空和合法零命中,未知结构绝不降级为空数组。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "sheet",
"command": "+read",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格校验 success、二维 cells、行列坐标与完成证据;服务返回 hasMore=true 时因没有可执行续页游标而失败关闭,保留 Sheet 读取与 AITable/Base 记录查询的产品边界。",
"availability": "available"
},
{
"suite": "semantic",
@@ -3857,6 +3863,26 @@
"semantic_delta": "更新指定字段后读取详情逐字段核验。",
"availability": "available"
},
{
"suite": "semantic",
"service": "whiteboard",
"command": "+query",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格投影要求 success=true、OpenNodes V1、显式 pages 数组、每页稳定 id 与显式 nodes 数组,并校验跨页节点身份及服务端完整性摘要。",
"availability": "available"
},
{
"suite": "semantic",
"service": "whiteboard",
"command": "+update",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "首次远端调用前完成 OpenNodes V1 校验与用户确认;写后要求 success=true、同一目标、非空终态回执、createdNodeIds/idMap 精确映射,再按真实节点身份独立 query 读回请求关键字段。",
"availability": "available"
},
{
"suite": "semantic",
"service": "wiki",
+1
View File
@@ -10,6 +10,7 @@ require (
github.com/charmbracelet/bubbletea v1.3.6
github.com/charmbracelet/huh v1.0.0
github.com/charmbracelet/lipgloss v1.1.0
github.com/creack/pty v1.1.24
github.com/fatih/color v1.18.0
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.0
+11
View File
@@ -1295,6 +1295,17 @@ func interruptPersonalConsumers(ipcEndpoint string, subscribeIDs []string) error
}
func stopPersonalConsumers(w io.Writer, ipcEndpoint string, subscribeIDs []string) error {
hasTarget := false
for _, id := range subscribeIDs {
if strings.TrimSpace(id) != "" {
hasTarget = true
break
}
}
if !hasTarget {
return nil
}
if _, err := personalStopConsumers(ipcEndpoint, subscribeIDs); err == nil {
return nil
} else if !errors.Is(err, busctl.ErrConsumerStopUnsupported) {
+13 -1
View File
@@ -734,7 +734,7 @@ func TestCrossPlatformCoverageRunPersonalEventConsumeManySetupAndCleanupEdges(t
})
}
func TestStopPersonalConsumersUsesTargetedRPCAndLegacyFallback(t *testing.T) {
func TestCrossPlatformCoverageStopPersonalConsumersUsesTargetedRPCAndLegacyFallback(t *testing.T) {
oldStop := personalStopConsumers
oldQuery := personalQueryStatus
oldFind := personalFindProcess
@@ -746,6 +746,18 @@ func TestStopPersonalConsumersUsesTargetedRPCAndLegacyFallback(t *testing.T) {
personalSignalProcess = oldSignal
}()
personalStopConsumers = func(string, []string) (transport.ConsumerStopResp, error) {
t.Fatal("targeted stop called without a subscribe_id")
return transport.ConsumerStopResp{}, nil
}
personalQueryStatus = func(string) (*transport.StatusResp, error) {
t.Fatal("legacy status queried without a subscribe_id")
return nil, nil
}
if err := stopPersonalConsumers(io.Discard, "endpoint", []string{"", " "}); err != nil {
t.Fatalf("empty target stop = %v", err)
}
personalStopConsumers = func(string, []string) (transport.ConsumerStopResp, error) {
return transport.ConsumerStopResp{Stopped: []string{"sub-a"}}, nil
}
@@ -0,0 +1,175 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package app
import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageSheetWhiteboardMarkdownRoutes(t *testing.T) {
root := NewRootCommand()
tools := deliverySchemaAllToolsForHelpFlagTest(t, root)
assertMarkdownLarkTasksRouteWithoutDuplicateShortcuts(t, root, tools)
assertMarkdownDriveRoutesStayCrossProduct(t, root, tools)
assertWhiteboardPublicShortcutsStayAvailableInSchema(t, root, tools)
}
func assertMarkdownLarkTasksRouteWithoutDuplicateShortcuts(t *testing.T, root *cobra.Command, tools map[string]map[string]any) {
t.Helper()
registered := 0
for _, item := range shortcut.All() {
if item.Service == "markdown" {
registered++
}
}
if registered != 0 {
t.Fatalf("registered Markdown Shortcuts=%d, want 0: existing composite leaves own these workflows", registered)
}
type route struct {
canonical string
confirmation string
flags []string
}
routes := map[string]route{
"create": {
canonical: "markdown.create", confirmation: "not_required",
flags: []string{"content", "file", "folder", "name", "space-id", "workspace"},
},
"fetch": {
canonical: "markdown.fetch", confirmation: "not_required",
flags: []string{"node", "output", "space-id", "workspace"},
},
"overwrite": {
canonical: "markdown.overwrite", confirmation: "user_required",
flags: []string{"content", "dry-run", "file", "name", "node", "space-id", "workspace"},
},
"patch": {
canonical: "markdown.patch", confirmation: "user_required",
flags: []string{"content", "dry-run", "node", "pattern", "regex", "space-id", "workspace"},
},
"diff": {
canonical: "markdown.diff", confirmation: "not_required",
flags: []string{"context", "file", "node", "version", "version2"},
},
}
group := mustFindCommand(t, root, "markdown")
children := map[string]bool{}
for _, child := range group.Commands() {
children[child.Name()] = true
}
if len(children) != len(routes) {
t.Fatalf("Markdown ordinary leaves=%v, want exactly five routed workflows", children)
}
for name, want := range routes {
leaf := mustFindCommand(t, root, "markdown", name)
if leaf.Hidden || !leaf.Runnable() {
t.Errorf("markdown %s hidden/runnable=%v/%v, want false/true", name, leaf.Hidden, leaf.Runnable())
}
if !children[name] {
t.Errorf("markdown %s is not mounted on the ordinary product group", name)
}
for _, flag := range want.flags {
if leaf.Flags().Lookup(flag) == nil {
t.Errorf("markdown %s is missing routed flag --%s", name, flag)
}
}
if shortcut.InPublicCatalog("markdown", "+"+name) {
t.Errorf("markdown +%s unexpectedly entered the public Shortcut catalog", name)
}
meta, ok := cli.ResolveMeta("markdown " + name)
if !ok {
t.Errorf("markdown %s missing from assembled Schema", name)
continue
}
if meta.Identity.Canonical != want.canonical || meta.Identity.CLIPath != "markdown "+name {
t.Errorf("markdown %s identity=%#v, want canonical=%q cli_path=%q", name, meta.Identity, want.canonical, "markdown "+name)
}
if meta.Safety.Confirmation != want.confirmation {
t.Errorf("markdown %s confirmation=%q, want %q", name, meta.Safety.Confirmation, want.confirmation)
}
tool := tools[want.canonical]
if tool == nil {
t.Errorf("markdown %s missing from full delivery Schema", name)
continue
}
if got := schemaContractString(tool["availability"]); got != "available" {
t.Errorf("markdown %s availability=%q, want available", name, got)
}
if got := schemaContractString(tool["interface_mode"]); got != "composite" {
t.Errorf("markdown %s interface_mode=%q, want composite", name, got)
}
if got := schemaContractString(tool["interface_reason"]); got == "" {
t.Errorf("markdown %s is missing the reviewed composite routing reason", name)
}
}
}
func assertMarkdownDriveRoutesStayCrossProduct(t *testing.T, root *cobra.Command, tools map[string]map[string]any) {
t.Helper()
driveShortcuts := map[string]string{
"+copy": "drive.shortcut_copy",
"+delete": "drive.shortcut_delete",
"+find-file": "drive.shortcut_find_file",
"+list": "drive.shortcut_list",
"+move": "drive.shortcut_move",
"+publish-get": "drive.shortcut_publish_get",
"+recycle-restore": "drive.shortcut_recycle_restore",
"+rename": "drive.shortcut_rename",
"+version-download": "drive.shortcut_version_download",
"+version-get": "drive.shortcut_version_get",
"+version-history": "drive.shortcut_version_history",
"+version-revert": "drive.shortcut_version_revert",
}
for name, canonical := range driveShortcuts {
leaf := mustFindCommand(t, root, "drive", name)
if leaf.Hidden || !leaf.Runnable() {
t.Errorf("drive %s hidden/runnable=%v/%v, want false/true", name, leaf.Hidden, leaf.Runnable())
}
if !shortcut.InPublicCatalog("drive", name) {
t.Errorf("drive %s is not in the public Shortcut catalog", name)
}
assertMarkdownCrossProductRoute(t, tools, "drive "+name, canonical)
}
ordinaryRoutes := map[string]string{
"drive permission list": "drive.list_permission",
"drive pull": "drive.folder_pull",
"drive push": "drive.folder_push",
"drive status": "drive.folder_status",
"drive sync": "drive.folder_sync",
"wiki node list": "wiki.list_nodes",
}
for cliPath, canonical := range ordinaryRoutes {
assertMarkdownCrossProductRoute(t, tools, cliPath, canonical)
}
}
func assertMarkdownCrossProductRoute(t *testing.T, tools map[string]map[string]any, cliPath, canonical string) {
t.Helper()
meta, ok := cli.ResolveMeta(cliPath)
if !ok {
t.Errorf("cross-product route %q is missing from assembled Schema", cliPath)
return
}
if meta.Identity.Canonical != canonical || meta.Identity.CLIPath != cliPath {
t.Errorf("cross-product route %q identity=%#v, want canonical=%q", cliPath, meta.Identity, canonical)
}
tool := tools[canonical]
if tool == nil {
t.Errorf("cross-product route %q is missing from full delivery Schema", cliPath)
return
}
if got := schemaContractString(tool["availability"]); got != "available" {
t.Errorf("cross-product route %q availability=%q, want available", cliPath, got)
}
}
@@ -136,6 +136,53 @@ func TestCrossPlatformCoverageOAAttachmentDeliveredSchemaMatchesExecutableHelp(t
}
}
// TestCrossPlatformCoverageOAAttachmentUploadDeliversCompositeSchema 验证合并后的
// upload 命令以 composite 接口模式交付:它内部串联 init/commit 两个 RPC 与本地 HTTP PUT,
// 无法绑定单一 interface_ref,因此不进入上面按 mcp 模式断言的表驱动用例。
func TestCrossPlatformCoverageOAAttachmentUploadDeliversCompositeSchema(t *testing.T) {
snapshot := fullSchemaSnapshotForTest(t)
tool := snapshot.Tools["oa.attachment_upload"]
if tool == nil {
t.Fatal("oa.attachment_upload is missing from final Schema")
}
if got := schemaContractString(tool["primary_cli_path"]); got != "oa approval attachment upload" {
t.Fatalf("primary_cli_path = %q, want oa approval attachment upload", got)
}
if got := schemaContractString(tool["interface_mode"]); got != "composite" {
t.Fatalf("interface_mode = %q, want composite", got)
}
if got := schemaContractString(tool["availability"]); got != "available" {
t.Fatalf("availability = %q, want available", got)
}
if got := schemaContractString(tool["interface_reason"]); got == "" {
t.Fatal("composite upload command must document an interface reason")
}
if got := schemaContractString(tool["effect"]); got != "write" {
t.Fatalf("effect = %q, want write", got)
}
if got := schemaContractString(tool["risk"]); got != "low" {
t.Fatalf("risk = %q, want low", got)
}
if got := schemaContractString(tool["confirmation"]); got != "not_required" {
t.Fatalf("confirmation = %q, want not_required", got)
}
parameters := schemaContractMap(tool["parameters"])
for _, flag := range []string{"file", "file-name", "md5"} {
if parameters[flag] == nil {
t.Fatalf("upload --%s is missing from final Schema", flag)
}
}
if required, _ := parameters["file"]["required"].(bool); !required {
t.Fatalf("upload --file required = %#v, want true", parameters["file"]["required"])
}
result := schemaContractMap(tool["result"])
dataSchema := schemaContractMap(result["data_schema"])
properties := schemaContractMap(dataSchema["properties"])
if properties["fileId"] == nil {
t.Fatal("upload Result data_schema is missing fileId")
}
}
func oaAttachmentResultContract(t *testing.T, tool map[string]any, resultType string, fields map[string]string, sensitivePaths []string) map[string]any {
t.Helper()
result, ok := tool["result"].(map[string]any)
@@ -16,12 +16,12 @@ import (
)
const (
publicShortcutCount = 415
publicShortcutCount = 417
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
// including reviewed hidden compatibility and unavailable contracts.
schemaPublishedShortcutCount = 453
schemaPublishedShortcutCount = 455
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
publiclyDeliveredShortcutCount = 415
publiclyDeliveredShortcutCount = 417
)
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
@@ -0,0 +1,42 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package app
import (
"testing"
"github.com/spf13/cobra"
)
func assertWhiteboardPublicShortcutsStayAvailableInSchema(t *testing.T, root *cobra.Command, tools map[string]map[string]any) {
t.Helper()
for canonical, command := range map[string]string{
"whiteboard.shortcut_query": "+query",
"whiteboard.shortcut_update": "+update",
} {
leaf, _, err := root.Find([]string{"whiteboard", command})
if err != nil || leaf == nil || leaf.Name() != command {
t.Errorf("find whiteboard %s: leaf=%v err=%v", command, leaf, err)
} else if leaf.Hidden || !leaf.Runnable() {
t.Errorf("whiteboard %s hidden/runnable=%v/%v, want false/true", command, leaf.Hidden, leaf.Runnable())
}
tool := tools[canonical]
if tool == nil {
t.Errorf("public %s missing from delivery Schema surface", canonical)
continue
}
if got := schemaContractString(tool["availability"]); got != "available" {
t.Errorf("%s availability=%q, want available", canonical, got)
}
if got := schemaContractString(tool["interface_mode"]); got != "composite" {
t.Errorf("%s interface_mode=%q, want composite", canonical, got)
}
if got := schemaContractString(tool["interface_reason"]); got == "" {
t.Errorf("%s missing composite adapter reason", canonical)
}
if tool["interface_ref"] != nil {
t.Errorf("%s composite interface_ref=%#v, want nil", canonical, tool["interface_ref"])
}
}
}
+2
View File
@@ -192,6 +192,7 @@ func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *Token
updated.CorpID = data.CorpID
updated.UserID = data.UserID
updated.UserName = data.UserName
updated.RepairOrganizationMirror = data.RepairOrganizationMirror
if updated.CorpName == "" {
updated.CorpName = data.CorpName
}
@@ -239,6 +240,7 @@ func (p *OAuthProvider) refreshViaMCP(ctx context.Context, data *TokenData) (*To
updated.CorpID = data.CorpID
updated.UserID = data.UserID
updated.UserName = data.UserName
updated.RepairOrganizationMirror = data.RepairOrganizationMirror
if updated.CorpName == "" {
updated.CorpName = data.CorpName
}
+78 -1
View File
@@ -811,7 +811,84 @@ func (p *OAuthProvider) lockedRefresh(ctx context.Context) (*TokenData, error) {
if p.logger != nil {
p.logger.Debug("refreshing token (dual-locked)")
}
return oauthRefreshToken(p, ctx, data)
refreshed, rErr := oauthRefreshToken(p, ctx, data)
if rErr == nil || !isRefreshTokenRejected(rErr) {
return refreshed, rErr
}
// A stale identity slot can survive an older organization-only refresh.
// Retry once with the same-corp organization mirror while holding the
// existing dual lock; the fallback marks the publication so the rotated
// credential is written back into the mirror slot it consumed.
logging.AuthDebug(
"auth.refresh.fallback.triggered",
"corp_id", strings.TrimSpace(data.CorpID),
"user_id", strings.TrimSpace(data.UserID),
"error", rErr,
)
fallback, fErr := p.refreshFromOrgSlot(ctx, data)
if fErr != nil {
logging.AuthDebug("auth.refresh.fallback.unavailable", "error", fErr)
return nil, rErr
}
if p.logger != nil {
p.logger.Warn(i18n.T("当前身份的 refresh_token 已失效,已从组织镜像 token 恢复登录态"))
}
return fallback, nil
}
// refreshFromOrgSlot retries a rejected refresh with the token mirrored in
// the organization slot. The mirror must match the current corp, be valid,
// and differ from the rejected token. When both slots carry user identities,
// they must agree; legacy mirrors with an empty UserID are backfilled from the
// current identity before refresh.
func (p *OAuthProvider) refreshFromOrgSlot(ctx context.Context, current *TokenData) (*TokenData, error) {
if current == nil {
return nil, fmt.Errorf("no current token data")
}
corpID := strings.TrimSpace(current.CorpID)
if corpID == "" {
return nil, fmt.Errorf("current token has no corpId")
}
orgData, err := tokenLoadKeychainForCorpID(corpID)
if err != nil {
return nil, err
}
if orgData == nil {
return nil, ErrTokenDataNotFound
}
if strings.TrimSpace(orgData.CorpID) != corpID {
return nil, fmt.Errorf("organization token mirror for corpId %q contains token for corpId %q; refusing refresh fallback", corpID, orgData.CorpID)
}
if !orgData.IsRefreshTokenValid() {
return nil, fmt.Errorf("organization mirror refresh_token 已过期")
}
if orgData.RefreshToken == current.RefreshToken {
return nil, fmt.Errorf("organization mirror holds the same rejected refresh_token")
}
currentUserID := strings.TrimSpace(current.UserID)
orgUserID := strings.TrimSpace(orgData.UserID)
if currentUserID != "" && orgUserID != "" && orgUserID != currentUserID {
return nil, fmt.Errorf("organization token mirror for corpId %q belongs to userId %q; refusing refresh fallback for userId %q", corpID, orgData.UserID, current.UserID)
}
if orgUserID == "" {
orgData.UserID = current.UserID
orgData.UserName = current.UserName
}
// The refresh below consumes the mirror's refresh_token. Mark the
// publication so persistence writes the rotated credential back into the
// organization slot even under an explicit runtime selector whose plan
// would otherwise skip it (for example a preserved unresolved sibling).
orgData.RepairOrganizationMirror = true
refreshed, err := oauthRefreshToken(p, ctx, orgData)
if err != nil {
return nil, err
}
logging.AuthDebug(
"auth.refresh.fallback.success",
"corp_id", corpID,
"new_at_expires_at", refreshed.ExpiresAt.Format(time.RFC3339),
)
return refreshed, nil
}
// ExchangeAuthCode takes an AuthCode and an optional UserID provided by an
@@ -0,0 +1,427 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package auth
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestCrossPlatformCoverageIsRefreshTokenRejected(t *testing.T) {
tests := []struct {
name string
err error
want bool
}{
{"nil", nil, false},
{"mcp authCode.notFound", &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}, true},
{"mcp other business code", &MCPTokenExchangeError{Code: "other.error", Message: "boom"}, false},
{"wrapped mcp rejection", fmt.Errorf("refresh: %w", &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode}), true},
{"http 400 has no reviewed business code", &HTTPStatusError{StatusCode: http.StatusBadRequest}, false},
{"http 401 has no reviewed business code", &HTTPStatusError{StatusCode: http.StatusUnauthorized}, false},
{"http 403 has no reviewed business code", &HTTPStatusError{StatusCode: http.StatusForbidden}, false},
{"http 500 is transient", &HTTPStatusError{StatusCode: http.StatusInternalServerError}, false},
{"http 429 is transient", &HTTPStatusError{StatusCode: http.StatusTooManyRequests}, false},
{"plain error is unknown", errors.New("boom"), false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isRefreshTokenRejected(tt.err); got != tt.want {
t.Fatalf("isRefreshTokenRejected(%v) = %v, want %v", tt.err, got, tt.want)
}
})
}
}
// orgSlotFallbackFixture wires the injectable seams lockedRefresh depends on
// and records refresh attempts plus organization slot lookups.
type orgSlotFallbackFixture struct {
provider *OAuthProvider
stale *TokenData
orgMirror *TokenData
renewed *TokenData
rejected *MCPTokenExchangeError
refreshErr error
orgRefreshErr error
refreshCalls []string
refreshUserIDs []string
orgLoads int
}
func newOrgSlotFallbackFixture(t *testing.T) *orgSlotFallbackFixture {
t.Helper()
isolateOAuthPersistence(t)
f := &orgSlotFallbackFixture{
provider: &OAuthProvider{configDir: t.TempDir(), logger: slog.New(slog.NewTextHandler(io.Discard, nil)), Output: io.Discard},
stale: &TokenData{
AccessToken: "old-access",
RefreshToken: "stale-refresh",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(time.Hour),
CorpID: "corp-1",
UserID: "user-1",
},
orgMirror: &TokenData{
AccessToken: "org-access",
RefreshToken: "org-refresh",
ExpiresAt: time.Now().Add(-time.Minute),
RefreshExpAt: time.Now().Add(time.Hour),
CorpID: "corp-1",
UserID: "user-1",
},
renewed: &TokenData{
AccessToken: "new-access",
RefreshToken: "new-refresh",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: "corp-1",
UserID: "user-1",
},
rejected: &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"},
}
f.refreshErr = f.rejected
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
testseam.Swap(t, &oauthLoadTokenLocked, func(configDir, _ string) (*TokenData, error) { return oauthLoadToken(configDir) })
testseam.Swap(t, &oauthLoadToken, func(string) (*TokenData, error) { return f.stale, nil })
testseam.Swap(t, &oauthRefreshToken, func(_ *OAuthProvider, _ context.Context, data *TokenData) (*TokenData, error) {
f.refreshCalls = append(f.refreshCalls, data.RefreshToken)
f.refreshUserIDs = append(f.refreshUserIDs, data.UserID)
switch data.RefreshToken {
case "stale-refresh":
return nil, f.refreshErr
case "org-refresh":
return f.renewed, f.orgRefreshErr
}
return nil, fmt.Errorf("unexpected refresh token %q", data.RefreshToken)
})
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(corpID string) (*TokenData, error) {
f.orgLoads++
if corpID != "corp-1" {
return nil, ErrTokenDataNotFound
}
return f.orgMirror, nil
})
return f
}
func TestCrossPlatformCoverageLockedRefreshFallsBackToOrgSlot(t *testing.T) {
f := newOrgSlotFallbackFixture(t)
got, err := f.provider.lockedRefresh(context.Background())
if err != nil || got != f.renewed {
t.Fatalf("lockedRefresh() = %#v, %v; want renewed token, nil", got, err)
}
if len(f.refreshCalls) != 2 || f.refreshCalls[0] != "stale-refresh" || f.refreshCalls[1] != "org-refresh" {
t.Fatalf("refresh attempts = %v, want [stale-refresh org-refresh]", f.refreshCalls)
}
if f.orgLoads != 1 {
t.Fatalf("organization slot loads = %d, want 1", f.orgLoads)
}
}
func TestCrossPlatformCoverageRepairMarkerForcesOrganizationSlotWrite(t *testing.T) {
cfg := &ProfilesConfig{
Version: profilesVersion,
Profiles: []Profile{
{Name: "legacy", CorpID: "corp-1", UserID: ""},
{Name: "user-1", CorpID: "corp-1", UserID: "user-1"},
},
}
selector := profileSelector("corp-1", "user-1")
without := &TokenData{CorpID: "corp-1", UserID: "user-1"}
if plan := planTokenPersistenceWrites(cfg, without, selector); plan.WriteOrganization {
t.Fatalf("explicit selector preserved unresolved org slot: WriteOrganization = true, want false")
}
with := &TokenData{CorpID: "corp-1", UserID: "user-1", RepairOrganizationMirror: true}
if plan := planTokenPersistenceWrites(cfg, with, selector); !plan.WriteOrganization {
t.Fatalf("repair marker did not force the organization slot write")
}
}
func TestCrossPlatformCoverageLockedRefreshFallbackRepairsPersistedSlots(t *testing.T) {
isolateOAuthPersistence(t)
t.Setenv("DWS_CLIENT_ID", "")
t.Setenv("DWS_CLIENT_SECRET", "")
// Fake MCP refresh endpoint: the first call rejects the stale identity
// refresh_token with the reviewed business code; the second call (the
// organization mirror) succeeds and returns a rotated credential.
var refreshCalls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if refreshCalls.Add(1) == 1 {
fmt.Fprint(w, `{"errorCode":"invalidParameter.authCode.notFound","errorMsg":"authCode not found"}`)
return
}
fmt.Fprint(w, `{"accessToken":"new-access","refreshToken":"new-refresh","expiresIn":7200,"corpId":"corp-1","userId":"user-1","userName":"User One"}`)
}))
defer srv.Close()
configDir := setupMCPConfigDir(t, srv.URL)
resetAppConfigCache()
// Seed the pre-fallback state: an identity slot whose refresh_token the
// server rejects, plus a legacy organization mirror (no userId) with a
// still-valid refresh_token and a preserved unresolved sibling profile so
// an explicit --profile refresh would normally skip the org slot.
cfg := &ProfilesConfig{
Version: profilesVersion,
Profiles: []Profile{
{Name: "corp-1", CorpID: "corp-1", UserID: "", ClientID: "mcp-client"},
{Name: "user-1", CorpID: "corp-1", UserID: "user-1", UserName: "User One", ClientID: "mcp-client"},
},
}
if err := SaveProfiles(configDir, cfg); err != nil {
t.Fatalf("SaveProfiles() error = %v", err)
}
orgMirror := &TokenData{
AccessToken: "org-access",
RefreshToken: "org-refresh",
ExpiresAt: time.Now().Add(-time.Minute),
RefreshExpAt: time.Now().Add(time.Hour),
CorpID: "corp-1",
Source: "mcp",
ClientID: "mcp-client",
}
if err := SaveTokenDataKeychainForCorpID("corp-1", orgMirror); err != nil {
t.Fatalf("SaveTokenDataKeychainForCorpID() error = %v", err)
}
staleIdentity := &TokenData{
AccessToken: "stale-access",
RefreshToken: "stale-refresh",
ExpiresAt: time.Now().Add(-time.Hour),
RefreshExpAt: time.Now().Add(time.Hour),
CorpID: "corp-1",
UserID: "user-1",
UserName: "User One",
Source: "mcp",
ClientID: "mcp-client",
}
if err := SaveTokenDataKeychainForIdentity("corp-1", "user-1", staleIdentity); err != nil {
t.Fatalf("SaveTokenDataKeychainForIdentity() error = %v", err)
}
SetRuntimeProfile("corp-1:user-1")
t.Cleanup(func() { SetRuntimeProfile("") })
p := &OAuthProvider{
configDir: configDir,
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
Output: io.Discard,
httpClient: srv.Client(),
}
got, err := p.lockedRefresh(context.Background())
if err != nil {
t.Fatalf("lockedRefresh() error = %v", err)
}
if got == nil || got.AccessToken != "new-access" || got.RefreshToken != "new-refresh" {
t.Fatalf("lockedRefresh() = %#v, want rotated credential", got)
}
if refreshCalls.Load() != 2 {
t.Fatalf("MCP refresh calls = %d, want primary rejection plus fallback", refreshCalls.Load())
}
// The fallback consumed the mirror's refresh_token: both persisted slots
// must now carry the rotated credential instead of the consumed one.
orgSlot, err := LoadTokenDataKeychainForCorpID("corp-1")
if err != nil {
t.Fatalf("LoadTokenDataKeychainForCorpID() error = %v", err)
}
if orgSlot.RefreshToken != "new-refresh" || orgSlot.UserID != "user-1" {
t.Fatalf("organization slot = %#v, want new-refresh for user-1", orgSlot)
}
identitySlot, err := LoadTokenDataKeychainForIdentity("corp-1", "user-1")
if err != nil {
t.Fatalf("LoadTokenDataKeychainForIdentity() error = %v", err)
}
if identitySlot.RefreshToken != "new-refresh" {
t.Fatalf("identity slot = %#v, want new-refresh", identitySlot)
}
}
func TestCrossPlatformCoverageLockedRefreshOrgSlotFallbackGuardrails(t *testing.T) {
t.Run("transient failure does not fall back", func(t *testing.T) {
f := newOrgSlotFallbackFixture(t)
f.refreshErr = &HTTPStatusError{StatusCode: http.StatusInternalServerError}
_, err := f.provider.lockedRefresh(context.Background())
if err == nil || err.Error() != f.refreshErr.Error() {
t.Fatalf("lockedRefresh() error = %v, want transient failure", err)
}
if f.orgLoads != 0 {
t.Fatalf("organization slot loads = %d, want 0 for transient failure", f.orgLoads)
}
if len(f.refreshCalls) != 1 {
t.Fatalf("refresh attempts = %v, want only the primary attempt", f.refreshCalls)
}
})
t.Run("missing org slot preserves rejection", func(t *testing.T) {
f := newOrgSlotFallbackFixture(t)
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) {
f.orgLoads++
return nil, ErrTokenDataNotFound
})
_, err := f.provider.lockedRefresh(context.Background())
if !errors.Is(err, f.rejected) {
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
}
if len(f.refreshCalls) != 1 {
t.Fatalf("refresh attempts = %v, want only the primary attempt", f.refreshCalls)
}
})
t.Run("nil org data from keychain preserves rejection", func(t *testing.T) {
f := newOrgSlotFallbackFixture(t)
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) {
f.orgLoads++
return nil, nil
})
_, err := f.provider.lockedRefresh(context.Background())
if !errors.Is(err, f.rejected) {
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
}
if len(f.refreshCalls) != 1 {
t.Fatalf("refresh attempts = %v, want only the primary attempt", f.refreshCalls)
}
})
t.Run("org slot refresh failure preserves rejection", func(t *testing.T) {
f := newOrgSlotFallbackFixture(t)
f.orgRefreshErr = fmt.Errorf("org mirror refresh failed")
_, err := f.provider.lockedRefresh(context.Background())
if !errors.Is(err, f.rejected) {
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
}
if len(f.refreshCalls) != 2 {
t.Fatalf("refresh attempts = %v, want primary and fallback attempts", f.refreshCalls)
}
})
t.Run("different user in org slot is rejected", func(t *testing.T) {
f := newOrgSlotFallbackFixture(t)
f.orgMirror.UserID = "user-2"
_, err := f.provider.lockedRefresh(context.Background())
if !errors.Is(err, f.rejected) {
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
}
if len(f.refreshCalls) != 1 {
t.Fatalf("refresh attempts = %v, mismatched user must not refresh", f.refreshCalls)
}
})
t.Run("empty org slot user identity is backfilled", func(t *testing.T) {
f := newOrgSlotFallbackFixture(t)
f.orgMirror.UserID = ""
f.orgMirror.UserName = ""
got, err := f.provider.lockedRefresh(context.Background())
if err != nil || got != f.renewed {
t.Fatalf("lockedRefresh() = %#v, %v; want renewed token, nil", got, err)
}
if len(f.refreshCalls) != 2 {
t.Fatalf("refresh attempts = %v, want fallback attempt", f.refreshCalls)
}
if f.refreshUserIDs[1] != "user-1" {
t.Fatalf("fallback refresh UserID = %q, want backfilled current identity user-1", f.refreshUserIDs[1])
}
})
t.Run("same rejected refresh token is skipped", func(t *testing.T) {
f := newOrgSlotFallbackFixture(t)
f.orgMirror.RefreshToken = "stale-refresh"
_, err := f.provider.lockedRefresh(context.Background())
if !errors.Is(err, f.rejected) {
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
}
if len(f.refreshCalls) != 1 {
t.Fatalf("refresh attempts = %v, retrying the rejected token must not run", f.refreshCalls)
}
})
t.Run("expired org refresh token is skipped", func(t *testing.T) {
f := newOrgSlotFallbackFixture(t)
f.orgMirror.RefreshExpAt = time.Now().Add(-time.Hour)
_, err := f.provider.lockedRefresh(context.Background())
if !errors.Is(err, f.rejected) {
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
}
if len(f.refreshCalls) != 1 {
t.Fatalf("refresh attempts = %v, want only the primary attempt", f.refreshCalls)
}
})
t.Run("missing corp id skips fallback", func(t *testing.T) {
f := newOrgSlotFallbackFixture(t)
f.stale.CorpID = ""
_, err := f.provider.lockedRefresh(context.Background())
if !errors.Is(err, f.rejected) {
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
}
if f.orgLoads != 0 {
t.Fatalf("organization slot loads = %d, want 0 without corpId", f.orgLoads)
}
})
t.Run("direct mode terminal status does not fall back without business code", func(t *testing.T) {
f := newOrgSlotFallbackFixture(t)
f.refreshErr = &HTTPStatusError{StatusCode: http.StatusBadRequest}
_, err := f.provider.lockedRefresh(context.Background())
if err == nil || err.Error() != f.refreshErr.Error() {
t.Fatalf("lockedRefresh() error = %v, want direct terminal status", err)
}
if f.orgLoads != 0 {
t.Fatalf("fallback slot loads = %d, want 0 without reviewed business code", f.orgLoads)
}
if len(f.refreshCalls) != 1 {
t.Fatalf("refresh attempts = %v, want only the primary attempt", f.refreshCalls)
}
})
}
func TestCrossPlatformCoverageRefreshFromOrgSlotBoundaries(t *testing.T) {
f := newOrgSlotFallbackFixture(t)
if _, err := f.provider.refreshFromOrgSlot(context.Background(), nil); err == nil {
t.Fatal("refreshFromOrgSlot(nil) succeeded")
}
f.orgMirror.CorpID = "corp-2"
if _, err := f.provider.refreshFromOrgSlot(context.Background(), f.stale); err == nil {
t.Fatal("refreshFromOrgSlot with mismatched corpId succeeded")
}
if len(f.refreshCalls) != 0 {
t.Fatalf("refresh attempts = %v, corpId mismatch must not refresh", f.refreshCalls)
}
}
+13
View File
@@ -87,3 +87,16 @@ func ClassifyRefreshFailure(err error) RefreshFailureClass {
}
return RefreshFailureUnknown
}
// isRefreshTokenRejected reports whether the server returned a reviewed
// business code that definitively rejects the presented refresh_token.
// Only the reviewed MCP business code enables the organization-slot
// fallback; direct-mode terminal HTTP rejections (400/401/403) carry no
// reviewed business code and deliberately do not trigger the fallback.
func isRefreshTokenRejected(err error) bool {
if err == nil {
return false
}
var exchangeErr *MCPTokenExchangeError
return errors.As(err, &exchangeErr) && exchangeErr != nil && exchangeErr.requiresReauthorization()
}
+16 -1
View File
@@ -107,6 +107,13 @@ type TokenData struct {
// transient marker to reject ambiguous UID-less logins without breaking
// legitimate refreshes of unresolved accounts.
FreshAuthorization bool `json:"-"`
// RepairOrganizationMirror marks a fallback refresh that consumed the
// organization mirror's refresh_token. The regular write plan can skip the
// organization slot under an explicit runtime selector (for example when an
// unresolved sibling profile still owns it), which would strand a
// refresh_token the server has already rotated; the marker forces the
// rotated credential back into that slot.
RepairOrganizationMirror bool `json:"-"`
}
// tokenPersistenceWritePlan is the single source of truth for deciding which
@@ -127,6 +134,7 @@ type tokenPersistenceWritePlan struct {
ExistingIdentity bool
UpgradesLegacyProfile bool
PreserveUnresolvedOrganization bool
RepairOrganizationMirror bool
WriteIdentity bool
WriteOrganization bool
WriteGlobal bool
@@ -167,6 +175,11 @@ func planTokenPersistenceWrites(
plan.PreserveUnresolvedOrganization = plan.UserID != "" &&
unresolvedProfileForCorp(cfg, plan.CorpID) != nil &&
!plan.UpgradesLegacyProfile
// A fallback refresh consumed the organization mirror's refresh_token;
// the rotated credential must go back into that slot even when the
// selector-driven plan would skip it (for example an explicit --profile
// that preserves an unresolved sibling profile's slot).
plan.RepairOrganizationMirror = data.RepairOrganizationMirror
plan.WriteIdentity = plan.UserID != ""
orgCurrentSelector := ""
if cfg != nil {
@@ -177,7 +190,8 @@ func planTokenPersistenceWrites(
// reauthorization. Its organization slot must move with the newly exact
// identity even when an explicit runtime selector keeps it from becoming
// process-global current.
plan.WriteOrganization = plan.UserID == "" ||
plan.WriteOrganization = plan.RepairOrganizationMirror ||
plan.UserID == "" ||
plan.UpgradesLegacyProfile ||
(!plan.PreserveUnresolvedOrganization &&
(plan.MakeCurrent ||
@@ -387,6 +401,7 @@ func saveTokenDataLocked(configDir string, data *TokenData) error {
"persistence_profile", plan.PersistenceSelector,
"write_identity_slot", plan.WriteIdentity,
"write_org_mirror", plan.WriteOrganization,
"repair_org_mirror", plan.RepairOrganizationMirror,
"write_global_mirror", plan.WriteGlobal,
"publish_incoming_global", plan.MakeCurrent,
)
+2 -2
View File
@@ -4243,7 +4243,7 @@ var generatedParamAliases = []ParamAliasEntry{
"start-block": "start-block-id",
"url": "node",
},
Blocked: []string{"comment-id", "comment-key", "content-format", "dentry-id", "doc-format", "folder", "folder-id", "id", "job-id", "name", "parent-id", "range", "role", "space-id", "task-id", "template-id", "version", "workspace", "workspace-id"},
Blocked: []string{"comment-id", "comment-key", "content-format", "dentry-id", "doc-format", "folder", "folder-id", "id", "job-id", "name", "parent-id", "range", "role", "space-id", "task-id", "template-id", "workspace", "workspace-id"},
Ambiguous: []string{"block-id"},
},
{
@@ -4859,7 +4859,7 @@ var generatedParamAliases = []ParamAliasEntry{
"output-path": "output",
"save-path": "output",
},
Blocked: []string{"before-block-id", "comment-id", "comment-key", "content-file", "dentry-id", "file", "file-path", "folder", "folder-id", "job-id", "name", "parent-block-id", "parent-id", "ref-block-id", "reference-block-id", "revision", "role", "space-id", "task-id", "template-id", "version", "workspace", "workspace-id"},
Blocked: []string{"before-block-id", "comment-id", "comment-key", "content-file", "dentry-id", "file", "file-path", "folder", "folder-id", "job-id", "name", "parent-block-id", "parent-id", "ref-block-id", "reference-block-id", "revision", "role", "space-id", "task-id", "template-id", "workspace", "workspace-id"},
Ambiguous: []string{"block-id"},
},
{
+10 -1
View File
@@ -301,7 +301,7 @@ func TestDeliveryCatalogDocReadParamDeclsMatchMergeBaseContract(t *testing.T) {
t.Fatalf("doc read --content-format required = %#v, want false", contentFormat["required"])
}
for _, flagName := range []string{"scope", "tags", "max-depth", "start-block-id", "end-block-id"} {
for _, flagName := range []string{"scope", "tags", "max-depth", "start-block-id", "end-block-id", "version", "password"} {
if parameters[flagName]["required"] != false {
t.Fatalf("doc read --%s required = %#v, want false", flagName, parameters[flagName]["required"])
}
@@ -315,6 +315,15 @@ func TestDeliveryCatalogDocReadParamDeclsMatchMergeBaseContract(t *testing.T) {
if parameters["max-depth"]["type"] != "integer" {
t.Fatalf("doc read --max-depth type = %#v, want integer", parameters["max-depth"]["type"])
}
if got := parameters["version"]["property"]; got != "historyVersion" {
t.Fatalf("doc read --version property = %#v, want historyVersion", got)
}
if parameters["version"]["type"] != "integer" {
t.Fatalf("doc read --version type = %#v, want integer", parameters["version"]["type"])
}
if got := parameters["password"]["property"]; got != "password" {
t.Fatalf("doc read --password property = %#v, want password", got)
}
}
func TestDeliveryCatalogDocCommentParamDeclsMatchMergeBaseContract(t *testing.T) {
@@ -76,6 +76,7 @@ var reviewedRuntimeSchemaExclusionGroups = []runtimeSchemaExclusionGroup{
"agoal scorecard detail",
"agoal scorecard entity-detail",
"agoal scorecard update",
"agoal scorecard search-entities",
"agoal strategy detail",
"agoal strategy list",
"agoal strategy update",
+7 -1
View File
@@ -32,12 +32,18 @@ func ValidateInputSchema(params map[string]any, schema map[string]any) error {
if params == nil {
params = map[string]any{}
}
if err := validateSchemaValue("$", params, schema); err != nil {
if err := ValidateJSONSchemaValue(params, schema); err != nil {
return apperrors.NewValidation(fmt.Sprintf("input schema validation failed: %v", err))
}
return nil
}
// ValidateJSONSchemaValue validates one decoded JSON value against the
// required/type/enum/properties/items subset used by reviewed CLI contracts.
func ValidateJSONSchemaValue(value any, schema map[string]any) error {
return validateSchemaValue("$", value, schema)
}
func validateSchemaValue(path string, value any, schema map[string]any) error {
if len(schema) == 0 {
return nil
+32 -1
View File
@@ -31,6 +31,7 @@ func newAgoalCommand() *cobra.Command {
dws agoal scorecard detail 获取计分卡详情
dws agoal scorecard entity-detail 获取计分卡实体详情
dws agoal scorecard update 更新计分卡
dws agoal scorecard search-entities 搜索计分卡指标与关键事项
dws agoal user rules 获取用户规则
dws agoal user objectives 查询用户目标列表
dws agoal report list-statistics 获取周月报数据跟催列表
@@ -359,7 +360,37 @@ scopeType 支持:
scorecardUpdateCmd.Flags().String("content", "", "内容 JSON 数组 (必填)")
scorecardUpdateCmd.Flags().String("request-id", "", "requestId (可选)")
scorecardCmd.AddCommand(scorecardDetailCmd, scorecardEntityDetailCmd, scorecardUpdateCmd)
scorecardSearchContentCmd := &cobra.Command{
Use: "search-entities",
Short: "搜索计分卡指标与关键事项",
Long: `根据关键词模糊搜索计分卡中的指标和关键事项标题,返回匹配的计分卡实体信息(计分卡ID、实体ID、实体类型、标题、所属团队等)。`,
Example: ` dws agoal scorecard search-entities --keyword "业绩"
dws agoal scorecard search-entities --keyword "业绩" --page 1 --page-size 20`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "keyword"); err != nil {
return err
}
toolArgs := map[string]any{
"keyword": mustGetFlag(cmd, "keyword"),
}
if v, _ := cmd.Flags().GetString("request-id"); v != "" {
toolArgs["requestId"] = v
}
if v, _ := cmd.Flags().GetInt("page"); v != 0 {
toolArgs["page"] = v
}
if v, _ := cmd.Flags().GetInt("page-size"); v != 0 {
toolArgs["pageSize"] = v
}
return callMCPTool("search_score_card_entities", toolArgs)
},
}
scorecardSearchContentCmd.Flags().String("keyword", "", "搜索关键词,标题模糊匹配 (必填)")
scorecardSearchContentCmd.Flags().String("request-id", "", "requestId (可选)")
scorecardSearchContentCmd.Flags().Int("page", 0, "页码,默认 1 (可选)")
scorecardSearchContentCmd.Flags().Int("page-size", 0, "每页数量,最大 100 (可选)")
scorecardCmd.AddCommand(scorecardDetailCmd, scorecardEntityDetailCmd, scorecardUpdateCmd, scorecardSearchContentCmd)
// ── user: 用户目标管理 ──────────────────────────────────────
+48 -10
View File
@@ -1429,9 +1429,14 @@ func newDocCommand() *cobra.Command {
readCmd := &cobra.Command{
Use: "read",
Short: "读取文档内容 (Markdown)",
Long: `获取文档内容,以 Markdown 格式返回。支持传入文档 URL 或 ID。`,
Long: `获取文档内容,以 Markdown 格式返回。支持传入文档 URL 或 ID。
互联网公开文档(含开启密码保护的)可传入公开链接;设置了访问密码时通过 --password 提供。
--version 读取指定历史版本内容(版本号从 dws doc version list 获取,0 表示文档初始版本,需要文档编辑权限);缺省读最新版。`,
Example: ` dws doc read --node DOC_ID
dws doc read --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>"`,
dws doc read --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>"
dws doc read --node PUBLIC_URL --password <ACCESS_PASSWORD>
dws doc read --node DOC_ID --version 3`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
@@ -1441,6 +1446,15 @@ func newDocCommand() *cobra.Command {
"dws doc read --node DOC_ID --content-format jsonml"); err != nil {
return err
}
password, _ := cmd.Flags().GetString("password")
historyVersion := 0
historyVersionSet := cmd.Flags().Changed("version")
if historyVersionSet {
historyVersion, _ = cmd.Flags().GetInt("version")
if historyVersion < 0 {
return fmt.Errorf("--version 必须为非负整数历史版本号(0 表示初始版本,版本号从 dws doc version list 获取),当前值: %d", historyVersion)
}
}
format, _ := cmd.Flags().GetString("content-format")
scope, _ := cmd.Flags().GetString("scope")
tags, _ := cmd.Flags().GetString("tags")
@@ -1481,15 +1495,18 @@ func newDocCommand() *cobra.Command {
startBlockID,
endBlockID,
outputPath,
password,
historyVersion,
historyVersionSet,
)
}
if format == "jsonml" {
outputPath, _ := cmd.Flags().GetString("output")
return runDocReadJsonML(cmd, nodeID, outputPath)
return runDocReadJsonML(nodeID, outputPath, password, historyVersion, historyVersionSet)
}
return callMCPTool("get_document_content", map[string]any{
"nodeId": nodeID,
})
toolArgs := map[string]any{"nodeId": nodeID}
applyDocReadAccessParams(toolArgs, password, historyVersion, historyVersionSet)
return callMCPTool("get_document_content", toolArgs)
},
}
DeclareLeafMetadata(readCmd, LeafSpec{
@@ -1516,6 +1533,8 @@ func newDocCommand() *cobra.Command {
UseWhen: []string{
"用户要读取钉钉在线文字文档(adoc)正文(Markdown)时",
"用户直接粘贴文档 URL 且无其他指令时(默认读内容)",
"互联网公开文档(含设置密码保护的公开链接)时配合 --password 提供访问密码",
"要读取指定历史版本内容时用 --version(版本号来自 doc version list,0 表示初始版本,需要编辑权限)",
"只需标题大纲、指定块区间/单块或特定 JSONML tags 时使用 --content-format jsonml 与 --scope",
},
AvoidWhen: []string{
@@ -1533,9 +1552,11 @@ func newDocCommand() *cobra.Command {
{Name: "content-format", Property: "format", Required: boolPtr(false)},
{Name: "end-block-id", Required: boolPtr(false)},
{Name: "max-depth", Required: boolPtr(false), InterfaceType: "integer"},
{Name: "password", Property: "password", Required: boolPtr(false)},
{Name: "scope", Required: boolPtr(false)},
{Name: "start-block-id", Required: boolPtr(false), RequiredWhen: "--scope=range or --scope=section"},
{Name: "tags", Required: boolPtr(false), RequiredWhen: "--scope=tags"},
{Name: "version", Property: "historyVersion", Required: boolPtr(false), InterfaceType: "integer"},
},
},
})
@@ -2680,6 +2701,8 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。
readCmd.Flags().Int("max-depth", 0, "筛选遍历最大深度, 0 表示不限(仅 --scope 时生效)")
readCmd.Flags().String("start-block-id", "", "range/section 起始块 ID(节点 uuid); scope=range/section 时必填")
readCmd.Flags().String("end-block-id", "", "range 结束块 ID(节点 uuid); \"-1\"或空=到文档末尾(仅 scope=range 生效)")
readCmd.Flags().String("password", "", "互联网公开文档开启密码保护时的访问密码;普通文档无需传入")
readCmd.Flags().Int("version", 0, "读取指定历史版本内容(版本号从 doc version list 获取, 0 表示初始版本, 需要文档编辑权限);缺省读最新版")
cli.AnnotateRuntimeFlagEnum(readCmd, "scope", "outline", "range", "section", "tags")
cli.AnnotateRuntimeFlagRequiredWhen(readCmd, "tags", "--scope=tags")
cli.AnnotateRuntimeFlagRequiredWhen(readCmd, "start-block-id", "--scope=range or --scope=section")
@@ -4752,16 +4775,30 @@ func wrapDocDeprecatedToTarget(cmd *cobra.Command, targetCmd string) {
}
}
// applyDocReadAccessParams 把 doc read 的访问参数(互联网公开文档密码、历史版本号)
// 附加到 get_document_content 请求上;空密码或未显式设置版本时不发送对应字段,
// 显式 --version 0 表示读取文档初始版本。
func applyDocReadAccessParams(args map[string]any, password string, historyVersion int, historyVersionSet bool) {
if password != "" {
args["password"] = password
}
if historyVersionSet && historyVersion >= 0 {
args["historyVersion"] = historyVersion
}
}
// resolveContentFromFlags 从 --content-file / --content-path / --content / --markdown 获取文档内容。
// 优先级:--content-file/--content-path > --content > --markdown(已弃用别名,向后兼容)。
func runDocReadJsonML(_ *cobra.Command, nodeID string, outputPath string) error {
func runDocReadJsonML(nodeID, outputPath, password string, historyVersion int, historyVersionSet bool) error {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
resultText, err := callMCPToolReturnText(ctx, "get_document_content", map[string]any{
toolArgs := map[string]any{
"nodeId": nodeID,
"format": "jsonml",
})
}
applyDocReadAccessParams(toolArgs, password, historyVersion, historyVersionSet)
resultText, err := callMCPToolReturnText(ctx, "get_document_content", toolArgs)
if err != nil {
return err
}
@@ -4813,7 +4850,7 @@ func runDocReadJsonML(_ *cobra.Command, nodeID string, outputPath string) error
// runDocReadScope calls get_document_content with JSONML filtering parameters
// and preserves the returned read-only fragment container.
func runDocReadScope(nodeID, scope, tags string, maxDepth int, maxDepthSet bool, startBlockID, endBlockID, outputPath string) error {
func runDocReadScope(nodeID, scope, tags string, maxDepth int, maxDepthSet bool, startBlockID, endBlockID, outputPath, password string, historyVersion int, historyVersionSet bool) error {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
@@ -4833,6 +4870,7 @@ func runDocReadScope(nodeID, scope, tags string, maxDepth int, maxDepthSet bool,
if endBlockID != "" && scope == "range" {
args["endBlockId"] = endBlockID
}
applyDocReadAccessParams(args, password, historyVersion, historyVersionSet)
resultText, err := callMCPToolReturnTextOnServer(ctx, "doc", "get_document_content", args)
if err != nil {
+1 -1
View File
@@ -144,7 +144,7 @@ func TestCrossPlatformCoverageRunDocReadJSONMLCoverage(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{tc.step}}
installScriptedCaller(t, caller)
err := runDocReadJsonML(&cobra.Command{}, "node", tc.output)
err := runDocReadJsonML("node", tc.output, "", 0, false)
if tc.wantFail && err == nil {
t.Fatal("expected failure")
}
+95 -5
View File
@@ -77,7 +77,7 @@ func TestCrossPlatformCoverageDocReadScopeFlagsAndValidation(t *testing.T) {
if err != nil || len(remaining) != 0 {
t.Fatalf("find read: remaining=%v err=%v", remaining, err)
}
for _, name := range []string{"scope", "tags", "max-depth", "start-block-id", "end-block-id"} {
for _, name := range []string{"scope", "tags", "max-depth", "start-block-id", "end-block-id", "password", "version"} {
if read.Flags().Lookup(name) == nil {
t.Fatalf("doc read missing --%s", name)
}
@@ -249,7 +249,7 @@ func TestCrossPlatformCoverageRunDocReadScopeResponseBranches(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
output := installDocReadScopeCaller(t, tt.caller)
err := runDocReadScope("doc-1", "", "", 0, false, "", "ignored", tt.output)
err := runDocReadScope("doc-1", "", "", 0, false, "", "ignored", tt.output, "", 0, false)
if tt.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error = %v, want %q", err, tt.wantErr)
@@ -277,7 +277,7 @@ func TestCrossPlatformCoverageRunDocReadScopeResponseBranches(t *testing.T) {
caller := &docReadScopeCaller{text: `{"jsonml":"[\"fragment\",{}]"}`}
stdout := installDocReadScopeCaller(t, caller)
outputPath := filepath.Join(t.TempDir(), "fragment.json")
if err := runDocReadScope("doc-1", "outline", "", 0, false, "", "", outputPath); err != nil {
if err := runDocReadScope("doc-1", "outline", "", 0, false, "", "", outputPath, "", 0, false); err != nil {
t.Fatalf("runDocReadScope: %v", err)
}
var receipt map[string]any
@@ -292,7 +292,7 @@ func TestCrossPlatformCoverageRunDocReadScopeResponseBranches(t *testing.T) {
t.Run("human output reports missing fragment", func(t *testing.T) {
caller := &docReadScopeCaller{text: `{}`, format: "raw"}
stdout := installDocReadScopeCaller(t, caller)
if err := runDocReadScope("doc-1", "", "", 0, false, "", "", ""); err != nil {
if err := runDocReadScope("doc-1", "", "", 0, false, "", "", "", "", 0, false); err != nil {
t.Fatalf("runDocReadScope: %v", err)
}
if !strings.Contains(stdout.String(), "未匹配到节点") {
@@ -304,7 +304,7 @@ func TestCrossPlatformCoverageRunDocReadScopeResponseBranches(t *testing.T) {
caller := &docReadScopeCaller{text: `{"jsonml":"[\"fragment\",{}]"}`, format: "raw"}
stdout := installDocReadScopeCaller(t, caller)
outputPath := filepath.Join(t.TempDir(), "fragment.json")
if err := runDocReadScope("doc-1", "outline", "", 0, false, "", "", outputPath); err != nil {
if err := runDocReadScope("doc-1", "outline", "", 0, false, "", "", outputPath, "", 0, false); err != nil {
t.Fatalf("runDocReadScope: %v", err)
}
if !strings.Contains(stdout.String(), "JSONML fragment 已写入 "+outputPath) {
@@ -312,3 +312,93 @@ func TestCrossPlatformCoverageRunDocReadScopeResponseBranches(t *testing.T) {
}
})
}
// TestCrossPlatformCoverageDocReadAccessParamsForwarding 验证 doc read 三条读取路径
// 都把 --password / --version 透传到 get_document_content,且非法版本号在
// 本地校验阶段被拒绝、不产生远端调用。
func TestCrossPlatformCoverageDocReadAccessParamsForwarding(t *testing.T) {
t.Run("markdown path forwards password and history version", func(t *testing.T) {
caller := &docReadScopeCaller{text: `{"markdown":"body"}`}
if err := executeDocReadScopeCommand(t, caller, "read", "--node", "doc-1", "--password", "pw", "--version", "7"); err != nil {
t.Fatalf("execute: %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %d, want 1", len(caller.calls))
}
want := map[string]any{"nodeId": "doc-1", "password": "pw", "historyVersion": 7}
if caller.calls[0].tool != "get_document_content" || !reflect.DeepEqual(caller.calls[0].args, want) {
t.Fatalf("call = %#v, want get_document_content %#v", caller.calls[0], want)
}
})
t.Run("jsonml path forwards password and history version", func(t *testing.T) {
caller := &docReadScopeCaller{text: `{"jsonml":"[\"root\",{}]","revision":7}`}
if err := executeDocReadScopeCommand(t, caller, "read", "--node", "doc-1", "--content-format", "jsonml", "--password", "pw", "--version", "7"); err != nil {
t.Fatalf("execute: %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %d, want 1", len(caller.calls))
}
want := map[string]any{"nodeId": "doc-1", "format": "jsonml", "password": "pw", "historyVersion": 7}
if caller.calls[0].tool != "get_document_content" || !reflect.DeepEqual(caller.calls[0].args, want) {
t.Fatalf("call = %#v, want get_document_content %#v", caller.calls[0], want)
}
})
t.Run("scope path forwards password and history version", func(t *testing.T) {
caller := &docReadScopeCaller{text: `{"jsonml":"[\"fragment\",{},[\"h1\",{},\"t\"]]"}`}
if err := executeDocReadScopeCommand(t, caller,
"read", "--node", "doc-1", "--content-format", "jsonml", "--scope", "outline",
"--password", "pw", "--version", "7"); err != nil {
t.Fatalf("execute: %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %d, want 1", len(caller.calls))
}
want := map[string]any{"nodeId": "doc-1", "format": "jsonml", "scope": "outline", "password": "pw", "historyVersion": 7}
if caller.calls[0].tool != "get_document_content" || !reflect.DeepEqual(caller.calls[0].args, want) {
t.Fatalf("call = %#v, want get_document_content %#v", caller.calls[0], want)
}
})
t.Run("negative history version is rejected locally", func(t *testing.T) {
for _, raw := range []string{"-1", "-3"} {
caller := &docReadScopeCaller{}
err := executeDocReadScopeCommand(t, caller, "read", "--node", "doc-1", "--version", raw)
if err == nil || !strings.Contains(err.Error(), "--version 必须为非负整数") {
t.Fatalf("--version %s error = %v, want invalid --version", raw, err)
}
if len(caller.calls) != 0 {
t.Fatalf("--version %s produced %d remote calls, want 0", raw, len(caller.calls))
}
}
})
t.Run("explicit zero history version forwards initial version", func(t *testing.T) {
caller := &docReadScopeCaller{text: `{"markdown":"body"}`}
if err := executeDocReadScopeCommand(t, caller, "read", "--node", "doc-1", "--version", "0"); err != nil {
t.Fatalf("execute: %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %d, want 1", len(caller.calls))
}
want := map[string]any{"nodeId": "doc-1", "historyVersion": 0}
if caller.calls[0].tool != "get_document_content" || !reflect.DeepEqual(caller.calls[0].args, want) {
t.Fatalf("call = %#v, want get_document_content %#v", caller.calls[0], want)
}
})
t.Run("unset history version is not forwarded", func(t *testing.T) {
caller := &docReadScopeCaller{text: `{"markdown":"body"}`}
if err := executeDocReadScopeCommand(t, caller, "read", "--node", "doc-1"); err != nil {
t.Fatalf("execute: %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %d, want 1", len(caller.calls))
}
want := map[string]any{"nodeId": "doc-1"}
if caller.calls[0].tool != "get_document_content" || !reflect.DeepEqual(caller.calls[0].args, want) {
t.Fatalf("call = %#v, want get_document_content %#v", caller.calls[0], want)
}
})
}
+3 -3
View File
@@ -147,9 +147,9 @@ func TestCrossPlatformCoverageRunDocReadJsonMLCoverage(t *testing.T) {
`{"jsonml":"{\"type\":\"doc\"}","revision":"bad"}`,
} {
caller.result = &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}
_ = runDocReadJsonML(nil, "node", "")
_ = runDocReadJsonML("node", "", "", 0, false)
}
caller.result = &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"jsonml":"{\"type\":\"doc\"}"}`}}}
_ = runDocReadJsonML(nil, "node", filepath.Join(t.TempDir(), "out.json"))
_ = runDocReadJsonML(nil, "node", filepath.Join(t.TempDir(), "missing", "out.json"))
_ = runDocReadJsonML("node", filepath.Join(t.TempDir(), "out.json"), "", 0, false)
_ = runDocReadJsonML("node", filepath.Join(t.TempDir(), "missing", "out.json"), "pw", 3, true)
}
+288 -4
View File
@@ -2,10 +2,13 @@ package helpers
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"time"
@@ -18,6 +21,9 @@ import (
"github.com/spf13/cobra"
)
// computeFileMD5 is the function used to calculate file MD5. Tests can override via direct assignment.
var computeFileMD5 = fileMD5Hex
func decodeOARequest(raw string) (map[string]any, error) {
dec := json.NewDecoder(bytes.NewBufferString(raw))
dec.UseNumber()
@@ -126,7 +132,13 @@ func validateOAPreviewFileIDs(cmd *cobra.Command, _ []string) error {
}
func callOAAttachmentResult(cmd *cobra.Command, tool string, args map[string]any) (output.CommandResult, error) {
data, err := CallMCPToolDataOnServer(cmd.Context(), "oa", tool, args)
return callOAAttachmentResultCtx(cmd.Context(), tool, args)
}
// callOAAttachmentResultCtx 复用统一结果投影逻辑,但允许调用方传入自定义 context
// (例如 upload 命令的 10 分钟超时):调用 oa/<tool>,提取 result 并包装为 output.Success。
func callOAAttachmentResultCtx(ctx context.Context, tool string, args map[string]any) (output.CommandResult, error) {
data, err := CallMCPToolDataOnServer(ctx, "oa", tool, args)
if err != nil {
return nil, err
}
@@ -141,10 +153,225 @@ func callOAAttachmentResult(cmd *cobra.Command, tool string, args map[string]any
return output.Success(result), nil
}
// validateOAAttachmentCommitResult 校验 commit_attachment_upload_info 的原始 result
// 包含构造 DDAttachment 所需的全部必需字段,并将 spaceId/fileSize 归一化为声明的 integer
// 类型(int64),确保输出始终符合 ResultSpec schema。当字段缺失或类型错误时返回 Validation
// 错误,避免 Agent 拿到空 fileId 后组装无效的审批表单。
func validateOAAttachmentCommitResult(result any) (map[string]any, error) {
resultMap, ok := result.(map[string]any)
if !ok || resultMap == nil {
return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值 result 不是有效的 JSON 对象")
}
// spaceId — 接受 string 或 number,归一化为 int64
switch v := resultMap["spaceId"].(type) {
case string:
if strings.TrimSpace(v) == "" {
return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值缺少必需字段 spaceId")
}
parsed, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
if err != nil {
return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值字段 spaceId 不是有效整数")
}
resultMap["spaceId"] = parsed
case float64:
// numeric spaceId — keep as-is (already matches JSON number)
case json.Number:
parsed, err := v.Int64()
if err != nil {
return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值字段 spaceId 不是有效整数")
}
resultMap["spaceId"] = parsed
default:
return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值缺少必需字段 spaceId")
}
// fileName — must be non-empty string
if s, ok := resultMap["fileName"].(string); !ok || strings.TrimSpace(s) == "" {
return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值缺少必需字段 fileName")
}
// fileSize — must be > 0 (number),归一化 json.Number → int64
switch v := resultMap["fileSize"].(type) {
case float64:
if v <= 0 {
return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值字段 fileSize 必须大于 0")
}
case json.Number:
parsed, err := v.Int64()
if err != nil || parsed <= 0 {
return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值字段 fileSize 必须大于 0")
}
resultMap["fileSize"] = parsed
default:
return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值缺少必需字段 fileSize")
}
// fileId — must be non-empty string
if s, ok := resultMap["fileId"].(string); !ok || strings.TrimSpace(s) == "" {
return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值缺少必需字段 fileId")
}
return resultMap, nil
}
// parseOAAttachmentUploadInfo 解析 oa/init_attachment_upload_info 的返回,提取首个上传地址、
// 签名请求头与 uploadKey。OA 的返回结构为 result.resourceUrls([]string)、result.headers(object)、
// result.uploadKey(string),与钉盘 doc 的 resourceUrl(单数)不同,因此单独实现。
func parseOAAttachmentUploadInfo(text string) (resourceURL string, headers map[string]string, uploadKey string, err error) {
var data map[string]any
if err = json.Unmarshal([]byte(text), &data); err != nil {
return "", nil, "", apperrors.NewInternal(fmt.Sprintf("解析 init_attachment_upload_info 返回失败: %v", err))
}
result, ok := data["result"].(map[string]any)
if !ok {
return "", nil, "", apperrors.NewInternal("oa/init_attachment_upload_info 返回值缺少 result")
}
uploadKey, _ = result["uploadKey"].(string)
if strings.TrimSpace(uploadKey) == "" {
return "", nil, "", apperrors.NewValidation("oa/init_attachment_upload_info 返回值缺少 uploadKey")
}
rawURLs, ok := result["resourceUrls"].([]any)
if !ok || len(rawURLs) == 0 {
return "", nil, "", apperrors.NewValidation("oa/init_attachment_upload_info 返回值缺少 resourceUrls")
}
resourceURL, _ = rawURLs[0].(string)
if strings.TrimSpace(resourceURL) == "" {
return "", nil, "", apperrors.NewValidation("oa/init_attachment_upload_info 首个 resourceUrls 为空")
}
rawHeaders, ok := result["headers"].(map[string]any)
if !ok {
return "", nil, "", apperrors.NewValidation("oa/init_attachment_upload_info 返回值缺少 headers")
}
headers = make(map[string]string)
for key, value := range rawHeaders {
if str, ok := value.(string); ok {
headers[key] = str
}
}
if strings.TrimSpace(headers["Authorization"]) == "" || strings.TrimSpace(headers["x-oss-date"]) == "" {
return "", nil, "", apperrors.NewValidation("oa/init_attachment_upload_info headers 缺少必需的签名字段 Authorization 或 x-oss-date")
}
return resourceURL, headers, uploadKey, nil
}
// runOAAttachmentUpload 端到端上传审批附件:init 获取 OSS 上传凭证 → HTTP PUT 上传文件字节
// → commit 提交入库,一条命令完成三步。PUT 复用包级 httpPutFile(它会删除 Content-Type 并
// 写入签名请求头,避免钉钉 OSS SignatureDoesNotMatch),不要在此重复实现。
func runOAAttachmentUpload(cmd *cobra.Command, _ []string) error {
filePath := strings.TrimSpace(mustGetFlag(cmd, "file"))
if filePath == "" {
return apperrors.NewValidation("--file 不能为空")
}
info, err := os.Stat(filePath)
if err != nil {
return apperrors.NewValidation(fmt.Sprintf("无法读取文件 %s: %v", filePath, err))
}
if info.IsDir() {
return apperrors.NewValidation(fmt.Sprintf("%s 是目录,不是文件", filePath))
}
fileSize := info.Size()
fileName := strings.TrimSpace(mustGetFlag(cmd, "file-name"))
if fileName == "" {
fileName = filepath.Base(filePath)
}
md5Hex := strings.TrimSpace(mustGetFlag(cmd, "md5"))
if md5Hex == "" {
md5Hex, err = computeFileMD5(filePath)
if err != nil {
return apperrors.NewInternal(fmt.Sprintf("计算文件 MD5 失败: %v", err))
}
}
// --dry-run:本地只读工作(校验、Stat、大小、文件名、MD5)已完成,
// 在任何远程调用(init/PUT/commit)之前 early return,输出 plan 预览。
// 本命令是 RolloutUnifiedActive,必须经 output.StoreResult 存入统一结果,
// 不能直接 PrintJSON(否则框架报 "returned without a CommandResult")。
// 不伪造 uploadKey/resourceURL/fileId/spaceId——它们只能由远程调用返回。
if deps.Caller.DryRun() {
return output.StoreResult(cmd.Context(), output.Success(map[string]any{
"dry_run": true,
"executed": false,
"preview_kind": "plan",
"operation": "attachment_upload",
"source": "oa",
"file": filePath,
"file_name": fileName,
"file_size": fileSize,
"md5": md5Hex,
"steps": []map[string]any{
{
"tool": "oa/init_attachment_upload_info",
"args": map[string]any{"fileName": fileName, "fileSize": fileSize, "md5": md5Hex},
"status": "planned",
},
{
"tool": "HTTP PUT",
"args": map[string]any{"file": filePath, "fileSize": fileSize},
"status": "planned",
},
{
"tool": "oa/commit_attachment_upload_info",
"args": map[string]any{"fileName": fileName, "fileSize": fileSize},
"requires": []string{"uploadKey from oa/init_attachment_upload_info"},
"status": "planned",
},
},
}, output.WithDryRun()))
}
ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Minute)
defer cancel()
// Step 1: 初始化上传,拿到 OSS 上传地址、签名头与 uploadKey。
initText, err := callMCPToolReturnTextOnServer(ctx, "oa", "init_attachment_upload_info", map[string]any{
"fileName": fileName,
"fileSize": float64(fileSize),
"md5": md5Hex,
})
if err != nil {
return err
}
resourceURL, headers, uploadKey, err := parseOAAttachmentUploadInfo(initText)
if err != nil {
return err
}
// Step 2: HTTP PUT 文件字节到 OSS(复用 httpPutFile,勿重复实现)。
if err := httpPutFile(ctx, resourceURL, headers, filePath, fileSize); err != nil {
return err
}
// Step 3: 提交上传信息完成入库,校验必需字段后以统一输出渲染 commit 结果。
commitData, err := CallMCPToolDataOnServer(ctx, "oa", "commit_attachment_upload_info", map[string]any{
"fileName": fileName,
"uploadKey": uploadKey,
"fileSize": float64(fileSize),
})
if err != nil {
return err
}
commitResp, ok := commitData.(map[string]any)
if !ok {
return apperrors.NewInternal("oa/commit_attachment_upload_info 返回值不是 JSON 对象")
}
commitResult, _ := commitResp["result"]
normalized, err := validateOAAttachmentCommitResult(commitResult)
if err != nil {
return err
}
return output.StoreResult(cmd.Context(), output.Success(normalized))
}
func newOAAttachmentCommand() *cobra.Command {
attachmentCmd := &cobra.Command{
Use: "attachment",
Short: "审批附件授权与下载链接",
Short: "审批附件授权、上传、下载与链接管理",
RunE: groupRunE,
}
@@ -319,7 +546,64 @@ func newOAAttachmentCommand() *cobra.Command {
},
})
attachmentCmd.AddCommand(downloadURLCmd, authorizeDownloadCmd, authorizePreviewCmd)
uploadCmd := &cobra.Command{
Use: "upload",
Short: "上传本地文件为审批附件(初始化+PUT+提交,一步完成)",
Long: `上传本地文件为审批附件(三步自动完成)。
流程:
1. 初始化上传信息,获取 OSS 上传地址与凭证 (oa/init_attachment_upload_info)
2. HTTP PUT 上传文件二进制到 OSS
3. 提交上传信息完成入库 (oa/commit_attachment_upload_info)
--file-name 不传时默认用文件名;--md5 不传时自动计算。`,
Example: ` dws oa approval attachment upload --file ./合同.pdf
dws oa approval attachment upload --file ./report.xlsx --file-name Q1报表.xlsx
dws oa approval attachment upload --file ./data.bin --md5 d41d8cd98f00b204e9800998ecf8427e`,
RunE: runOAAttachmentUpload,
}
DeclareLeafMetadata(uploadCmd, LeafSpec{
OutputRollout: output.RolloutUnifiedActive,
Safety: contract.SafetySpec{
Effect: "write", Risk: "low",
Confirmation: "not_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "oa",
Name: "attachment_upload",
CanonicalPath: "oa.attachment_upload",
CLIPath: "oa approval attachment upload",
PrimaryCLIPath: "oa approval attachment upload",
},
Description: "上传本地文件为审批附件,一条命令完成初始化、HTTP PUT 与提交入库",
DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false},
Result: &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: json.RawMessage(`{"type":"object","description":"审批附件上传提交结果","properties":{"spaceId":{"type":"integer","description":"审批附件所在钉盘空间 ID"},"fileName":{"type":"string","description":"文件名"},"fileSize":{"type":"integer","description":"文件字节数"},"class":{"type":"string","description":"服务端响应类型标识"},"fileType":{"type":"string","description":"文件类型"},"fileId":{"type":"string","description":"文件 ID"}},"required":["spaceId","fileName","fileSize","fileId"],"additionalProperties":true}`),
},
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "命令包含多个 RPC(init/commit)与本地 HTTP PUT 步骤,不能绑定为单一 interface_ref",
},
Selection: contract.SelectionSpec{
AgentSummary: "上传本地文件为审批附件,自动完成初始化+PUT+提交三步",
UseWhen: []string{"用户要把本地文件作为审批附件上传(首选一条命令自动完成凭证+PUT+提交)时"},
AvoidWhen: []string{
"仅需为已有附件授权下载或预览时使用 attachment authorize-download / authorize-preview",
"仅需获取已有附件临时下载链接时使用 attachment download-url",
},
Examples: []string{"dws oa approval attachment upload --file ./合同.pdf --format json"},
},
},
})
uploadCmd.Flags().String("file", "", "本地文件路径 (必填)")
uploadCmd.Flags().String("file-name", "", "完整文件名,例如 合同.pdf (默认使用文件名)")
uploadCmd.Flags().String("md5", "", "文件原始字节内容的 MD5,32位十六进制字符串 (可选,不传则自动计算)")
_ = uploadCmd.MarkFlagRequired("file")
attachmentCmd.AddCommand(downloadURLCmd, authorizeDownloadCmd, authorizePreviewCmd, uploadCmd)
return attachmentCmd
}
@@ -421,7 +705,7 @@ func validateOARequestTimeRange(request map[string]any) error {
// get_inst_revert_activities, get_process_schema, forecast_process,
// start_process_instance, get_process_instances_by_admin,
// get_attachment_download_url, auth_download_file,
// auth_preview_attachment
// auth_preview_attachment, init_attachment_upload_info, commit_attachment_upload_info
// ──────────────────────────────────────────────────────────
func newOaCommand() *cobra.Command {
+846
View File
@@ -10,7 +10,9 @@ import (
"errors"
"io"
"os"
"path/filepath"
"reflect"
"strconv"
"strings"
"testing"
@@ -30,6 +32,7 @@ func executeOAAttachmentCommandCapturingOutput(t *testing.T, caller *scriptedToo
cmd := newOaCommand()
cmd.PersistentFlags().Bool("yes", false, "跳过确认")
cmd.PersistentFlags().Bool("dry-run", false, "仅预览不执行")
cmd.PersistentFlags().String("format", caller.Format(), "输出格式")
ctx, _ := output.WithResultStore(context.Background())
cmd.SetContext(ctx)
@@ -337,3 +340,846 @@ func TestCrossPlatformCoverageOAAttachmentRejectsInvalidPreviewFileIDs(t *testin
})
}
}
const oaUploadInitResponse = `{"result":{"uploadKey":"key-123","resourceUrls":["https://oss.example.test/upload"],"headers":{"x-oss-date":"20260820T000000Z","Authorization":"OSS signature"},"storageDriver":"oss"},"success":true}`
func oaUploadCommitResponse(size int64) string {
return `{"result":{"spaceId":27827223951,"fileName":"合同.pdf","fileSize":` +
strconv.FormatInt(size, 10) +
`,"class":"com.dingtalk.oapi.response","fileType":"pdf","fileId":"file-abc"},"success":true}`
}
// writeOAAttachmentTempFile 落地一个临时文件,返回其绝对路径与字节数。
func writeOAAttachmentTempFile(t *testing.T, name, content string) (string, int64) {
t.Helper()
path := filepath.Join(t.TempDir(), name)
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("write temp file: %v", err)
}
return path, int64(len(content))
}
// capturedPut 记录一次 httpPutFile 调用的入参,供断言 PUT 使用了解析出的
// resourceURL 与签名 headers。
type capturedPut struct {
calls int
url string
headers map[string]string
filePath string
fileSize int64
}
func mockOAAttachmentPut(t *testing.T) *capturedPut {
t.Helper()
put := &capturedPut{}
SetHTTPPutFile(func(_ context.Context, url string, headers map[string]string, filePath string, fileSize int64) error {
put.calls++
put.url = url
put.headers = headers
put.filePath = filePath
put.fileSize = fileSize
return nil
})
t.Cleanup(func() { SetHTTPPutFile(nil) })
return put
}
func TestCrossPlatformCoverageOAAttachmentUploadEndToEnd(t *testing.T) {
const content = "pdf-bytes-content"
filePath, fileSize := writeOAAttachmentTempFile(t, "source.pdf", content)
put := mockOAAttachmentPut(t)
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: oaUploadInitResponse},
{text: oaUploadCommitResponse(fileSize)},
}}
stdout, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "upload",
"--file", filePath,
"--file-name", "合同.pdf",
"--md5", "d41d8cd98f00b204e9800998ecf8427e",
)
if err != nil {
t.Fatalf("execute command: %v", err)
}
if caller.calls != 2 {
t.Fatalf("MCP calls = %d, want 2 (init+commit)", caller.calls)
}
if caller.toolLog[0] != "init_attachment_upload_info" || caller.serverLog[0] != "oa" {
t.Fatalf("first call = %s/%s, want oa/init_attachment_upload_info", caller.serverLog[0], caller.toolLog[0])
}
wantInit := map[string]any{
"fileName": "合同.pdf",
"fileSize": float64(fileSize),
"md5": "d41d8cd98f00b204e9800998ecf8427e",
}
if !reflect.DeepEqual(caller.argsLog[0], wantInit) {
t.Fatalf("init args = %#v, want %#v", caller.argsLog[0], wantInit)
}
if put.calls != 1 {
t.Fatalf("httpPutFile called %d times, want 1", put.calls)
}
if put.url != "https://oss.example.test/upload" {
t.Fatalf("PUT url = %q, want parsed resourceURL", put.url)
}
if put.headers["Authorization"] != "OSS signature" || put.headers["x-oss-date"] != "20260820T000000Z" {
t.Fatalf("PUT headers = %#v, want parsed signed headers", put.headers)
}
if put.filePath != filePath || put.fileSize != fileSize {
t.Fatalf("PUT file = %q size = %d, want %q/%d", put.filePath, put.fileSize, filePath, fileSize)
}
if caller.toolLog[1] != "commit_attachment_upload_info" {
t.Fatalf("second call = %s, want commit_attachment_upload_info", caller.toolLog[1])
}
wantCommit := map[string]any{
"fileName": "合同.pdf",
"uploadKey": "key-123",
"fileSize": float64(fileSize),
}
if !reflect.DeepEqual(caller.argsLog[1], wantCommit) {
t.Fatalf("commit args = %#v, want %#v", caller.argsLog[1], wantCommit)
}
var envelope map[string]any
if err := json.Unmarshal([]byte(stdout), &envelope); err != nil {
t.Fatalf("decode unified output: %v", err)
}
if envelope["ok"] != true || envelope["outcome"] != "success" {
t.Fatalf("unified envelope = %#v", envelope)
}
data, ok := envelope["data"].(map[string]any)
if !ok || data["fileId"] != "file-abc" {
t.Fatalf("unified data = %#v, want fileId file-abc", envelope["data"])
}
}
func TestCrossPlatformCoverageOAAttachmentUploadAutoMD5AndDefaultName(t *testing.T) {
const content = "auto-md5-content"
filePath, fileSize := writeOAAttachmentTempFile(t, "report.xlsx", content)
wantMD5, err := fileMD5Hex(filePath)
if err != nil {
t.Fatalf("compute expected md5: %v", err)
}
mockOAAttachmentPut(t)
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: oaUploadInitResponse},
{text: oaUploadCommitResponse(fileSize)},
}}
// 既不传 --file-name 也不传 --md5:文件名应回退为 basename,md5 应自动计算。
if _, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "upload",
"--file", filePath,
); err != nil {
t.Fatalf("execute command: %v", err)
}
if got := caller.argsLog[0]["fileName"]; got != "report.xlsx" {
t.Fatalf("default fileName = %#v, want basename report.xlsx", got)
}
if got := caller.argsLog[0]["md5"]; got != wantMD5 {
t.Fatalf("auto md5 = %#v, want computed %q", got, wantMD5)
}
if got := caller.argsLog[0]["fileSize"]; got != float64(fileSize) {
t.Fatalf("fileSize = %#v, want number %d", got, fileSize)
}
}
func TestCrossPlatformCoverageOAAttachmentUploadMalformedInitResponse(t *testing.T) {
tests := []struct {
name string
response string
wantErr string
}{
{
name: "missing result",
response: `{"success":true}`,
wantErr: "缺少 result",
},
{
name: "missing uploadKey",
response: `{"result":{"resourceUrls":["https://oss.example.test/upload"],"headers":{"Authorization":"sig","x-oss-date":"d"}},"success":true}`,
wantErr: "缺少 uploadKey",
},
{
name: "empty uploadKey",
response: `{"result":{"uploadKey":" ","resourceUrls":["https://oss.example.test/upload"],"headers":{"Authorization":"sig","x-oss-date":"d"}},"success":true}`,
wantErr: "缺少 uploadKey",
},
{
name: "empty resourceUrls",
response: `{"result":{"uploadKey":"key-1","resourceUrls":[],"headers":{"Authorization":"sig","x-oss-date":"d"}},"success":true}`,
wantErr: "缺少 resourceUrls",
},
{
name: "empty resourceUrls element",
response: `{"result":{"uploadKey":"key-1","resourceUrls":[""],"headers":{"Authorization":"sig","x-oss-date":"d"}},"success":true}`,
wantErr: "首个 resourceUrls 为空",
},
{
name: "missing headers",
response: `{"result":{"uploadKey":"key-1","resourceUrls":["https://oss.example.test/upload"]},"success":true}`,
wantErr: "缺少 headers",
},
{
name: "headers missing Authorization",
response: `{"result":{"uploadKey":"key-1","resourceUrls":["https://oss.example.test/upload"],"headers":{"x-oss-date":"20260820T000000Z"}},"success":true}`,
wantErr: "Authorization",
},
{
name: "headers missing x-oss-date",
response: `{"result":{"uploadKey":"key-1","resourceUrls":["https://oss.example.test/upload"],"headers":{"Authorization":"OSS sig"}},"success":true}`,
wantErr: "x-oss-date",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
filePath, _ := writeOAAttachmentTempFile(t, "test.pdf", "data")
put := mockOAAttachmentPut(t)
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: test.response},
// second step should never be reached
{text: `{"result":{},"success":true}`},
}}
_, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "upload",
"--file", filePath,
"--file-name", "test.pdf",
"--md5", "d41d8cd98f00b204e9800998ecf8427e",
)
if err == nil {
t.Fatalf("expected error containing %q, got nil", test.wantErr)
}
if !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("error = %v, want substring %q", err, test.wantErr)
}
if caller.calls != 1 {
t.Fatalf("MCP calls = %d, want 1 (init only, no commit)", caller.calls)
}
if put.calls != 0 {
t.Fatalf("httpPutFile called %d times, want 0 (should not PUT)", put.calls)
}
})
}
}
func TestCrossPlatformCoverageOAAttachmentUploadRequiredFlagValidation(t *testing.T) {
existing, _ := writeOAAttachmentTempFile(t, "exists.bin", "x")
tests := []struct {
name string
args []string
}{
{name: "missing file", args: []string{"approval", "attachment", "upload"}},
{name: "file does not exist", args: []string{"approval", "attachment", "upload", "--file", filepath.Join(existing, "missing.bin")}},
{name: "file is directory", args: []string{"approval", "attachment", "upload", "--file", filepath.Dir(existing)}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
mockOAAttachmentPut(t)
caller := &scriptedToolCaller{}
err := executeOACommand(t, caller, test.args...)
if err == nil {
t.Fatalf("%s unexpectedly succeeded", test.name)
}
if caller.calls != 0 {
t.Fatalf("invalid upload made %d MCP call(s)", caller.calls)
}
})
}
}
// TestCrossPlatformCoverageOAAttachmentUploadDryRunDoesNotCallRemoteOrPut 验证
// --dry-run 在任何远程调用(init/PUT/commit)之前 early return:零 MCP 调用、
// 零 PUT,且输出一个包含 3 个 planned step 的 plan 预览。
func TestCrossPlatformCoverageOAAttachmentUploadDryRunDoesNotCallRemoteOrPut(t *testing.T) {
const content = "dry-run-preview-content"
filePath, fileSize := writeOAAttachmentTempFile(t, "plan.pdf", content)
wantMD5, err := fileMD5Hex(filePath)
if err != nil {
t.Fatalf("compute expected md5: %v", err)
}
put := mockOAAttachmentPut(t)
// caller.dry 驱动 deps.Caller.DryRun()==true;同时传入 --dry-run 以镜像生产路径。
caller := &scriptedToolCaller{format: "json", dry: true}
stdout, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "upload",
"--file", filePath,
"--dry-run",
)
if err != nil {
t.Fatalf("execute command: %v", err)
}
if caller.calls != 0 {
t.Fatalf("dry-run made %d MCP call(s), want 0", caller.calls)
}
if put.calls != 0 {
t.Fatalf("dry-run made %d PUT call(s), want 0", put.calls)
}
var envelope map[string]any
if err := json.Unmarshal([]byte(stdout), &envelope); err != nil {
t.Fatalf("decode unified output: %v\noutput: %s", err, stdout)
}
data, ok := envelope["data"].(map[string]any)
if !ok {
t.Fatalf("unified data missing/invalid: %#v", envelope["data"])
}
if data["dry_run"] != true || data["executed"] != false || data["preview_kind"] != "plan" {
t.Fatalf("plan flags = %#v", data)
}
if data["operation"] != "attachment_upload" || data["source"] != "oa" {
t.Fatalf("plan operation/source = %#v", data)
}
if data["file_name"] != "plan.pdf" || data["file_size"] != float64(fileSize) || data["md5"] != wantMD5 {
t.Fatalf("plan file metadata = %#v (want file_name=plan.pdf size=%d md5=%s)", data, fileSize, wantMD5)
}
steps, ok := data["steps"].([]any)
if !ok || len(steps) != 3 {
t.Fatalf("plan steps = %#v, want 3 planned steps", data["steps"])
}
wantTools := []string{"oa/init_attachment_upload_info", "HTTP PUT", "oa/commit_attachment_upload_info"}
for i, raw := range steps {
step, ok := raw.(map[string]any)
if !ok {
t.Fatalf("step %d not an object: %#v", i, raw)
}
if step["tool"] != wantTools[i] {
t.Fatalf("step %d tool = %#v, want %q", i, step["tool"], wantTools[i])
}
if step["status"] != "planned" {
t.Fatalf("step %d status = %#v, want planned", i, step["status"])
}
}
// The commit step (index 2) must NOT contain uploadKey in args (remote-only field)
// and must declare the dependency via a "requires" entry.
commitStep := steps[2].(map[string]any)
commitArgs, _ := commitStep["args"].(map[string]any)
if _, hasUploadKey := commitArgs["uploadKey"]; hasUploadKey {
t.Fatalf("commit step args must not contain uploadKey, got: %#v", commitArgs)
}
requires, ok := commitStep["requires"].([]any)
if !ok || len(requires) == 0 {
t.Fatalf("commit step must have non-empty requires, got: %#v", commitStep["requires"])
}
found := false
for _, r := range requires {
if s, ok := r.(string); ok && (len(s) > 0 && strings.Contains(s, "uploadKey") && strings.Contains(s, "init")) {
found = true
break
}
}
if !found {
t.Fatalf("commit step requires should mention uploadKey and init, got: %#v", requires)
}
}
// TestCrossPlatformCoverageOAAttachmentUploadMD5Failure injects a failing computeFileMD5 to
// cover runOAAttachmentUpload's MD5 failure branch on all platforms (including Windows).
func TestCrossPlatformCoverageOAAttachmentUploadMD5Failure(t *testing.T) {
filePath, _ := writeOAAttachmentTempFile(t, "test.bin", "hello")
put := mockOAAttachmentPut(t)
testseam.Swap(t, &computeFileMD5, func(string) (string, error) {
return "", errors.New("permission denied")
})
caller := &scriptedToolCaller{format: "json"}
_, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "upload",
"--file", filePath,
)
if err == nil {
t.Fatal("expected error from MD5 failure, got nil")
}
if !strings.Contains(err.Error(), "MD5") {
t.Fatalf("error should mention MD5, got: %v", err)
}
if caller.calls != 0 {
t.Fatalf("expected 0 MCP calls when MD5 fails, got %d", caller.calls)
}
if put.calls != 0 {
t.Fatalf("expected 0 PUT calls when MD5 fails, got %d", put.calls)
}
}
// TestCrossPlatformCoverageOAAttachmentUploadHTTPPutError 注入一个返回错误的 PUT,
// 覆盖 runOAAttachmentUpload 中 httpPutFile 失败分支:init 后报错(1 次 MCP 调用)且不提交。
func TestCrossPlatformCoverageOAAttachmentUploadHTTPPutError(t *testing.T) {
filePath, _ := writeOAAttachmentTempFile(t, "put-fail.pdf", "data")
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error {
return errors.New("oss put failed")
})
t.Cleanup(func() { SetHTTPPutFile(nil) })
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: oaUploadInitResponse},
// commit 不应被达到
{text: `{"result":{},"success":true}`},
}}
_, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "upload",
"--file", filePath,
"--file-name", "put-fail.pdf",
"--md5", "d41d8cd98f00b204e9800998ecf8427e",
)
if err == nil || !strings.Contains(err.Error(), "oss put failed") {
t.Fatalf("error = %v, want oss put failed", err)
}
if caller.calls != 1 {
t.Fatalf("MCP calls = %d, want 1 (init only, no commit after PUT failure)", caller.calls)
}
}
// TestCrossPlatformCoverageOAAttachmentUploadMalformedCommitResponse 校验 commit 返回
// 缺少必需字段时命令报错(而不是包装为成功)。
func TestCrossPlatformCoverageOAAttachmentUploadMalformedCommitResponse(t *testing.T) {
tests := []struct {
name string
commitResponse string
wantErr string
}{
{
name: "result is null",
commitResponse: `{"success":true,"result":null}`,
wantErr: "不是有效的 JSON 对象",
},
{
name: "result is empty object",
commitResponse: `{"success":true,"result":{}}`,
wantErr: "spaceId",
},
{
name: "missing fileId",
commitResponse: `{"success":true,"result":{"spaceId":"123","fileName":"a.pdf","fileSize":100}}`,
wantErr: "fileId",
},
{
name: "empty fileId",
commitResponse: `{"success":true,"result":{"spaceId":"123","fileName":"a.pdf","fileSize":100,"fileId":""}}`,
wantErr: "fileId",
},
{
name: "missing spaceId",
commitResponse: `{"success":true,"result":{"fileName":"a.pdf","fileSize":100,"fileId":"file-1"}}`,
wantErr: "spaceId",
},
{
name: "fileSize is zero",
commitResponse: `{"success":true,"result":{"spaceId":"123","fileName":"a.pdf","fileSize":0,"fileId":"file-1"}}`,
wantErr: "fileSize",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
filePath, _ := writeOAAttachmentTempFile(t, "commit-validate.pdf", "data")
put := mockOAAttachmentPut(t)
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: oaUploadInitResponse},
{text: test.commitResponse},
}}
_, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "upload",
"--file", filePath,
"--file-name", "commit-validate.pdf",
"--md5", "d41d8cd98f00b204e9800998ecf8427e",
)
if err == nil {
t.Fatalf("expected error containing %q, got nil", test.wantErr)
}
if !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("error = %v, want substring %q", err, test.wantErr)
}
// init + commit = 2 MCP calls
if caller.calls != 2 {
t.Fatalf("MCP calls = %d, want 2 (init + commit)", caller.calls)
}
// PUT should have been called (init succeeded)
if put.calls != 1 {
t.Fatalf("httpPutFile called %d times, want 1", put.calls)
}
})
}
}
// TestCrossPlatformCoverageOAAttachmentValidateCommitResultDirect 直接调用
// validateOAAttachmentCommitResult 覆盖所有分支路径,包括通过 end-to-end 流不可达的
// json.Number 类型分支。
func TestCrossPlatformCoverageOAAttachmentValidateCommitResultDirect(t *testing.T) {
tests := []struct {
name string
input any
wantErr string // empty means expect nil error
}{
// result 不是 map
{name: "result is string", input: "hello", wantErr: "不是有效的 JSON 对象"},
{name: "result is array", input: []any{"x"}, wantErr: "不是有效的 JSON 对象"},
{name: "result is number", input: float64(42), wantErr: "不是有效的 JSON 对象"},
{name: "result is nil", input: nil, wantErr: "不是有效的 JSON 对象"},
// spaceId — 空字符串
{name: "spaceId empty string", input: map[string]any{
"spaceId": "", "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1",
}, wantErr: "spaceId"},
{name: "spaceId whitespace only", input: map[string]any{
"spaceId": " ", "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1",
}, wantErr: "spaceId"},
// spaceId — json.Number(通过 UseNumber 解码的数字)
{name: "spaceId json.Number", input: map[string]any{
"spaceId": json.Number("27827223951"), "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1",
}, wantErr: ""},
// spaceId — float64(正常路径)
{name: "spaceId float64", input: map[string]any{
"spaceId": float64(123), "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1",
}, wantErr: ""},
// spaceId — 非法类型
{name: "spaceId bool", input: map[string]any{
"spaceId": true, "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1",
}, wantErr: "spaceId"},
// fileName — 非 string 类型
{name: "fileName is number", input: map[string]any{
"spaceId": "123", "fileName": float64(99), "fileSize": float64(100), "fileId": "file-1",
}, wantErr: "fileName"},
{name: "fileName is nil", input: map[string]any{
"spaceId": "123", "fileName": nil, "fileSize": float64(100), "fileId": "file-1",
}, wantErr: "fileName"},
{name: "fileName empty", input: map[string]any{
"spaceId": "123", "fileName": "", "fileSize": float64(100), "fileId": "file-1",
}, wantErr: "fileName"},
// fileSize — json.Number 有效
{name: "fileSize json.Number valid", input: map[string]any{
"spaceId": "123", "fileName": "a.pdf", "fileSize": json.Number("200"), "fileId": "file-1",
}, wantErr: ""},
// fileSize — json.Number 无效(<= 0)
{name: "fileSize json.Number zero", input: map[string]any{
"spaceId": "123", "fileName": "a.pdf", "fileSize": json.Number("0"), "fileId": "file-1",
}, wantErr: "fileSize"},
{name: "fileSize json.Number negative", input: map[string]any{
"spaceId": "123", "fileName": "a.pdf", "fileSize": json.Number("-5"), "fileId": "file-1",
}, wantErr: "fileSize"},
{name: "fileSize json.Number invalid", input: map[string]any{
"spaceId": "123", "fileName": "a.pdf", "fileSize": json.Number("abc"), "fileId": "file-1",
}, wantErr: "fileSize"},
// fileSize — 非法类型(default 分支)
{name: "fileSize is string", input: map[string]any{
"spaceId": "123", "fileName": "a.pdf", "fileSize": "100", "fileId": "file-1",
}, wantErr: "fileSize"},
{name: "fileSize is nil", input: map[string]any{
"spaceId": "123", "fileName": "a.pdf", "fileSize": nil, "fileId": "file-1",
}, wantErr: "fileSize"},
// fileId — 非 string 类型
{name: "fileId is number", input: map[string]any{
"spaceId": "123", "fileName": "a.pdf", "fileSize": float64(100), "fileId": float64(99),
}, wantErr: "fileId"},
{name: "fileId whitespace", input: map[string]any{
"spaceId": "123", "fileName": "a.pdf", "fileSize": float64(100), "fileId": " ",
}, wantErr: "fileId"},
// 全部通过
{name: "all valid string types", input: map[string]any{
"spaceId": "123", "fileName": "report.pdf", "fileSize": float64(512), "fileId": "file-abc",
}, wantErr: ""},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := validateOAAttachmentCommitResult(test.input)
if test.wantErr == "" {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
return
}
if err == nil {
t.Fatalf("expected error containing %q, got nil", test.wantErr)
}
if !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("error = %v, want substring %q", err, test.wantErr)
}
})
}
}
// TestCrossPlatformCoverageOAAttachmentUploadEmptyFilePath 覆盖 runOAAttachmentUpload
// 中 --file 传了但值为空字符串的分支(cobra MarkFlagRequired 只拦截未传,不拦截空值)。
func TestCrossPlatformCoverageOAAttachmentUploadEmptyFilePath(t *testing.T) {
mockOAAttachmentPut(t)
caller := &scriptedToolCaller{format: "json"}
_, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "upload",
"--file", "",
)
if err == nil || !strings.Contains(err.Error(), "--file 不能为空") {
t.Fatalf("error = %v, want --file empty error", err)
}
if caller.calls != 0 {
t.Fatalf("MCP calls = %d, want 0", caller.calls)
}
}
// TestCrossPlatformCoverageOAAttachmentUploadInitCallError 覆盖 runOAAttachmentUpload
// 中 callMCPToolReturnTextOnServer(init 步)返回错误的分支。
func TestCrossPlatformCoverageOAAttachmentUploadInitCallError(t *testing.T) {
filePath, _ := writeOAAttachmentTempFile(t, "init-err.pdf", "data")
put := mockOAAttachmentPut(t)
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{err: errors.New("injected init failure")},
}}
_, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "upload",
"--file", filePath,
"--file-name", "init-err.pdf",
"--md5", "d41d8cd98f00b204e9800998ecf8427e",
)
if err == nil {
t.Fatal("expected error from init call failure, got nil")
}
if caller.calls != 1 {
t.Fatalf("MCP calls = %d, want 1 (init attempted)", caller.calls)
}
if put.calls != 0 {
t.Fatalf("PUT calls = %d, want 0", put.calls)
}
}
// TestCrossPlatformCoverageOAAttachmentUploadCommitNonObject 覆盖 runOAAttachmentUpload
// 中 commitData 不是 map[string]any 的分支(line 348)。
func TestCrossPlatformCoverageOAAttachmentUploadCommitNonObject(t *testing.T) {
filePath, _ := writeOAAttachmentTempFile(t, "commit-nonobj.pdf", "data")
mockOAAttachmentPut(t)
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: oaUploadInitResponse},
// commit 返回一个 JSON 字符串而非对象
{text: `"not an object"`},
}}
_, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "upload",
"--file", filePath,
"--file-name", "commit-nonobj.pdf",
"--md5", "d41d8cd98f00b204e9800998ecf8427e",
)
if err == nil || !strings.Contains(err.Error(), "不是 JSON 对象") {
t.Fatalf("error = %v, want commit non-object error", err)
}
if caller.calls != 2 {
t.Fatalf("MCP calls = %d, want 2 (init + commit)", caller.calls)
}
}
// TestCrossPlatformCoverageOAAttachmentUploadMalformedInitJSON 覆盖
// parseOAAttachmentUploadInfo 中 json.Unmarshal 失败分支(init 返回非法 JSON)。
func TestCrossPlatformCoverageOAAttachmentUploadMalformedInitJSON(t *testing.T) {
filePath, _ := writeOAAttachmentTempFile(t, "bad-init.pdf", "data")
put := mockOAAttachmentPut(t)
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: `{not valid json`},
}}
_, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "upload",
"--file", filePath,
"--file-name", "bad-init.pdf",
"--md5", "d41d8cd98f00b204e9800998ecf8427e",
)
if err == nil || !strings.Contains(err.Error(), "解析 init_attachment_upload_info 返回失败") {
t.Fatalf("error = %v, want parse failure", err)
}
if caller.calls != 1 {
t.Fatalf("MCP calls = %d, want 1 (init only)", caller.calls)
}
if put.calls != 0 {
t.Fatalf("PUT calls = %d, want 0", put.calls)
}
}
// TestCrossPlatformCoverageOAAttachmentValidateCommitResultNormalization 验证
// validateOAAttachmentCommitResult 对 spaceId/fileSize 的归一化行为。
func TestCrossPlatformCoverageOAAttachmentValidateCommitResultNormalization(t *testing.T) {
tests := []struct {
name string
input map[string]any
wantSpaceID any
wantSize any
wantErr string
}{
{
name: "string spaceId normalized to int64",
input: map[string]any{"spaceId": "27827223951", "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1"},
wantSpaceID: int64(27827223951),
wantSize: float64(100),
},
{
name: "non-numeric string spaceId returns error",
input: map[string]any{"spaceId": "abc", "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1"},
wantErr: "spaceId",
},
{
name: "float string spaceId returns error",
input: map[string]any{"spaceId": "12.5", "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1"},
wantErr: "spaceId",
},
{
name: "json.Number spaceId normalized to int64",
input: map[string]any{"spaceId": json.Number("27827223951"), "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1"},
wantSpaceID: int64(27827223951),
wantSize: float64(100),
},
{
name: "json.Number spaceId non-integer returns error",
input: map[string]any{"spaceId": json.Number("12.5"), "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1"},
wantErr: "spaceId",
},
{
name: "json.Number fileSize normalized to int64",
input: map[string]any{"spaceId": float64(123), "fileName": "a.pdf", "fileSize": json.Number("2048"), "fileId": "file-1"},
wantSpaceID: float64(123),
wantSize: int64(2048),
},
{
name: "float64 spaceId unchanged",
input: map[string]any{"spaceId": float64(27827223951), "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1"},
wantSpaceID: float64(27827223951),
wantSize: float64(100),
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
normalized, err := validateOAAttachmentCommitResult(test.input)
if test.wantErr != "" {
if err == nil {
t.Fatalf("expected error containing %q, got nil", test.wantErr)
}
if !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("error = %v, want substring %q", err, test.wantErr)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if normalized["spaceId"] != test.wantSpaceID {
t.Fatalf("spaceId = %v (%T), want %v (%T)", normalized["spaceId"], normalized["spaceId"], test.wantSpaceID, test.wantSpaceID)
}
if normalized["fileSize"] != test.wantSize {
t.Fatalf("fileSize = %v (%T), want %v (%T)", normalized["fileSize"], normalized["fileSize"], test.wantSize, test.wantSize)
}
})
}
}
// TestCrossPlatformCoverageOAAttachmentUploadOutputContract 验证端到端上传命令
// 输出 JSON 中 spaceId 始终为 number(即使 commit 返回字符串 spaceId)。
func TestCrossPlatformCoverageOAAttachmentUploadOutputContract(t *testing.T) {
tests := []struct {
name string
commitResponse string
wantSpaceID float64 // JSON decode 后 number → float64
}{
{
name: "spaceId as number",
commitResponse: `{"result":{"spaceId":27827223951,"fileName":"合同.pdf","fileSize":17,"fileType":"pdf","fileId":"file-abc"},"success":true}`,
wantSpaceID: float64(27827223951),
},
{
name: "spaceId as string",
commitResponse: `{"result":{"spaceId":"27827223951","fileName":"合同.pdf","fileSize":17,"fileType":"pdf","fileId":"file-abc"},"success":true}`,
wantSpaceID: float64(27827223951),
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
const content = "output-contract-data"
filePath, _ := writeOAAttachmentTempFile(t, "contract.pdf", content)
mockOAAttachmentPut(t)
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: oaUploadInitResponse},
{text: test.commitResponse},
}}
stdout, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "upload",
"--file", filePath,
"--file-name", "合同.pdf",
"--md5", "d41d8cd98f00b204e9800998ecf8427e",
)
if err != nil {
t.Fatalf("execute command: %v", err)
}
var envelope map[string]any
if err := json.Unmarshal([]byte(stdout), &envelope); err != nil {
t.Fatalf("decode unified output: %v", err)
}
if envelope["ok"] != true || envelope["outcome"] != "success" {
t.Fatalf("unified envelope = %#v", envelope)
}
data, ok := envelope["data"].(map[string]any)
if !ok {
t.Fatalf("unified data missing: %#v", envelope)
}
// spaceId must be a number in the JSON output
spaceID, ok := data["spaceId"].(float64)
if !ok {
t.Fatalf("spaceId is not a number: %v (%T)", data["spaceId"], data["spaceId"])
}
if spaceID != test.wantSpaceID {
t.Fatalf("spaceId = %v, want %v", spaceID, test.wantSpaceID)
}
// fileSize must also be a number
if _, ok := data["fileSize"].(float64); !ok {
t.Fatalf("fileSize is not a number: %v (%T)", data["fileSize"], data["fileSize"])
}
})
}
}
// TestCrossPlatformCoverageOAAttachmentUploadCommitError 脚本化 init 返回合法、
// commit 返回错误,覆盖 callOAAttachmentResultCtx(commit 步)失败分支:
// PUT 发生一次,init+commit 均尝试(共 2 次 MCP 调用),命令报错。
func TestCrossPlatformCoverageOAAttachmentUploadCommitError(t *testing.T) {
filePath, _ := writeOAAttachmentTempFile(t, "commit-fail.pdf", "data")
put := mockOAAttachmentPut(t)
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: oaUploadInitResponse},
{err: errors.New("commit upload failed")},
}}
_, err := executeOAAttachmentCommandCapturingOutput(t, caller,
"approval", "attachment", "upload",
"--file", filePath,
"--file-name", "commit-fail.pdf",
"--md5", "d41d8cd98f00b204e9800998ecf8427e",
)
if err == nil || !strings.Contains(err.Error(), "commit upload failed") {
t.Fatalf("error = %v, want commit upload failed", err)
}
if put.calls != 1 {
t.Fatalf("PUT calls = %d, want 1", put.calls)
}
if caller.calls != 2 {
t.Fatalf("MCP calls = %d, want 2 (init + commit attempted)", caller.calls)
}
if caller.toolLog[0] != "init_attachment_upload_info" || caller.toolLog[1] != "commit_attachment_upload_info" {
t.Fatalf("tool sequence = %v, want init then commit", caller.toolLog)
}
}
@@ -27,6 +27,11 @@ type scriptedToolCaller struct {
server string
tool string
args map[string]any
// Per-call logs so multi-step flows (e.g. OA attachment upload's init+commit)
// can assert each invocation instead of only the last one captured above.
serverLog []string
toolLog []string
argsLog []map[string]any
}
func (c *scriptedToolCaller) CallTool(_ context.Context, serverID, toolName string, args map[string]any) (*edition.ToolResult, error) {
@@ -34,6 +39,9 @@ func (c *scriptedToolCaller) CallTool(_ context.Context, serverID, toolName stri
c.server = serverID
c.tool = toolName
c.args = args
c.serverLog = append(c.serverLog, serverID)
c.toolLog = append(c.toolLog, toolName)
c.argsLog = append(c.argsLog, args)
if len(c.steps) == 0 {
return &edition.ToolResult{}, nil
}
+6 -1
View File
@@ -26,10 +26,11 @@ func newSheetCommand() *cobra.Command {
contract.RegisterProductDecl(contract.ProductDecl{
ID: "sheet",
Selection: contract.ProductSelectionDecl{
AgentSummary: "导入本地 Excel,或创建、读取、编辑和导出钉钉在线电子表格(axls),并管理工作表、区域、筛选、图表、图片与格式。",
AgentSummary: "导入本地 Excel,或创建、读取、编辑、导出和审计钉钉在线电子表格(axls),并管理工作表、区域、历史 revision、筛选、图表、图片与格式。",
UseWhen: []string{
"用户要处理钉钉在线电子表格中的工作表、单元格、范围、筛选、图表、图片或格式时",
"用户要把本地 xlsx/xls 转换为新的钉钉在线电子表格时",
"用户要查询工作簿当前 revision 或复核两个 revision 之间的 changeset 时",
},
AvoidWhen: []string{
"目标是 AI 表格 Base 的结构化记录或钉钉文档正文时不要使用 sheet",
@@ -110,6 +111,8 @@ func newSheetCommand() *cobra.Command {
dws sheet chart create 创建浮动图表
dws sheet chart update 更新浮动图表
dws sheet chart delete 删除浮动图表
dws sheet revision-get 获取工作簿当前 revision
dws sheet changeset-get 获取工作簿 revision 区间内的 changeset
dws sheet export 导出表格为 xlsx(异步任务一站式:提交→轮询→可选下载)
dws sheet export-csv 导出单个工作表为纯 CSV(同步,可落盘)
dws sheet import 导入 xlsx/xls 为在线电子表格
@@ -169,6 +172,7 @@ func newSheetCommand() *cobra.Command {
templateCmd := newSheetTemplateCmd()
tableCmds := newTableCmds()
pivotTableCmd := newPivotTableCmd()
revisionCmds := newSheetRevisionCmds()
batchUpdateCmd := newBatchUpdateCmd()
DeclareLeafMetadata(batchUpdateCmd, LeafSpec{
@@ -244,6 +248,7 @@ func newSheetCommand() *cobra.Command {
standaloneCmds = append(standaloneCmds, mediaCmds...)
standaloneCmds = append(standaloneCmds, floatImageCmds...)
standaloneCmds = append(standaloneCmds, tableCmds...)
standaloneCmds = append(standaloneCmds, revisionCmds...)
standaloneCmds = append(standaloneCmds, exportCmd, exportCsvCmd, importCmd, batchUpdateCmd, createWithDataCmd)
// Register cross-product aliases
+783
View File
@@ -0,0 +1,783 @@
// 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 (
"encoding/json"
"fmt"
"io"
"reflect"
"sort"
"strconv"
"strings"
"sync"
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
)
const (
sheetRevisionGetRemoteTool = "get_sheet_revision"
sheetChangesetGetRemoteTool = "get_sheet_changeset"
sheetChangesetMaxSpan = int64(20)
sheetChangesetJSONMaxBytes = 2 * 1024 * 1024
)
var sheetRevisionResult = &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: json.RawMessage(`{
"type":"object",
"description":"工作簿当前持久化 Delta revision",
"properties":{
"success":{"type":"boolean","description":"服务端业务调用是否成功"},
"logId":{"type":"string","description":"服务端请求追踪 ID,可用于问题排查和反馈"},
"revision":{"type":"number","description":"当前工作簿 revision;空工作簿为 0"}
},
"required":["success","logId","revision"],
"additionalProperties":true
}`),
}
var sheetChangesetResult = &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: json.RawMessage(`{
"type":"object",
"description":"工作簿 revision 区间内连续、按 revision 升序排列的 V2 前向语义 changeset",
"properties":{
"success":{"type":"boolean","description":"服务端业务调用是否成功"},
"logId":{"type":"string","description":"服务端请求追踪 ID,可用于问题排查和反馈"},
"schemaVersion":{"type":"number","description":"changeset 业务响应的语义版本;当前固定为 2","enum":[2]},
"changeSemantics":{"type":"string","description":"变更详情只描述当时提交的前向效果,不提供统一 old/current 值;当前固定为 FORWARD_ONLY","enum":["FORWARD_ONLY"]},
"latestRevision":{"type":"number","description":"请求开始时观测并固定的工作簿最新 revision"},
"startRevision":{"type":"number","description":"查询基线 revision;结果不包含该 revision"},
"endRevision":{"type":"number","description":"本次实际查询的结束 revision;结果包含该 revision"},
"summary":{
"type":"object",
"description":"本次区间内语义 change 的完整性和影响范围汇总",
"properties":{
"changeCount":{"type":"number","description":"全部 changesets 中 change 对象的总数;STATE_RESET 不计作普通 change"},
"completeChangeCount":{"type":"number","description":"detailsStatus 为 COMPLETE 的 change 数量"},
"partialChangeCount":{"type":"number","description":"detailsStatus 为 PARTIAL 的 change 数量"},
"unsupportedChangeCount":{"type":"number","description":"type 为 UNSUPPORTED_CHANGE 的 change 数量"},
"containsStateReset":{"type":"boolean","description":"区间内是否包含 STATE_RESET 事件"},
"containsIncompleteChanges":{"type":"boolean","description":"区间内是否包含 PARTIAL、UNAVAILABLE 或 UNSUPPORTED_CHANGE"},
"affectedSheets":{
"type":"array",
"description":"由 AFFECTED 或 DESTINATION target 去重排序得到的工作表与 A1 范围摘要;SOURCE 不计入",
"items":{
"type":"object",
"properties":{
"sheetId":{"type":"string","description":"受影响工作表的稳定 ID"},
"sheetName":{"type":"string","description":"可可靠解析时的工作表显示名称;缺失时不得由 sheetId 猜测"},
"ranges":{"type":"array","description":"去重排序后的 1-based A1 范围;工作表级变更为空数组","items":{"type":"string","description":"1-based A1 范围;整行或整列可写为 1:3 或 A:C"}}
},
"required":["sheetId","ranges"],
"additionalProperties":true
}
}
},
"required":["changeCount","completeChangeCount","partialChangeCount","unsupportedChangeCount","containsStateReset","containsIncompleteChanges","affectedSheets"],
"additionalProperties":true
},
"changesets":{
"type":"array",
"description":"区间 (startRevision, endRevision] 内按 revision 升序排列的完整事件",
"items":{
"type":"object",
"properties":{
"revision":{"type":"number","description":"该 changeset 对应的 Delta revision"},
"createTime":{"type":"string","description":"该 revision 的创建时间"},
"isSelfEdit":{"type":"boolean","description":"该 Delta 是否由当前请求用户提交;false 不能用于识别具体编辑者"},
"eventType":{"type":"string","description":"事件类型;EDIT 为普通前向编辑,UNDO 为撤销提交产生的前向效果,STATE_RESET 为全量状态替换点","enum":["EDIT","UNDO","STATE_RESET"]},
"detailsStatus":{"type":"string","description":"该事件内所有 change 的最差详情完整度;COMPLETE、PARTIAL 或 UNAVAILABLE","enum":["COMPLETE","PARTIAL","UNAVAILABLE"]},
"reset":{
"type":"object",
"description":"STATE_RESET 的状态替换信息;EDIT 和 UNDO 不返回该对象",
"properties":{
"type":{"type":"string","description":"状态替换原因类型","enum":["ROLLBACK","OVERWRITE","UPGRADE","TEMPLATE","PRETTIFY","UNKNOWN_RESET"]},
"targetRevision":{"type":"number","description":"targetStatus 为 KNOWN 时的目标 revision;0 是合法空基线"},
"targetStatus":{"type":"string","description":"目标 revision 状态;KNOWN 表示已返回,NOT_APPLICABLE 表示该 reset 没有目标,UNAVAILABLE 表示应有目标但无法确认","enum":["KNOWN","NOT_APPLICABLE","UNAVAILABLE"]}
},
"required":["type","targetStatus"],
"additionalProperties":true
},
"changes":{
"type":"array",
"description":"该 revision 内按提交顺序排列的前向语义 change;STATE_RESET 固定为空数组",
"items":{
"type":"object",
"properties":{
"type":{"type":"string","description":"Agent 可解释的前向变更类型","enum":["ROWS_INSERTED","ROWS_DELETED","ROWS_UPDATED","COLUMNS_INSERTED","COLUMNS_DELETED","COLUMNS_UPDATED","SHEET_CREATED","SHEET_DELETED","SHEET_UPDATED","CUSTOM_TAB_ADDED","CUSTOM_TAB_DELETED","CUSTOM_TAB_UPDATED","CELLS_INSERTED","CELLS_DELETED","RANGE_PASTED","RANGE_AUTOFILLED","RANGE_CLEARED","RANGE_CONTENT_SET","RANGE_BORDER_SET","RANGE_STYLE_SET","RANGE_TAG_SET","RANGE_SORTED","CELLS_CONTENT_SET","CELLS_STYLE_SET","CELLS_TAG_SET","DIMENSION_GROUP_ADDED","DIMENSION_GROUP_REMOVED","DIMENSION_GROUP_UPDATED","DATA_VALIDATION_SET","DATA_VALIDATION_CLEARED","CELLS_MERGED","CELLS_UNMERGED","NAMED_RANGE_SET","NAMED_RANGE_CLEARED","FEATURE_ADDED","FEATURE_DELETED","FEATURE_UPDATED","WORKBOOK_SETTING_UPDATED","EXTERNAL_REFERENCES_REPLACED","UNSUPPORTED_CHANGE"]},
"targets":{
"type":"array",
"description":"该 change 涉及的工作簿、工作表或范围;源和目的范围用 role 区分",
"items":{
"type":"object",
"properties":{
"scope":{"type":"string","description":"定位层级","enum":["WORKBOOK","SHEET","RANGE"]},
"sheetId":{"type":"string","description":"SHEET 或 RANGE target 的稳定工作表 ID"},
"sheetName":{"type":"string","description":"可可靠解析时的工作表显示名称"},
"sheetNameSource":{"type":"string","description":"工作表名称来源;AT_CHANGE 为变更时名称,CURRENT_STATE 为当前状态映射,UNKNOWN 为无法确认","enum":["AT_CHANGE","CURRENT_STATE","UNKNOWN"]},
"a1Range":{"type":"string","description":"RANGE target 的 1-based A1 范围"},
"role":{"type":"string","description":"target 在变更中的角色;省略时等价于 AFFECTED","enum":["AFFECTED","SOURCE","DESTINATION"]}
},
"required":["scope"],
"additionalProperties":true
}
},
"details":{
"type":"object",
"description":"按 change.type 归一后的前向详情;字段集合见 Sheet Skill,未返回的旧值或当前值不得推断",
"properties":{
"cell":{
"type":"object",
"description":"前向单元格内容;整体清除时为 {cleared:true}",
"properties":{
"cleared":{"type":"boolean","description":"true 表示整个单元格内容对象被清除","enum":[true]},
"value":{"type":"object","description":"具名类型化前向值","properties":{"kind":{"type":"string","description":"值类型","enum":["STRING","NUMBER","BOOLEAN","NULL","MULTI"]},"stringValue":{"type":"string","description":"kind=STRING 时的字符串值"},"numberValue":{"type":"number","description":"kind=NUMBER 时的数值"},"booleanValue":{"type":"boolean","description":"kind=BOOLEAN 时的布尔值"},"values":{"type":"array","description":"kind=MULTI 时的字符串值列表","items":{"type":"string","description":"多选值"}}},"required":["kind"],"additionalProperties":true},
"formula":{"type":"string","description":"本次修改写入的可读公式文本"},
"formulaCleared":{"type":"boolean","description":"true 表示本次修改清除了公式","enum":[true]},
"cellType":{"type":"object","description":"单元格类型;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个单元格类型对象被清除","enum":[true]},"type":{"type":"string","description":"单元格类型","enum":["general","checkbox","select"]},"options":{"type":"array","description":"select 类型的选项","items":{"type":"object","properties":{"value":{"type":"string","description":"选项值"},"color":{"type":"string","description":"可用时的选项颜色"}},"required":["value"],"additionalProperties":true}}},"additionalProperties":true},
"link":{"type":"object","description":"工作簿内范围链接;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个链接对象被清除","enum":[true]},"type":{"type":"string","description":"正常链接类型;当前为 range","enum":["range"]},"sheetId":{"type":"string","description":"正常链接的目标工作表 ID"},"a1Range":{"type":"string","description":"正常链接目标的 1-based A1 范围"},"absolute":{"type":"boolean","description":"正常链接是否使用绝对引用"}},"additionalProperties":true}
},
"additionalProperties":true
},
"cellType":{"type":"object","description":"相对内容变更的单元格类型;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个单元格类型对象被清除","enum":[true]},"type":{"type":"string","description":"单元格类型","enum":["general","checkbox","select"]},"options":{"type":"array","description":"select 类型的选项","items":{"type":"object","properties":{"value":{"type":"string","description":"选项值"},"color":{"type":"string","description":"可用时的选项颜色"}},"required":["value"],"additionalProperties":true}}},"additionalProperties":true},
"tag":{"type":"object","description":"tag 清除标记;非清除的动态 tag 内容不会返回","properties":{"cleared":{"type":"boolean","description":"true 表示 tag 被清除","enum":[true]}},"required":["cleared"],"additionalProperties":false},
"border":{"type":"object","description":"前向边框;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个边框对象被清除","enum":[true]},"color":{"description":"边框颜色;已知属性为 null 表示清除该属性"},"style":{"description":"边框线型;已知属性为 null 表示清除该属性"}},"additionalProperties":true},
"style":{"type":"object","description":"前向样式白名单;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个样式对象被清除","enum":[true]}},"additionalProperties":true},
"properties":{"type":"object","description":"行列、工作表或自定义 Tab 的前向属性;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个属性对象被清除","enum":[true]}},"additionalProperties":true},
"changes":{"type":"object","description":"工作簿设置的前向字段;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个设置对象被清除","enum":[true]},"mode":{"type":"string","description":"CALCULATION 的重算模式;default 表示恢复默认模式","enum":["auto","autoNoTable","manual","default"]},"iterate":{"type":["boolean","null"],"description":"CALCULATION 是否启用迭代计算;null 表示清除该设置"},"iterateCount":{"type":["number","null"],"description":"CALCULATION 的最大迭代次数;null 表示清除该设置"},"iterateDelta":{"type":["number","null"],"description":"CALCULATION 的迭代收敛阈值;null 表示清除该设置"},"enableDynamicArray":{"type":["boolean","null"],"description":"CALCULATION 是否启用动态数组;null 表示清除该设置"},"date1904":{"type":["boolean","null"],"description":"CALCULATION 是否使用 1904 日期系统;null 表示清除该设置"},"image":{"type":"object","description":"BACKGROUND 图片设置;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示背景图片设置被清除","enum":[true]},"opacity":{"type":["number","null"],"description":"背景图片不透明度;null 表示清除该属性"}},"additionalProperties":true}},"additionalProperties":true},
"fillMode":{"type":"string","description":"RANGE_AUTOFILLED 的填充模式","enum":["copy","series","trend","predict","none"]},
"copyStyle":{"type":"boolean","description":"RANGE_AUTOFILLED 是否复制样式;当前协议的 COMPLETE change 必有"},
"styleMode":{"type":"string","description":"CELLS_STYLE_SET 的样式作用模式;coverStyle 未传 mode 时服务端显式返回 cell","enum":["sheet","row","col","cell"]},
"step":{"type":"object","description":"粘贴或自动填充模式块大小","properties":{"rows":{"type":"number","description":"模式块行数"},"columns":{"type":"number","description":"模式块列数"}},"required":["rows","columns"],"additionalProperties":true},
"pasteMode":{"type":"string","description":"RANGE_PASTED 的粘贴作用模式","enum":["cell","row","col","sheet"]},
"isCut":{"type":"boolean","description":"RANGE_PASTED 是否来自剪切"},
"iterateMode":{"type":"string","description":"RANGE_PASTED 如何重复应用内容模式","enum":["step","flex"]},
"includedParts":{"type":"array","description":"RANGE_PASTED 实际包含非空数据的变更类别;空切片不计入","items":{"type":"string","description":"实际包含的变更类别","enum":["CONTENT","STYLE","MERGES","CELL_TYPES","CONDITIONAL_FORMATTING","TABLES","PIVOT_TABLES","COMMENTS","REMINDERS","MENTIONS","DATA_VALIDATION","FILTERS","LOCKS","FOLLOWERS","PROTECTION_RANGES","DIMENSION_METADATA","REACTIONS","RANGE_TAGS"]}},
"contentPattern":{"type":"object","description":"粘贴内容模式的安全化前向表示","properties":{"rows":{"type":"number","description":"模式块行数"},"columns":{"type":"number","description":"模式块列数"},"cells":{"type":"array","description":"相对模式块的单元格内容","items":{"type":"object","properties":{"rowOffset":{"type":"number","description":"相对模式块左上角的 0-based 行偏移"},"columnOffset":{"type":"number","description":"相对模式块左上角的 0-based 列偏移"},"cleared":{"type":"boolean","description":"true 表示整个模式单元格被清除","enum":[true]},"value":{"type":"object","description":"具名类型化前向值","properties":{"kind":{"type":"string","description":"值类型","enum":["STRING","NUMBER","BOOLEAN","NULL","MULTI"]},"stringValue":{"type":"string","description":"kind=STRING 时的字符串值"},"numberValue":{"type":"number","description":"kind=NUMBER 时的数值"},"booleanValue":{"type":"boolean","description":"kind=BOOLEAN 时的布尔值"},"values":{"type":"array","description":"kind=MULTI 时的字符串值列表","items":{"type":"string","description":"多选值"}}},"required":["kind"],"additionalProperties":true},"formula":{"type":"string","description":"本次修改写入的可读公式文本"},"formulaCleared":{"type":"boolean","description":"true 表示本次修改清除了公式","enum":[true]},"cellType":{"type":"object","description":"前向单元格类型或 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个单元格类型对象被清除","enum":[true]},"type":{"type":"string","description":"单元格类型","enum":["general","checkbox","select"]}},"additionalProperties":true},"link":{"type":"object","description":"工作簿内范围链接或 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个链接对象被清除","enum":[true]},"type":{"type":"string","description":"正常链接类型;当前为 range","enum":["range"]},"sheetId":{"type":"string","description":"正常链接的目标工作表 ID"},"a1Range":{"type":"string","description":"正常链接目标的 1-based A1 范围"},"absolute":{"type":"boolean","description":"正常链接是否使用绝对引用"}},"additionalProperties":true}},"required":["rowOffset","columnOffset"],"additionalProperties":true}}},"required":["cells"],"additionalProperties":true},
"clearParts":{"type":"array","description":"RANGE_CLEARED 实际清除的内容类别","items":{"type":"string","description":"被清除的内容类别","enum":["VALUES","FORMULAS","MERGES","STYLES","CELL_TYPES","CONDITIONAL_FORMATTING","DATA_VALIDATION","DATA_VALIDATION_LIST","COLUMN_TYPES","LINKS","TABLES","COMMENTS","REMINDERS","REACTIONS"]}},
"preservedCellTypes":{"type":"array","description":"清除 CELL_TYPES 时明确保留的类型;空数组表示不保留 select 或 checkbox","items":{"type":"string","description":"保留的单元格类型","enum":["SELECT","CHECKBOX"]}},
"relativeChanges":{"type":"array","description":"相对 target 定位的前向行列属性、内容、样式或 tag 变更;sheet 级项可没有坐标","items":{"type":"object","properties":{"offset":{"type":"number","description":"ROWS_UPDATED 或 COLUMNS_UPDATED 中相对行列区间起点的 0-based 偏移"},"properties":{"type":"object","description":"offset 对应的前向行列属性或 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个行列属性对象被清除","enum":[true]},"size":{"type":["number","null"],"description":"行高或列宽;null 表示清除该属性"},"customSize":{"type":["boolean","null"],"description":"是否使用自定义尺寸;null 表示清除该属性"},"hidden":{"type":["boolean","null"],"description":"是否隐藏;null 表示清除该属性"},"sticky":{"type":["boolean","null"],"description":"是否冻结;null 表示清除该属性"},"cellType":{"type":"object","description":"行列默认单元格类型或 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个单元格类型对象被清除","enum":[true]},"type":{"type":"string","description":"单元格类型","enum":["general","checkbox","select"]},"options":{"type":"array","description":"select 类型的选项","items":{"type":"object","properties":{"value":{"type":"string","description":"选项值"},"color":{"type":"string","description":"可用时的选项颜色"}},"required":["value"],"additionalProperties":true}}},"additionalProperties":true}},"additionalProperties":true},"rowOffset":{"type":"number","description":"0-based 行偏移;行级或单元格级项使用"},"columnOffset":{"type":"number","description":"0-based 列偏移;列级或单元格级项使用"},"cell":{"type":"object","description":"前向单元格内容或 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个单元格内容对象被清除","enum":[true]},"value":{"type":"object","description":"具名类型化前向值","properties":{"kind":{"type":"string","description":"值类型","enum":["STRING","NUMBER","BOOLEAN","NULL","MULTI"]},"stringValue":{"type":"string","description":"kind=STRING 时的字符串值"},"numberValue":{"type":"number","description":"kind=NUMBER 时的数值"},"booleanValue":{"type":"boolean","description":"kind=BOOLEAN 时的布尔值"},"values":{"type":"array","description":"kind=MULTI 时的字符串值列表","items":{"type":"string","description":"多选值"}}},"required":["kind"],"additionalProperties":true},"formula":{"type":"string","description":"本次修改写入的可读公式文本"},"formulaCleared":{"type":"boolean","description":"true 表示本次修改清除了公式","enum":[true]},"cellType":{"type":"object","description":"前向单元格类型或 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个单元格类型对象被清除","enum":[true]},"type":{"type":"string","description":"单元格类型","enum":["general","checkbox","select"]}},"additionalProperties":true},"link":{"type":"object","description":"工作簿内范围链接或 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个链接对象被清除","enum":[true]},"type":{"type":"string","description":"正常链接类型;当前为 range","enum":["range"]},"sheetId":{"type":"string","description":"正常链接的目标工作表 ID"},"a1Range":{"type":"string","description":"正常链接目标的 1-based A1 范围"},"absolute":{"type":"boolean","description":"正常链接是否使用绝对引用"}},"additionalProperties":true}},"additionalProperties":true},"cellType":{"type":"object","description":"前向单元格类型或 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个单元格类型对象被清除","enum":[true]}},"additionalProperties":true},"styleId":{"type":"string","description":"引用同一 change 的 styles[] 条目的 ID"},"styleCleared":{"type":"boolean","description":"true 表示该相对位置的样式被清除","enum":[true]},"tag":{"type":"object","description":"tag 清除标记;非清除的动态 tag 内容不会返回","properties":{"cleared":{"type":"boolean","description":"true 表示 tag 被清除","enum":[true]}},"required":["cleared"],"additionalProperties":false}},"additionalProperties":true}},
"styles":{"type":"array","description":"同一 change 内可由 relativeChanges.styleId 引用的具名样式条目","items":{"type":"object","properties":{"styleId":{"type":"string","description":"样式引用 ID"},"style":{"type":"object","description":"样式白名单对象;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个样式对象被清除","enum":[true]}},"additionalProperties":true}},"required":["styleId","style"],"additionalProperties":true}},
"dataValidation":{
"type":"object",
"description":"DATA_VALIDATION_SET 的归一化验证规则;敏感或无法安全解释的内部字段不会返回",
"properties":{
"type":{"type":"string","description":"数据验证规则类型"},
"templateId":{"type":"number","description":"列表验证使用的模板 ID;存在时不代表已返回内联选项或来源区域"},
"sourceType":{"type":"string","description":"下拉选项来源;inline 为内联选项,sourceRange 为区域引用","enum":["inline","sourceRange"]},
"options":{"type":"array","description":"安全化后的下拉选项","items":{"type":"object","properties":{"value":{"type":"string","description":"选项值"},"color":{"type":"string","description":"可用时的选项颜色"}},"required":["value"],"additionalProperties":true}},
"sourceRange":{"type":"object","description":"解析成功时的下拉来源区域","properties":{"sheetId":{"type":"string","description":"来源工作表 ID"},"sheetName":{"type":"string","description":"可用时的来源工作表名称"},"a1Notation":{"type":"string","description":"来源区域的 1-based A1 表示"}},"additionalProperties":true},
"sourceRangeStatus":{"type":"string","description":"来源区域解析状态","enum":["RESOLVED","UNRESOLVED","INVALID"]},
"sourceRangeExpression":{"type":"string","description":"安全来源区域表达式;解析为 RESOLVED 时也可能保留原表达式"},
"enableMultiSelect":{"type":"boolean","description":"下拉是否允许多选"},
"criteria":{"type":"object","description":"非下拉规则可安全公开的具名条件参数","properties":{"operator":{"type":"string","description":"条件运算符"},"value1":{"type":"object","description":"第一个具名类型化条件值","properties":{"kind":{"type":"string","description":"值类型","enum":["STRING","NUMBER","BOOLEAN","NULL","FORMULA"]},"stringValue":{"type":"string","description":"kind=STRING 时的字符串值"},"numberValue":{"type":"number","description":"kind=NUMBER 时的数值"},"booleanValue":{"type":"boolean","description":"kind=BOOLEAN 时的布尔值"},"formula":{"type":"string","description":"kind=FORMULA 时的公式文本"}},"required":["kind"],"additionalProperties":true},"value2":{"type":"object","description":"第二个具名类型化条件值","properties":{"kind":{"type":"string","description":"值类型","enum":["STRING","NUMBER","BOOLEAN","NULL","FORMULA"]},"stringValue":{"type":"string","description":"kind=STRING 时的字符串值"},"numberValue":{"type":"number","description":"kind=NUMBER 时的数值"},"booleanValue":{"type":"boolean","description":"kind=BOOLEAN 时的布尔值"},"formula":{"type":"string","description":"kind=FORMULA 时的公式文本"}},"required":["kind"],"additionalProperties":true},"formula":{"type":"string","description":"条件公式"}},"additionalProperties":true},
"settings":{"type":"object","description":"可安全公开的数据验证通用行为设置;已知属性为 null 表示清除该属性","properties":{"allowBlank":{"type":["boolean","null"],"description":"是否允许空值;null 表示清除该设置"},"errorStyle":{"type":["string","null"],"description":"验证失败时的错误样式;null 表示清除该设置"},"showInputMessage":{"type":["boolean","null"],"description":"是否显示输入提示;null 表示清除该设置"},"showErrorMessage":{"type":["boolean","null"],"description":"是否显示错误提示;null 表示清除该设置"},"prompt":{"type":["string","null"],"description":"输入提示文本;null 表示清除该设置"},"error":{"type":["string","null"],"description":"验证失败提示文本;null 表示清除该设置"},"requiredInRow":{"type":["boolean","null"],"description":"是否要求行内必填;null 表示清除该设置"},"showDropDown":{"type":["boolean","null"],"description":"是否显示下拉控件;null 表示清除该设置"},"columnType":{"type":["boolean","null"],"description":"是否启用列类型行为;null 表示清除该设置"},"promptTitle":{"type":["string","null"],"description":"输入提示标题;null 表示清除该设置"},"errorTitle":{"type":["string","null"],"description":"验证失败提示标题;null 表示清除该设置"},"imeMode":{"type":["string","null"],"description":"输入法模式;null 表示清除该设置"}},"additionalProperties":true}
},
"required":["type"],
"additionalProperties":true
}
},
"additionalProperties":true
},
"detailsStatus":{"type":"string","description":"details 的完整度;COMPLETE 仍只代表前向提交详情,不代表 old/current 状态完整","enum":["COMPLETE","PARTIAL","UNAVAILABLE"]},
"omissions":{
"type":"array",
"description":"PARTIAL 或 UNAVAILABLE 时的稳定省略原因;COMPLETE 时固定为空数组",
"items":{"type":"object","properties":{"code":{"type":"string","description":"稳定的省略原因码","enum":["MISSING_REQUIRED_FIELD","PLUGIN_OPERATION","UNSUPPORTED_WRAPPER","UNSUPPORTED_PROTOCOL_VERSION","UNKNOWN_ACTION","UNKNOWN_VARIANT","DETAILS_NOT_FULLY_INTERPRETED","INVALID_TARGET","VALUE_NOT_AGENT_READABLE","FORMULA_TEXT_UNAVAILABLE","SOURCE_RANGE_UNRESOLVED","SOURCE_RANGE_INVALID","SENSITIVE_DETAILS_OMITTED"]},"fields":{"type":"array","description":"受该原因影响的 details 字段路径","items":{"type":"string","description":"被省略或不完整的字段路径"}}},"required":["code"],"additionalProperties":true}
}
},
"required":["type","targets","details","detailsStatus","omissions"],
"additionalProperties":true
}
}
},
"required":["revision","createTime","isSelfEdit","eventType","detailsStatus","changes"],
"additionalProperties":true
}
}
},
"required":["success","logId","schemaVersion","changeSemantics","latestRevision","startRevision","endRevision","summary","changesets"],
"additionalProperties":true
}`),
}
type sheetPublishedResultSchemaCache struct {
once sync.Once
schema map[string]any
err error
}
var (
sheetRevisionPublishedSchemaCache sheetPublishedResultSchemaCache
sheetChangesetPublishedSchemaCache sheetPublishedResultSchemaCache
)
func newSheetRevisionCmds() []*cobra.Command {
return []*cobra.Command{newSheetRevisionGetCmd(), newSheetChangesetGetCmd()}
}
func newSheetRevisionGetCmd() *cobra.Command {
return NewLeafCommand(LeafSpec{
Use: "revision-get",
Short: "获取表格工作簿当前 revision",
Long: "获取在线电子表格工作簿当前持久化 revision。该能力是工作簿级的,不接收 --sheet-id;--node 可传文档 ID 或完整 URL。服务端业务 JSON 原样放入统一输出的 data。",
Example: " dws sheet revision-get --node <NODE_ID_OR_URL> --format json",
OutputRollout: output.RolloutUnifiedActive,
Server: "sheet",
Tool: sheetRevisionGetRemoteTool,
Flags: []LeafFlag{
{Name: "node", Usage: "表格文档 ID 或 URL (必填)", Bind: "nodeId", Required: true, Trim: true, Example: "https://alidocs.dingtalk.com/i/nodes/xxx"},
},
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "sheet",
Name: sheetRevisionGetRemoteTool,
CanonicalPath: "sheet." + sheetRevisionGetRemoteTool,
CLIPath: "sheet revision-get",
PrimaryCLIPath: "sheet revision-get",
},
Description: "获取在线电子表格工作簿当前持久化 revision;结果为后续 changeset 区间查询的 revision 锚点。",
DryRun: &contract.DryRunSpec{PreviewKind: contract.DryRunPreviewRequest, RemoteReads: false},
Result: sheetRevisionResult,
Interface: &contract.InterfaceSpec{
Mode: contract.InterfaceModeMCP,
Availability: contract.InterfaceAvailable,
Ref: &contract.InterfaceRefSpec{ProductID: "sheet", RPCName: sheetRevisionGetRemoteTool},
},
Selection: contract.SelectionSpec{
AgentSummary: "获取在线电子表格工作簿当前 Delta revision,作为 changeset 查询锚点。",
UseWhen: []string{"需要知道当前 revision,或准备按 revision 区间复核工作簿编辑时"},
AvoidWhen: []string{"要查看可命名/可回滚的历史快照时用 sheet version list;要读当前单元格值时用 csv-get、table-get 或 range read"},
Examples: []string{"dws sheet revision-get --node <NODE_ID_OR_URL> --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "node", Property: "nodeId", Description: "在线电子表格文档 ID 或完整 URL"},
},
},
ResultCall: callSheetRevisionResult,
})
}
func newSheetChangesetGetCmd() *cobra.Command {
return NewLeafCommand(LeafSpec{
Use: "changeset-get",
Short: "获取表格工作簿 revision 区间内的 changeset",
Long: `获取在线电子表格工作簿在 (startRevision, endRevision] 区间内的连续、Agent 可解释的前向 changeset。
该能力是工作簿级的,不接收 --sheet-id。--end-revision 省略时由服务端在请求开始时固定为 latestRevision;单次区间最多 20 个 revision。统一输出 data.changesets 始终是可直接遍历的 JSON 数组;CLI 只解码服务端传输格式,不改写语义化 change。changeset 只描述当时提交的前向变更,不提供统一 old/current 值;确认最终状态必须另行回读。`,
Example: ` dws sheet changeset-get --node <NODE_ID_OR_URL> --start-revision 120 --end-revision 121 --format json
dws sheet changeset-get --node <NODE_ID_OR_URL> --start-revision 120 --format json`,
OutputRollout: output.RolloutUnifiedActive,
Server: "sheet",
Tool: sheetChangesetGetRemoteTool,
Flags: []LeafFlag{
{Name: "node", Usage: "表格文档 ID 或 URL (必填)", Bind: "nodeId", Required: true, Trim: true, Example: "https://alidocs.dingtalk.com/i/nodes/xxx"},
{
Name: "start-revision", Usage: "查询基线 revision (必填,非负;结果不包含该 revision)", Bind: "startRevision",
Required: true, Trim: true, Example: "120", Transform: sheetRevisionNumberArg,
},
{
Name: "end-revision", Usage: "查询结束 revision (可选,非负;结果包含该 revision)", Bind: "endRevision",
Trim: true, OmitEmpty: true, Example: "121", Transform: sheetRevisionNumberArg,
},
},
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "sheet",
Name: sheetChangesetGetRemoteTool,
CanonicalPath: "sheet." + sheetChangesetGetRemoteTool,
CLIPath: "sheet changeset-get",
PrimaryCLIPath: "sheet changeset-get",
},
Description: "获取在线电子表格工作簿 revision 区间内连续、语义化的前向 changeset;区间语义为 (startRevision, endRevision]。",
DryRun: &contract.DryRunSpec{PreviewKind: contract.DryRunPreviewRequest, RemoteReads: false},
Result: sheetChangesetResult,
Interface: &contract.InterfaceSpec{
Mode: contract.InterfaceModeMCP,
Availability: contract.InterfaceAvailable,
Ref: &contract.InterfaceRefSpec{ProductID: "sheet", RPCName: sheetChangesetGetRemoteTool},
},
Selection: contract.SelectionSpec{
AgentSummary: "读取工作簿两个 revision 之间的语义化前向 changeset,区分 EDIT、UNDO 与 STATE_RESET。",
UseWhen: []string{"已知起始 revision,需要复核之后发生了哪些工作簿级编辑或回滚时"},
AvoidWhen: []string{"要读取单元格当前最终值时用 csv-get、table-get 或 range read;要查看或回滚命名历史快照时用 sheet version list/revert"},
Examples: []string{"dws sheet changeset-get --node <NODE_ID_OR_URL> --start-revision 120 --end-revision 121 --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "node", Property: "nodeId", Description: "在线电子表格文档 ID 或完整 URL"},
{Name: "start-revision", Property: "startRevision", InterfaceType: "number", Description: "非负查询基线;返回区间不包含该 revision"},
{Name: "end-revision", Property: "endRevision", InterfaceType: "number", Description: "可选非负结束 revision;返回区间包含该 revision"},
},
},
Validate: validateSheetChangesetRange,
ResultCall: callSheetRevisionResult,
})
}
func callSheetRevisionResult(cmd *cobra.Command, tool string, args map[string]any) (output.CommandResult, error) {
if deps.Caller.DryRun() {
return output.Success(map[string]any{
"executed": false,
"tool": tool,
"arguments": args,
}, output.WithDryRun()), nil
}
raw, err := callMCPToolReturnTextOnServer(cmd.Context(), "sheet", tool, args)
if err != nil {
return nil, err
}
data, err := decodeSheetRevisionResult(tool, args, raw)
if err != nil {
return nil, err
}
return output.Success(data), nil
}
func decodeSheetRevisionResult(tool string, request map[string]any, raw string) (any, error) {
if strings.TrimSpace(raw) == "" {
return nil, invalidSheetRevisionResponse(tool,
"MCP sheet read tool returned no non-empty text content",
"empty_tool_response",
true,
)
}
data, err := decodeSheetSingleJSON(raw)
if err != nil {
return nil, invalidSheetRevisionResponse(tool,
fmt.Sprintf("MCP sheet read tool returned invalid JSON: %v", err),
"invalid_tool_response",
false,
)
}
object, ok := data.(map[string]any)
if !ok {
return nil, invalidSheetRevisionResponse(tool,
"MCP sheet read tool returned a non-object business result",
"invalid_tool_response",
false,
)
}
if isBusinessError(object) {
return nil, &CLIError{
Code: CodeMCPToolError,
Message: raw,
Suggestion: suggestForBusinessError(object),
Operation: "sheet/" + tool,
}
}
success, ok := object["success"].(bool)
if !ok || !success {
return nil, invalidSheetRevisionResponse(tool,
"MCP sheet read tool returned a business result without success=true",
"invalid_tool_response",
false,
)
}
if tool == sheetChangesetGetRemoteTool {
if err := normalizeSheetChangesetTransport(object); err != nil {
return nil, invalidSheetRevisionResponse(tool,
fmt.Sprintf("MCP sheet read tool returned invalid changeset data: %v%s",
err, sheetResultLogIDSuffix(object)),
"invalid_tool_response",
false,
)
}
}
if err := validateSheetPublishedResult(tool, object); err != nil {
return nil, invalidSheetRevisionResponse(tool,
fmt.Sprintf("MCP sheet read tool returned data that does not match its published result contract: %v%s",
err, sheetResultLogIDSuffix(object)),
"invalid_tool_response",
false,
)
}
if tool == sheetChangesetGetRemoteTool {
if err := validateSheetChangesetAudit(request, object); err != nil {
return nil, invalidSheetRevisionResponse(tool,
fmt.Sprintf("MCP sheet read tool returned changeset data that does not match the requested interval or its summary: %v%s",
err, sheetResultLogIDSuffix(object)),
"invalid_tool_response",
false,
)
}
}
return object, nil
}
func invalidSheetRevisionResponse(tool, message, reason string, retryable bool) error {
return apperrors.NewAPI(message,
apperrors.WithOperation("sheet/"+tool),
apperrors.WithOrigin("mcp"),
apperrors.WithFailureStage("response_validation"),
apperrors.WithRetryable(retryable),
apperrors.WithReason(reason),
)
}
func sheetResultLogIDSuffix(object map[string]any) string {
logID, ok := object["logId"].(string)
if !ok || strings.TrimSpace(logID) == "" {
return ""
}
return fmt.Sprintf(" (logId=%s)", strings.TrimSpace(logID))
}
func decodeSheetSingleJSON(raw string) (any, error) {
decoder := json.NewDecoder(strings.NewReader(raw))
decoder.UseNumber()
var data any
if err := decoder.Decode(&data); err != nil {
return nil, err
}
var trailing any
if err := decoder.Decode(&trailing); err != io.EOF {
if err == nil {
err = fmt.Errorf("包含多个 JSON 值")
}
return nil, err
}
return data, nil
}
func normalizeSheetChangesetTransport(object map[string]any) error {
encoded, hasEncoded := object["changesetsJson"]
if hasEncoded {
changesetsJSON, ok := encoded.(string)
if !ok {
return fmt.Errorf("changesetsJson 不是字符串")
}
if strings.TrimSpace(changesetsJSON) == "" {
return fmt.Errorf("changesetsJson 为空")
}
if len(changesetsJSON) > sheetChangesetJSONMaxBytes {
return fmt.Errorf("changesetsJson 超过 %d 字节", sheetChangesetJSONMaxBytes)
}
decoded, err := decodeSheetSingleJSON(changesetsJSON)
if err != nil {
return fmt.Errorf("changesetsJson 不是完整 JSON: %v", err)
}
changesets, ok := decoded.([]any)
if !ok {
return fmt.Errorf("changesetsJson 根节点不是数组")
}
object["changesets"] = changesets
delete(object, "changesetsJson")
return nil
}
if legacy, exists := object["changesets"]; exists {
if _, ok := legacy.([]any); !ok {
return fmt.Errorf("changesets 不是数组")
}
return nil
}
return fmt.Errorf("成功响应缺少 changesetsJson")
}
func validateSheetPublishedResult(tool string, object map[string]any) error {
var rawSchema json.RawMessage
var cache *sheetPublishedResultSchemaCache
switch tool {
case sheetRevisionGetRemoteTool:
rawSchema = sheetRevisionResult.DataSchema
cache = &sheetRevisionPublishedSchemaCache
case sheetChangesetGetRemoteTool:
rawSchema = sheetChangesetResult.DataSchema
cache = &sheetChangesetPublishedSchemaCache
default:
return fmt.Errorf("未知工具 %q", tool)
}
return validateSheetPublishedResultWithSchema(tool, object, rawSchema, cache)
}
func validateSheetPublishedResultWithSchema(tool string, object map[string]any,
rawSchema json.RawMessage, cache *sheetPublishedResultSchemaCache,
) error {
cache.once.Do(func() { cache.err = json.Unmarshal(rawSchema, &cache.schema) })
if cache.err != nil {
return fmt.Errorf("读取已发布 Result Schema 失败: %v", cache.err)
}
schema := cache.schema
if err := cli.ValidateJSONSchemaValue(object, schema); err != nil {
return err
}
if strings.TrimSpace(object["logId"].(string)) == "" {
return fmt.Errorf("$.logId 不能为空")
}
switch tool {
case sheetRevisionGetRemoteTool:
if _, err := sheetNonNegativeInteger(object["revision"]); err != nil {
return fmt.Errorf("$.revision %v", err)
}
case sheetChangesetGetRemoteTool:
latest, err := sheetNonNegativeInteger(object["latestRevision"])
if err != nil {
return fmt.Errorf("$.latestRevision %v", err)
}
start, err := sheetNonNegativeInteger(object["startRevision"])
if err != nil {
return fmt.Errorf("$.startRevision %v", err)
}
end, err := sheetNonNegativeInteger(object["endRevision"])
if err != nil {
return fmt.Errorf("$.endRevision %v", err)
}
if start > end {
return fmt.Errorf("$.startRevision 不能大于 $.endRevision")
}
if end > latest {
return fmt.Errorf("$.endRevision 不能大于 $.latestRevision")
}
}
return nil
}
type sheetChangesetSummaryAudit struct {
changeCount int64
completeChangeCount int64
partialChangeCount int64
unsupportedChangeCount int64
containsStateReset bool
containsIncompleteChanges bool
affectedSheets []sheetChangesetAffectedSheetAudit
}
type sheetChangesetAffectedSheetAudit struct {
sheetID string
sheetName string
ranges []string
}
type sheetChangesetAffectedSheetAccumulator struct {
sheetName string
ranges map[string]struct{}
}
func validateSheetChangesetAudit(request, object map[string]any) error {
requestStart := request["startRevision"].(int64)
responseStart, _ := sheetNonNegativeInteger(object["startRevision"])
responseEnd, _ := sheetNonNegativeInteger(object["endRevision"])
latest, _ := sheetNonNegativeInteger(object["latestRevision"])
if responseStart != requestStart {
return fmt.Errorf("$.startRevision=%d,与请求值 %d 不一致", responseStart, requestStart)
}
if requestEnd, exists := request["endRevision"]; exists {
if responseEnd != requestEnd.(int64) {
return fmt.Errorf("$.endRevision=%d,与请求值 %d 不一致", responseEnd, requestEnd)
}
} else if responseEnd != latest {
return fmt.Errorf("省略 endRevision 时 $.endRevision=%d,必须等于 $.latestRevision=%d", responseEnd, latest)
}
if responseEnd-responseStart > sheetChangesetMaxSpan {
return fmt.Errorf("响应区间超过 %d 个 revision", sheetChangesetMaxSpan)
}
changesets := object["changesets"].([]any)
wantCount := responseEnd - responseStart
if int64(len(changesets)) != wantCount {
return fmt.Errorf("$.changesets 数量为 %d,无法完整覆盖 (%d,%d]", len(changesets), responseStart, responseEnd)
}
for index, rawChangeset := range changesets {
changeset := rawChangeset.(map[string]any)
revision, err := sheetNonNegativeInteger(changeset["revision"])
if err != nil {
return fmt.Errorf("$.changesets[%d].revision %v", index, err)
}
expected := responseStart + int64(index) + 1
if revision != expected {
return fmt.Errorf("$.changesets[%d].revision=%d,期望连续 revision %d", index, revision, expected)
}
}
actualSummary, err := parseSheetChangesetSummaryAudit(object["summary"].(map[string]any))
if err != nil {
return err
}
expectedSummary := summarizeSheetChangesetsForAudit(changesets)
if !reflect.DeepEqual(actualSummary, expectedSummary) {
return fmt.Errorf("$.summary 与 $.changesets 复算结果不一致")
}
return nil
}
func parseSheetChangesetSummaryAudit(raw map[string]any) (sheetChangesetSummaryAudit, error) {
fields := []string{"changeCount", "completeChangeCount", "partialChangeCount", "unsupportedChangeCount"}
counts := make([]int64, len(fields))
for index, field := range fields {
value, err := sheetNonNegativeInteger(raw[field])
if err != nil {
return sheetChangesetSummaryAudit{}, fmt.Errorf("$.summary.%s %v", field, err)
}
counts[index] = value
}
result := sheetChangesetSummaryAudit{
changeCount: counts[0],
completeChangeCount: counts[1],
partialChangeCount: counts[2],
unsupportedChangeCount: counts[3],
containsStateReset: raw["containsStateReset"].(bool),
containsIncompleteChanges: raw["containsIncompleteChanges"].(bool),
}
for _, rawSheet := range raw["affectedSheets"].([]any) {
sheet := rawSheet.(map[string]any)
rawRanges := sheet["ranges"].([]any)
affected := sheetChangesetAffectedSheetAudit{
sheetID: sheet["sheetId"].(string),
ranges: make([]string, 0, len(rawRanges)),
}
affected.sheetName, _ = sheet["sheetName"].(string)
for _, rawRange := range rawRanges {
affected.ranges = append(affected.ranges, rawRange.(string))
}
result.affectedSheets = append(result.affectedSheets, affected)
}
return result, nil
}
func summarizeSheetChangesetsForAudit(changesets []any) sheetChangesetSummaryAudit {
result := sheetChangesetSummaryAudit{}
affected := map[string]*sheetChangesetAffectedSheetAccumulator{}
for _, rawChangeset := range changesets {
changeset := rawChangeset.(map[string]any)
result.containsStateReset = result.containsStateReset || changeset["eventType"] == "STATE_RESET"
result.containsIncompleteChanges = result.containsIncompleteChanges || changeset["detailsStatus"] != "COMPLETE"
for _, rawChange := range changeset["changes"].([]any) {
change := rawChange.(map[string]any)
result.changeCount++
switch change["detailsStatus"] {
case "COMPLETE":
result.completeChangeCount++
case "PARTIAL":
result.partialChangeCount++
}
if change["type"] == "UNSUPPORTED_CHANGE" {
result.unsupportedChangeCount++
}
for _, rawTarget := range change["targets"].([]any) {
target := rawTarget.(map[string]any)
if target["role"] == "SOURCE" {
continue
}
sheetID, _ := target["sheetId"].(string)
if strings.TrimSpace(sheetID) == "" {
continue
}
accumulator := affected[sheetID]
if accumulator == nil {
accumulator = &sheetChangesetAffectedSheetAccumulator{ranges: map[string]struct{}{}}
affected[sheetID] = accumulator
}
if sheetName, ok := target["sheetName"].(string); ok && strings.TrimSpace(sheetName) != "" {
accumulator.sheetName = sheetName
}
if a1Range, ok := target["a1Range"].(string); ok && strings.TrimSpace(a1Range) != "" {
accumulator.ranges[a1Range] = struct{}{}
}
}
}
}
sheetIDs := make([]string, 0, len(affected))
for sheetID := range affected {
sheetIDs = append(sheetIDs, sheetID)
}
sort.Strings(sheetIDs)
for _, sheetID := range sheetIDs {
accumulator := affected[sheetID]
ranges := make([]string, 0, len(accumulator.ranges))
for a1Range := range accumulator.ranges {
ranges = append(ranges, a1Range)
}
sort.Strings(ranges)
result.affectedSheets = append(result.affectedSheets, sheetChangesetAffectedSheetAudit{
sheetID: sheetID, sheetName: accumulator.sheetName, ranges: ranges,
})
}
return result
}
func sheetNonNegativeInteger(value any) (int64, error) {
number, ok := value.(json.Number)
if !ok {
return 0, fmt.Errorf("必须是非负整数")
}
parsed, err := strconv.ParseInt(number.String(), 10, 64)
if err != nil || parsed < 0 {
return 0, fmt.Errorf("必须是非负整数")
}
return parsed, nil
}
func sheetRevisionNumberArg(raw string) (any, error) {
return strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
}
func validateSheetChangesetRange(cmd *cobra.Command, _ []string) error {
start, err := parseSheetRevisionFlag(cmd, "start-revision")
if err != nil {
return err
}
if start < 0 {
return apperrors.NewValidation("--start-revision 必须是非负整数")
}
endRaw, _ := cmd.Flags().GetString("end-revision")
if strings.TrimSpace(endRaw) == "" {
return nil
}
end, err := parseSheetRevisionFlag(cmd, "end-revision")
if err != nil {
return err
}
if end < 0 {
return apperrors.NewValidation("--end-revision 必须是非负整数")
}
if end < start {
return apperrors.NewValidation("--end-revision 必须大于或等于 --start-revision")
}
if end-start > sheetChangesetMaxSpan {
return apperrors.NewValidation(fmt.Sprintf(
"单次最多查询 %d 个 revision;请把 --end-revision 调整为不大于 %d",
sheetChangesetMaxSpan, start+sheetChangesetMaxSpan,
))
}
return nil
}
func parseSheetRevisionFlag(cmd *cobra.Command, name string) (int64, error) {
raw, _ := cmd.Flags().GetString(name)
value, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
if err != nil {
return 0, apperrors.NewValidation(fmt.Sprintf("--%s 必须是 64 位整数", name))
}
return value, nil
}
File diff suppressed because it is too large. Load diff
+13 -8
View File
@@ -111,9 +111,14 @@ func newSheetVersionCmd() *cobra.Command {
versionListCmd.Flags().String("cursor", "", "分页游标")
versionRevertCmd := &cobra.Command{
Use: "revert",
Short: "[危险] 回滚表格到指定版本",
Example: ` dws sheet version revert --node SHEET_ID --version 3 --yes`,
Use: "revert",
Short: "[危险] 回滚表格到指定历史版本或 revision",
Long: `将在线表格恢复到指定历史版本或精确 revision。
通常应从 version list 选择已保存的历史版本。用户明确要求恢复到某个精确 revision 时,
也可传入已从同一工作簿真实查询结果确认的 revision,即使它不在版本列表中。未列入
版本列表的 revision 只有在服务端仍可恢复时才能成功;禁止猜测 revision。`,
Example: ` dws sheet version revert --node SHEET_ID --version 3`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
@@ -142,22 +147,22 @@ func newSheetVersionCmd() *cobra.Command {
CLIPath: "sheet version revert",
PrimaryCLIPath: "sheet version revert",
},
Description: "回滚表格到指定历史版本",
Description: "回滚表格到指定历史版本或已确认的精确 revision",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
},
Selection: contract.SelectionSpec{
AgentSummary: "回滚表格到指定历史版本",
UseWhen: []string{"用户说 回滚到某个版本/恢复到之前的表格"},
AvoidWhen: []string{"普通文件回滚用 drive revert;在线文档用 doc version revert"},
AgentSummary: "回滚表格到指定历史版本或已确认的精确 revision",
UseWhen: []string{"用户说 回滚到某个版本/恢复到之前的表格,或明确要求恢复到同一工作簿中已确认的 revision"},
AvoidWhen: []string{"普通文件回滚用 drive revert;在线文档用 doc version revert;目标 revision 未经同一工作簿的真实查询结果确认时不要猜测"},
Examples: []string{"dws sheet version revert --node <SHEET_ID> --version 3 --format json"},
},
},
})
versionRevertCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
versionRevertCmd.Flags().Int("version", 0, "目标版本号 (必填,从 list 获取)")
versionRevertCmd.Flags().Int("version", 0, "目标历史版本或已确认 revision (必填,通常从 version list 获取)")
for _, c := range []*cobra.Command{versionSaveCmd, versionListCmd, versionRevertCmd} {
c.Flags().String("url", "", "")
+78
View File
@@ -0,0 +1,78 @@
// 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"
"reflect"
"strings"
"testing"
)
func TestSheetVersionRevertHelpDocumentsConfirmedRevisionTargets(t *testing.T) {
command := newSheetVersionCmd()
var output bytes.Buffer
command.SetOut(&output)
command.SetErr(&output)
command.SetArgs([]string{"revert", "--help"})
if err := command.Execute(); err != nil {
t.Fatalf("sheet version revert --help returned error: %v", err)
}
help := output.String()
for _, expected := range []string{
"通常应从 version list 选择已保存的历史版本",
"已从同一工作簿真实查询结果确认的 revision",
"禁止猜测 revision",
"目标历史版本或已确认 revision",
} {
if !strings.Contains(help, expected) {
t.Fatalf("sheet version revert help missing %q:\n%s", expected, help)
}
}
if strings.Contains(help, "--yes") {
t.Fatalf("sheet version revert help must not publish a confirmation-bypass example:\n%s", help)
}
}
func TestSheetVersionRevertRequiresConfirmationAndCallsExactToolWhenConfirmed(t *testing.T) {
args := []string{"version", "revert", "--node", "node-1", "--version", "37"}
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newSheetCommand, args...)
requireTypedConfirmationError(t, err)
if len(caller.calls) != 0 {
t.Fatalf("expected 0 MCP calls before confirmation, got %d: %+v", len(caller.calls), caller.calls)
}
caller = &guardedMutationCaller{}
confirmedArgs := append(append([]string(nil), args...), "--yes")
if err := executeGuardedMutationCommand(t, caller, newSheetCommand, confirmedArgs...); err != nil {
t.Fatalf("sheet version revert after confirmation returned error: %v", err)
}
want := guardedMutationCall{
productID: "doc",
toolName: "revert_doc_version",
args: map[string]any{
"nodeId": "node-1",
"version": 37,
},
}
if len(caller.calls) != 1 {
t.Fatalf("expected exactly 1 MCP call after confirmation, got %d: %+v", len(caller.calls), caller.calls)
}
if !reflect.DeepEqual(caller.calls[0], want) {
t.Fatalf("tool call = %#v, want %#v", caller.calls[0], want)
}
}
@@ -179,6 +179,7 @@ func TestWukongSyncAgoalCommands(t *testing.T) {
{[]string{"scorecard", "detail"}, []string{"selected-time", "dept-id", "request-id"}},
{[]string{"scorecard", "entity-detail"}, []string{"sc-id", "entity-id", "request-id"}},
{[]string{"scorecard", "update"}, []string{"dept-id", "selected-time", "id", "tracking-period-type", "content", "request-id"}},
{[]string{"scorecard", "search-entities"}, []string{"keyword", "page", "page-size", "request-id"}},
{[]string{"user", "rules"}, []string{"user-id", "request-id"}},
{[]string{"user", "objectives"}, []string{"user-id", "rule-id", "period-ids", "request-id"}},
{[]string{"report", "list-statistics"}, []string{"keyword", "request-id"}},
+1
View File
@@ -80,6 +80,7 @@
"工具调用失败;请检查参数和上游服务状态。": "Tool invocation failed; check parameters and upstream service status.",
"已取消操作": "Operation cancelled",
"当前平台 %s 没有可用的预编译二进制": "No pre-built binary available for platform %s",
"当前身份的 refresh_token 已失效,已从组织镜像 token 恢复登录态": "The current identity's refresh_token is invalid; login state was recovered from the organization mirror token.",
"待办": "todo",
"待办任务 ID (必填)": "Todo task ID (required)",
"待办任务管理": "Todo task management",
+1
View File
@@ -80,6 +80,7 @@
"工具调用失败;请检查参数和上游服务状态。": "工具调用失败;请检查参数和上游服务状态。",
"已取消操作": "已取消操作",
"当前平台 %s 没有可用的预编译二进制": "当前平台 %s 没有可用的预编译二进制",
"当前身份的 refresh_token 已失效,已从组织镜像 token 恢复登录态": "当前身份的 refresh_token 已失效,已从组织镜像 token 恢复登录态",
"待办": "待办",
"待办任务 ID (必填)": "待办任务 ID (必填)",
"待办任务管理": "待办任务管理",
+1
View File
@@ -41,6 +41,7 @@ import (
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/sheet"
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/smart"
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/todo"
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/whiteboard"
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/wiki"
"github.com/spf13/cobra"
@@ -0,0 +1,72 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package builtin_test
import (
"encoding/json"
"os"
"sort"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
func TestCrossPlatformCoverageSheetSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) {
raw, err := os.ReadFile("../semantic_catalog_sheet.json")
if err != nil {
t.Fatal(err)
}
var source chatSemanticCatalogFixture
if err := json.Unmarshal(raw, &source); err != nil {
t.Fatal(err)
}
if source.Service != "sheet" {
t.Fatalf("service=%q", source.Service)
}
registered := map[string]shortcut.Shortcut{}
for _, item := range shortcut.All() {
if item.Service != "sheet" {
continue
}
if _, duplicate := registered[item.Command]; duplicate {
t.Fatalf("duplicate Sheet Shortcut %s", item.Command)
}
registered[item.Command] = item
}
if len(registered) != 2 || len(source.Shortcuts) != 2 {
t.Fatalf("registered/catalog=%d/%d, want 2/2", len(registered), len(source.Shortcuts))
}
var missing, stale []string
for command, item := range registered {
record, ok := source.Shortcuts[command]
if !ok {
missing = append(missing, command)
continue
}
if !record.Reviewed || !item.SemanticReviewed || strings.TrimSpace(item.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta {
t.Errorf("%s semantic review facts drifted", command)
}
if !record.Public || item.Hidden || !shortcut.InPublicCatalog("sheet", command) {
t.Errorf("%s is not delivered as reviewed public", command)
}
if item.Availability != shortcut.AvailabilityAvailable || item.Risk != record.Risk {
t.Errorf("%s availability/risk=%q/%q", command, item.Availability, item.Risk)
}
if item.Contract.Empty() || item.Contract.Result == nil || item.Contract.Pagination != nil || strings.TrimSpace(item.Safety.Effect) == "" || item.OutputRollout != output.RolloutUnifiedActive {
t.Errorf("%s lacks Result/Safety/unified output or publishes false pagination", command)
}
}
for command := range source.Shortcuts {
if _, ok := registered[command]; !ok {
stale = append(stale, command)
}
}
sort.Strings(missing)
sort.Strings(stale)
if len(missing) != 0 || len(stale) != 0 {
t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale)
}
}
@@ -0,0 +1,84 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package builtin_test
import (
"encoding/json"
"os"
"sort"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
func TestCrossPlatformCoverageWhiteboardSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) {
raw, err := os.ReadFile("../semantic_catalog_whiteboard.json")
if err != nil {
t.Fatal(err)
}
var source chatSemanticCatalogFixture
if err := json.Unmarshal(raw, &source); err != nil {
t.Fatal(err)
}
if source.Service != "whiteboard" {
t.Fatalf("service=%q", source.Service)
}
registered := map[string]shortcut.Shortcut{}
for _, item := range shortcut.All() {
if item.Service != "whiteboard" {
continue
}
if _, duplicate := registered[item.Command]; duplicate {
t.Fatalf("duplicate Whiteboard Shortcut %s", item.Command)
}
registered[item.Command] = item
}
if len(registered) != 2 || len(source.Shortcuts) != 2 {
t.Fatalf("registered/catalog=%d/%d, want 2/2", len(registered), len(source.Shortcuts))
}
public, unavailable, hidden := 0, 0, 0
var missing, stale []string
for command, item := range registered {
record, ok := source.Shortcuts[command]
if !ok {
missing = append(missing, command)
continue
}
if !record.Reviewed || !item.SemanticReviewed || strings.TrimSpace(item.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta {
t.Errorf("%s semantic review facts drifted", command)
}
if !record.Public || item.Hidden || !shortcut.InPublicCatalog("whiteboard", command) {
t.Errorf("%s did not enter the public surface", command)
}
if !record.Public {
hidden++
} else {
public++
}
if item.Availability == shortcut.AvailabilityUnavailable {
unavailable++
}
if item.Availability != shortcut.AvailabilityAvailable || record.Availability != shortcut.AvailabilityAvailable || item.Risk != record.Risk {
t.Errorf("%s availability/risk=%q/%q", command, item.Availability, item.Risk)
}
if item.Contract.Empty() || item.Contract.Result == nil || item.Contract.Pagination != nil || strings.TrimSpace(item.Safety.Effect) == "" || item.OutputRollout != output.RolloutUnifiedActive || item.Contract.Interface == nil || item.Contract.Interface.Availability != "available" || strings.TrimSpace(item.Contract.Interface.Reason) == "" {
t.Errorf("%s lacks Result/Safety/unified output or publishes false pagination", command)
}
}
if public != 2 || unavailable != 0 || hidden != 0 {
t.Fatalf("Whiteboard source/public/unavailable/hidden=2/%d/%d/%d, want 2/2/0/0", public, unavailable, hidden)
}
for command := range source.Shortcuts {
if _, ok := registered[command]; !ok {
stale = append(stale, command)
}
}
sort.Strings(missing)
sort.Strings(stale)
if len(missing) != 0 || len(stale) != 0 {
t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale)
}
}
+17 -4
View File
@@ -176,15 +176,17 @@ var Fetch = shortcut.Shortcut{
Command: "+fetch",
Product: productDoc,
Description: "读取完整或局部文档内容,并按 detail 控制保真度",
Intent: "当用户要按 node/URL 直接读取在线文字文档,或只知道唯一标题并希望一次完成解析和读取时使用;支持 outline/range/section/keyword/tags 局部内容用于精确编辑和评论。",
Intent: "当用户要按 node/URL 直接读取在线文字文档,或只知道唯一标题并希望一次完成解析和读取时使用;支持 outline/range/section/keyword/tags 局部内容用于精确编辑和评论;互联网公开文档(含密码保护)用 --password 提供访问密码,读历史版本用 --version 指定版本号(0 表示初始版本)。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: docContract(
"+fetch", "读取完整或局部文档内容,并按 detail 控制保真度",
"当用户要按 node/URL 直接读取在线文字文档,或只知道唯一标题并希望一次完成解析和读取时使用;支持 outline/range/section/keyword/tags 局部内容用于精确编辑和评论。",
"当用户要按 node/URL 直接读取在线文字文档,或只知道唯一标题并希望一次完成解析和读取时使用;支持 outline/range/section/keyword/tags 局部内容用于精确编辑和评论;互联网公开文档(含密码保护)用 --password 提供访问密码,读历史版本用 --version 指定版本号(0 表示初始版本)。",
[]string{`dws doc +fetch --node <DOC_ID>`, `dws doc +fetch --query "项目周报" --scope keyword --keyword "结论"`},
contract.ParamDecl{Name: "node", Property: "nodeId"},
contract.ParamDecl{Name: "query", Property: "keyword"},
contract.ParamDecl{Name: "password", Property: "password"},
contract.ParamDecl{Name: "version", Property: "historyVersion"},
),
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL;" + fetchTargetConstraint},
@@ -198,12 +200,17 @@ var Fetch = shortcut.Shortcut{
{Name: "context-before", Type: shortcut.FlagInt, Desc: "关键词命中前的上下文字符数"},
{Name: "context-after", Type: shortcut.FlagInt, Desc: "关键词命中后的上下文字符数"},
{Name: "max-depth", Type: shortcut.FlagInt, Desc: "outline/section 最大深度"},
{Name: "revision", Type: shortcut.FlagInt, Desc: "只接受当前最新版;历史 revision 暂不支持"},
{Name: "password", Type: shortcut.FlagString, Desc: "互联网公开文档开启密码保护时的访问密码;普通文档无需传入"},
{Name: "revision", Type: shortcut.FlagInt, Desc: "不支持;revision 是文档编辑版本号(JSONML 读取响应返回、供 +update --expected-revision 条件写使用),不是历史版本号"},
{Name: "version", Type: shortcut.FlagInt, Desc: "读取指定历史版本(版本号从 doc +version-list 获取, 0 表示初始版本, 需要文档编辑权限);缺省读最新版"},
},
Tips: []string{`dws doc +fetch --node <DOC_ID>`, `dws doc +fetch --query "项目周报" --scope keyword --keyword "结论"`},
Validate: func(rt *shortcut.RuntimeContext) error {
if rt.Changed("revision") {
return apperrors.NewValidation("HISTORICAL_READ_UNSUPPORTED: 当前接口不能读取指定历史 revision")
return apperrors.NewValidation("--revision 不支持:revision 是文档编辑版本号(doc read --content-format jsonml 响应返回,供 doc +update --expected-revision 条件写使用),不是历史版本号;读历史版本请用 --version")
}
if rt.Changed("version") && rt.Int("version") < 0 {
return apperrors.NewValidation("--version 必须为非负整数历史版本号(0 表示初始版本,从 doc +version-list 获取)")
}
if rt.Str("scope") == "keyword" && rt.Str("keyword") == "" {
return apperrors.NewValidation("--scope keyword 时必须提供 --keyword")
@@ -240,6 +247,12 @@ var Fetch = shortcut.Shortcut{
if rt.Changed("max-depth") {
params["maxDepth"] = rt.Int("max-depth")
}
if rt.Changed("version") {
params["historyVersion"] = rt.Int("version")
}
if value := rt.Str("password"); value != "" {
params["password"] = value
}
data, err := rt.CallMCPData(productDoc, "get_document_content", params)
if err != nil {
return err
+23 -1
View File
@@ -1097,7 +1097,8 @@ func TestCrossPlatformCoverageDocContentValidationAndPureHelpers(t *testing.T) {
{Create, []string{"--name", "n", "--content", `{}`, "--doc-format", "jsonml"}},
{Create, []string{"--name", "n", "--content", `[]`, "--doc-format", "jsonml"}},
{Create, []string{"--name", "n", "--content", `[["p",{},"x"]]`, "--doc-format", "jsonml"}},
{Fetch, []string{"--node", "n", "--revision", "1"}},
{Fetch, []string{"--node", "n", "--revision", "7"}},
{Fetch, []string{"--node", "n", "--version", "-2"}},
{Fetch, []string{"--node", "n", "--scope", "keyword"}},
{Update, []string{"--node", "n"}},
{Update, []string{"--command", "append", "--content", "x", "--yes"}},
@@ -1128,6 +1129,27 @@ func TestCrossPlatformCoverageDocContentValidationAndPureHelpers(t *testing.T) {
t.Fatalf("expected revision was not forwarded atomically: %#v", revision.history)
}
fetchAccess := &docCoverageCaller{}
if err := runDocCoverage(t, Fetch, fetchAccess, "--node", "n", "--version", "7", "--password", "pw"); err != nil {
t.Fatalf("fetch with access params failed: %v", err)
}
if len(fetchAccess.history) != 1 ||
fetchAccess.history[0].tool != "get_document_content" ||
fetchAccess.history[0].params["historyVersion"] != 7 ||
fetchAccess.history[0].params["password"] != "pw" {
t.Fatalf("fetch access params were not forwarded: %#v", fetchAccess.history)
}
fetchZero := &docCoverageCaller{}
if err := runDocCoverage(t, Fetch, fetchZero, "--node", "n", "--version", "0"); err != nil {
t.Fatalf("fetch with zero version failed: %v", err)
}
if len(fetchZero.history) != 1 ||
fetchZero.history[0].tool != "get_document_content" ||
fetchZero.history[0].params["historyVersion"] != 0 {
t.Fatalf("fetch zero version (initial version) was not forwarded: %#v", fetchZero.history)
}
for _, value := range []any{
map[string]any{"revision": 2.5}, map[string]any{"revision": json.Number("bad")}, map[string]any{"revision": "bad"},
map[string]any{"data": []any{map[string]any{"versionNumber": "3"}}}, []any{map[string]any{"version": 4.0}}, "none",
@@ -402,6 +402,8 @@ func generatedPublicShortcutCatalog() map[string]struct{} {
"todo\u0000+search": {},
"todo\u0000+todo-done": {},
"todo\u0000+update": {},
"whiteboard\u0000+query": {},
"whiteboard\u0000+update": {},
"wiki\u0000+delete-space": {},
"wiki\u0000+feed-list": {},
"wiki\u0000+member-add": {},
+8
View File
@@ -49,6 +49,12 @@ var dingSemanticCatalogJSON []byte
//go:embed semantic_catalog_report.json
var reportSemanticCatalogJSON []byte
//go:embed semantic_catalog_sheet.json
var sheetSemanticCatalogJSON []byte
//go:embed semantic_catalog_whiteboard.json
var whiteboardSemanticCatalogJSON []byte
type semanticCatalogFile struct {
Version int `json:"version"`
Service string `json:"service"`
@@ -81,6 +87,8 @@ var reviewedSemanticCatalog = mustLoadSemanticCatalogs(
oaSemanticCatalogJSON,
dingSemanticCatalogJSON,
reportSemanticCatalogJSON,
sheetSemanticCatalogJSON,
whiteboardSemanticCatalogJSON,
)
func mustLoadSemanticCatalogs(sources ...[]byte) map[string]semanticCatalogRecord {
@@ -0,0 +1,21 @@
{
"version": 1,
"service": "sheet",
"default_availability": "available",
"shortcuts": {
"+list-sheets": {
"disposition": "semantic_adapter",
"semantic_delta": "严格要求 success=true、显式 sheets 数组、非空且唯一的 sheetId 与标题;提供完整标题本地精确筛选,因此可分别证明已知非空和合法零命中,未知结构绝不降级为空数组。",
"risk": "read",
"public": true,
"reviewed": true
},
"+read": {
"disposition": "semantic_adapter",
"semantic_delta": "严格校验 success、二维 cells、行列坐标与完成证据;服务返回 hasMore=true 时因没有可执行续页游标而失败关闭,保留 Sheet 读取与 AITable/Base 记录查询的产品边界。",
"risk": "read",
"public": true,
"reviewed": true
}
}
}
@@ -0,0 +1,23 @@
{
"version": 1,
"service": "whiteboard",
"default_availability": "available",
"shortcuts": {
"+query": {
"disposition": "semantic_adapter",
"semantic_delta": "严格投影要求 success=true、OpenNodes V1、显式 pages 数组、每页稳定 id 与显式 nodes 数组,并校验跨页节点身份及服务端完整性摘要。",
"risk": "read",
"availability": "available",
"public": true,
"reviewed": true
},
"+update": {
"disposition": "primary_smart",
"semantic_delta": "首次远端调用前完成 OpenNodes V1 校验与用户确认;写后要求 success=true、同一目标、非空终态回执、createdNodeIds/idMap 精确映射,再按真实节点身份独立 query 读回请求关键字段。",
"risk": "high-risk-write",
"availability": "available",
"public": true,
"reviewed": true
}
}
}
+316 -93
View File
@@ -17,9 +17,17 @@
package sheet
import (
"encoding/json"
"fmt"
"math"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/responsecheck"
)
// ── workbook & worksheet ──────────────────────────────────────────────
@@ -27,12 +35,13 @@ import (
// Create creates a new DingTalk online spreadsheet document.
// ListSheets lists all worksheets in a spreadsheet document.
var ListSheets = shortcut.Shortcut{
Service: "sheet",
Command: "+list-sheets",
Product: "sheet",
Description: "获取表格文档中全部工作表列表",
Intent: "当你拿到一个表格文档、想先了解它里面有哪些工作表(sheet)以及各自的 sheetId 时使用,通常作为读写具体数据前的第一步;传入表格文档 ID 或 URL,返回工作表清单。",
Risk: shortcut.RiskRead,
OutputRollout: output.RolloutUnifiedActive,
Service: "sheet",
Command: "+list-sheets",
Product: "sheet",
Description: "严格列出在线电子表格的工作表,并可按完整标题精确筛选",
Intent: "需要发现一个在线电子表格中的工作表与稳定 sheetId,或验证某个完整工作表标题是否存在时",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
@@ -45,107 +54,95 @@ var ListSheets = shortcut.Shortcut{
CLIPath: "sheet +list-sheets",
PrimaryCLIPath: "sheet +list-sheets",
},
Description: "获取表格文档中全部工作表列表",
Description: "严格列出在线电子表格的工作表",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
},
Selection: contract.SelectionSpec{
AgentSummary: "获取表格文档中全部工作表列表",
UseWhen: []string{"当你拿到一个表格文档、想先了解它里面有哪些工作表(sheet)以及各自的 sheetId 时使用,通常作为读写具体数据前的第一步;传入表格文档 ID 或 URL,返回工作表清单。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
AgentSummary: "严格列出在线电子表格的工作表,并可按完整标题精确筛选",
UseWhen: []string{"需要发现一个在线电子表格中的工作表与稳定 sheetId,或验证某个完整工作表标题是否存在时"},
AvoidWhen: []string{"需要管理 AITable/Base 的数据表或记录时改用 aitable;需要原始 get_all_sheets 响应时改用 sheet list"},
Examples: []string{"dws sheet +list-sheets --node NODE_ID"},
},
Parameters: []contract.ParamDecl{
{Name: "node", Property: "node"},
{Name: "title", Property: "title"},
},
Result: &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: json.RawMessage(`{"type":"object","description":"经过严格校验的工作表列表","properties":{"count":{"type":"integer","description":"精确筛选后的工作表数量"},"sheets":{"type":"array","description":"工作表条目;显式空数组表示合法零命中","items":{"type":"object","description":"带稳定身份的工作表","properties":{"sheetId":{"type":"string","description":"稳定工作表 ID"},"title":{"type":"string","description":"工作表完整标题"},"index":{"type":"integer","description":"可选工作表顺序"},"visibility":{"description":"可选工作表可见性"},"rowCount":{"type":"integer","description":"可选行数"},"columnCount":{"type":"integer","description":"可选列数"}},"required":["sheetId","title"],"additionalProperties":false}}},"required":["count","sheets"],"additionalProperties":false}`),
SensitivePaths: []string{"sheets.sheetId", "sheets.title"},
},
},
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "表格文档 ID 或 URL", Required: true},
{Name: "node", Type: shortcut.FlagString, Desc: "表格文档 ID 或 URL;--node 去除空白后不能为空", Required: true},
{Name: "title", Type: shortcut.FlagString, Desc: "按完整工作表标题精确筛选(区分大小写);显式传入时去除空白后不能为空"},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintCustom, Flags: []string{"node"}, Description: "--node 去除空白后不能为空"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"title"}, Description: "--title 显式传入时去除空白后不能为空"},
},
Tips: []string{`dws sheet +list-sheets --node NODE_ID`},
Validate: func(rt *shortcut.RuntimeContext) error {
if err := validateSheetNode(rt); err != nil {
return err
}
return validateOptionalSheetString(rt, "title")
},
Execute: func(rt *shortcut.RuntimeContext) error {
data, err := rt.CallMCPData("sheet", "get_all_sheets", map[string]any{"nodeId": rt.Str("node")})
if err != nil {
return err
}
sheets := listSheetsProject(data)
sheets, err := projectSheetList(data, rt.Str("title"))
if err != nil {
return err
}
return rt.Output(map[string]any{"count": len(sheets), "sheets": sheets})
},
}
// listSheetsProject reshapes the raw get_all_sheets response into a clean
// worksheet list (sheetId/title/index/visibility/rowCount/columnCount) — the
// the clean output projection applied to every list command. Both
// the list container and the per-item field names are probed defensively across
// candidate keys, so an empty/unknown shape yields an empty list rather than a
// crash or fabricated data.
func listSheetsProject(data map[string]any) []map[string]any {
raw := listSheetsResolveList(data)
out := make([]map[string]any, 0, len(raw))
for _, item := range raw {
m, ok := item.(map[string]any)
if !ok {
func projectSheetList(data map[string]any, exactTitle string) ([]map[string]any, error) {
items, err := responsecheck.RequireObjectCollection(data, "sheet/get_all_sheets", "sheets")
if err != nil {
return nil, err
}
seen := make(map[string]struct{}, len(items))
out := make([]map[string]any, 0, len(items))
for index, item := range items {
sheetID, err := requireSheetString(item, index, "sheetId")
if err != nil {
return nil, err
}
if _, duplicate := seen[sheetID]; duplicate {
return nil, responsecheck.Error("sheet/get_all_sheets", "duplicate_sheet_id", fmt.Sprintf("响应 sheets[%d] 的 sheetId 重复", index))
}
seen[sheetID] = struct{}{}
title, err := requireSheetStringAliases(item, index, "name", "title")
if err != nil {
return nil, err
}
if exactTitle != "" && title != exactTitle {
continue
}
row := map[string]any{}
if v, ok := listSheetsFirst(m, "sheetId", "sheet_id", "id"); ok {
row["sheetId"] = v
row := map[string]any{"sheetId": sheetID, "title": title}
if err := copySheetOptionalNumber(row, item, index, "index", "index", "sheetIndex"); err != nil {
return nil, err
}
if v, ok := listSheetsFirst(m, "title", "name", "sheetName", "sheet_name"); ok {
row["title"] = v
if err := copySheetOptionalVisibility(row, item, index, "visibility", "visibility", "hidden", "visible"); err != nil {
return nil, err
}
if v, ok := listSheetsFirst(m, "index", "sheetIndex"); ok {
row["index"] = v
if err := copySheetOptionalNumber(row, item, index, "rowCount", "rowCount", "row_count", "rows"); err != nil {
return nil, err
}
if v, ok := listSheetsFirst(m, "visibility", "hidden", "visible"); ok {
row["visibility"] = v
}
if v, ok := listSheetsFirst(m, "rowCount", "row_count", "rows"); ok {
row["rowCount"] = v
}
if v, ok := listSheetsFirst(m, "columnCount", "column_count", "columns", "colCount"); ok {
row["columnCount"] = v
}
if len(row) > 0 {
out = append(out, row)
if err := copySheetOptionalNumber(row, item, index, "columnCount", "columnCount", "column_count", "columns", "colCount"); err != nil {
return nil, err
}
out = append(out, row)
}
return out
}
// listSheetsResolveList locates the worksheet array inside the response,
// tolerating a bare top-level array container or nesting one level deeper under
// a common envelope key.
func listSheetsResolveList(data map[string]any) []any {
if data == nil {
return []any{}
}
for _, key := range []string{"sheets", "result", "data", "list", "items", "worksheets"} {
v, ok := data[key]
if !ok {
continue
}
if arr, ok := v.([]any); ok {
return arr
}
if inner, ok := v.(map[string]any); ok {
for _, ik := range []string{"sheets", "list", "items", "worksheets", "result", "data"} {
if arr, ok := inner[ik].([]any); ok {
return arr
}
}
}
}
return []any{}
}
// listSheetsFirst returns the first present candidate key's value.
func listSheetsFirst(m map[string]any, keys ...string) (any, bool) {
for _, k := range keys {
if v, ok := m[k]; ok {
return v, true
}
}
return nil, false
return out, nil
}
// SheetInfo returns the detail of a single worksheet.
@@ -157,12 +154,13 @@ func listSheetsFirst(m map[string]any, keys ...string) (any, bool) {
// Read reads structured per-cell data from a worksheet range.
var Read = shortcut.Shortcut{
Service: "sheet",
Command: "+read",
Product: "sheet",
Description: "读取工作表指定范围的结构化单元格数据",
Intent: "当你需要按单元格逐格获取数据(含类型、公式或格式化值等结构化信息)以便程序处理时使用;传入表格与可选范围(A1 表示法,不传则全部),可指定取格式化值/原始值/公式,返回结构化单元格数组。若只想要纯文本可改用 +csv-get。",
Risk: shortcut.RiskRead,
OutputRollout: output.RolloutUnifiedActive,
Service: "sheet",
Command: "+read",
Product: "sheet",
Description: "完整读取并严格校验在线电子表格范围;截断结果失败关闭",
Intent: "需要逐格读取在线电子表格的值、公式或格式化值,并要求结果未被服务端截断时",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
@@ -175,26 +173,57 @@ var Read = shortcut.Shortcut{
CLIPath: "sheet +read",
PrimaryCLIPath: "sheet +read",
},
Description: "读取工作表指定范围的结构化单元格数据",
Description: "完整读取并严格校验工作表范围",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
},
Selection: contract.SelectionSpec{
AgentSummary: "读取工作表指定范围的结构化单元格数据",
UseWhen: []string{"当你需要按单元格逐格获取数据(含类型、公式或格式化值等结构化信息)以便程序处理时使用;传入表格与可选范围(A1 表示法,不传则全部),可指定取格式化值/原始值/公式,返回结构化单元格数组。若只想要纯文本可改用 +csv-get。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
AgentSummary: "完整读取并严格校验在线电子表格范围;截断结果失败关闭",
UseWhen: []string{"需要逐格读取在线电子表格的值、公式或格式化值,并要求结果未被服务端截断时"},
AvoidWhen: []string{"需要纯 CSV 时用 sheet csv-get;需要 AITable/Base 结构化记录时用 aitable;超大范围应拆小后重试,不能把截断结果当完整成功"},
Examples: []string{"dws sheet +read --node NODE_ID --sheet-id SHEET_ID --range \"A1:D10\""},
},
Parameters: []contract.ParamDecl{
{Name: "node", Property: "node"},
{Name: "sheet-id", Property: "sheetId"},
{Name: "range", Property: "range"},
{Name: "value-render-option", Property: "valueRenderOption"},
},
DryRun: &contract.DryRunSpec{
PreviewKind: contract.DryRunPreviewRequest,
RemoteReads: false,
},
Result: &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: json.RawMessage(`{"type":"object","description":"完整且未截断的单元格范围","properties":{"cells":{"type":"array","description":"与行列坐标对齐的二维单元格对象数组","items":{"type":"array","description":"一行单元格对象","items":{"type":"object","description":"单元格结构化数据","additionalProperties":true}}},"colIndices":{"type":"array","description":"与 cells 列对齐的 A1 列标","items":{"type":"string"}},"rowIndices":{"type":"array","description":"与 cells 行对齐的一基行号","items":{"type":"integer"}},"complete":{"type":"boolean","description":"结果已证明完整;成功结果恒为 true"},"hasMore":{"type":"boolean","description":"服务端截断标记;成功结果恒为 false"},"truncationReasons":{"type":"array","description":"截断原因;成功结果恒为空数组","items":{"type":"string"}},"resolvedRange":{"type":"string","description":"服务解析后的请求范围"},"returnedRange":{"type":"string","description":"服务实际返回的范围"},"message":{"type":"string","description":"可选服务说明"}},"required":["cells","colIndices","rowIndices","complete","hasMore","truncationReasons"],"additionalProperties":false}`),
SensitivePaths: []string{"cells"},
},
},
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "表格文档 ID 或 URL", Required: true},
{Name: "sheet-id", Type: shortcut.FlagString, Desc: "工作表 ID 或名称 (不传则第一个工作表)"},
{Name: "range", Type: shortcut.FlagString, Desc: "读取范围,A1 表示法 (不传则全部数据)"},
{Name: "node", Type: shortcut.FlagString, Desc: "表格文档 ID 或 URL;--node 去除空白后不能为空", Required: true},
{Name: "sheet-id", Type: shortcut.FlagString, Desc: "工作表 ID 或名称 (不传则第一个工作表);显式传入时去除空白后不能为空"},
{Name: "range", Type: shortcut.FlagString, Desc: "读取范围,A1 表示法 (不传则全部数据);显式传入时去除空白后不能为空"},
{Name: "value-render-option", Type: shortcut.FlagString, Desc: "取值模式", Enum: []string{"formatted_value", "raw_value", "formula"}},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintCustom, Flags: []string{"node"}, Description: "--node 去除空白后不能为空"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"sheet-id"}, Description: "--sheet-id 显式传入时去除空白后不能为空"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"range"}, Description: "--range 显式传入时去除空白后不能为空"},
},
Tips: []string{`dws sheet +read --node NODE_ID --sheet-id SHEET_ID --range "A1:D10"`},
Validate: func(rt *shortcut.RuntimeContext) error {
if err := validateSheetNode(rt); err != nil {
return err
}
for _, name := range []string{"sheet-id", "range"} {
if err := validateOptionalSheetString(rt, name); err != nil {
return err
}
}
return nil
},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{"nodeId": rt.Str("node")}
if rt.Changed("sheet-id") {
@@ -206,10 +235,204 @@ var Read = shortcut.Shortcut{
if rt.Changed("value-render-option") {
params["valueRenderOption"] = rt.Str("value-render-option")
}
return rt.CallMCP("get_cell_infos", params)
if rt.DryRun() {
return rt.CallMCP("get_cell_infos", params)
}
data, err := rt.CallMCPData("sheet", "get_cell_infos", params)
if err != nil {
return err
}
projected, err := projectSheetRead(data)
if err != nil {
return err
}
return rt.Output(projected)
},
}
func validateSheetNode(rt *shortcut.RuntimeContext) error {
if strings.TrimSpace(rt.Str("node")) == "" {
return apperrors.NewValidation("--node 去除空白后不能为空")
}
return nil
}
func validateOptionalSheetString(rt *shortcut.RuntimeContext, name string) error {
if rt.Changed(name) && strings.TrimSpace(rt.Str(name)) == "" {
return apperrors.NewValidation(fmt.Sprintf("--%s 显式传入时去除空白后不能为空", name))
}
return nil
}
func requireSheetString(item map[string]any, index int, key string) (string, error) {
value, present := item[key]
if !present {
return "", responsecheck.Error("sheet/get_all_sheets", "missing_sheet_identity", fmt.Sprintf("响应 sheets[%d] 缺少 %s", index, key))
}
text, ok := value.(string)
if !ok || strings.TrimSpace(text) == "" {
return "", responsecheck.Error("sheet/get_all_sheets", "malformed_sheet_identity", fmt.Sprintf("响应 sheets[%d].%s 必须是非空字符串", index, key))
}
return strings.TrimSpace(text), nil
}
func requireSheetStringAliases(item map[string]any, index int, keys ...string) (string, error) {
for _, key := range keys {
if _, present := item[key]; present {
return requireSheetString(item, index, key)
}
}
return "", responsecheck.Error("sheet/get_all_sheets", "missing_sheet_title", fmt.Sprintf("响应 sheets[%d] 缺少工作表标题", index))
}
func copySheetOptionalNumber(target, source map[string]any, itemIndex int, targetKey string, sourceKeys ...string) error {
for _, key := range sourceKeys {
if value, present := source[key]; present {
number, ok := value.(float64)
if !ok || number < 0 || math.Trunc(number) != number {
return responsecheck.Error("sheet/get_all_sheets", "malformed_sheet_field", fmt.Sprintf("响应 sheets[%d].%s 必须是非负整数", itemIndex, key))
}
target[targetKey] = value
return nil
}
}
return nil
}
func copySheetOptionalVisibility(target, source map[string]any, itemIndex int, targetKey string, sourceKeys ...string) error {
for _, key := range sourceKeys {
value, present := source[key]
if !present {
continue
}
switch typed := value.(type) {
case bool:
target[targetKey] = typed
case string:
if strings.TrimSpace(typed) == "" {
return responsecheck.Error("sheet/get_all_sheets", "malformed_sheet_field", fmt.Sprintf("响应 sheets[%d].%s 不能为空字符串", itemIndex, key))
}
target[targetKey] = strings.TrimSpace(typed)
default:
return responsecheck.Error("sheet/get_all_sheets", "malformed_sheet_field", fmt.Sprintf("响应 sheets[%d].%s 必须是布尔值或字符串", itemIndex, key))
}
return nil
}
return nil
}
func projectSheetRead(data map[string]any) (map[string]any, error) {
envelope, err := responsecheck.RequireSuccess(data, "sheet/get_cell_infos")
if err != nil {
return nil, err
}
cellsRaw, err := requireSheetArray(envelope, "cells")
if err != nil {
return nil, err
}
columnsRaw, err := requireSheetArray(envelope, "colIndices")
if err != nil {
return nil, err
}
rowsRaw, err := requireSheetArray(envelope, "rowIndices")
if err != nil {
return nil, err
}
columns := make([]any, len(columnsRaw))
for index, value := range columnsRaw {
column, ok := value.(string)
if !ok || strings.TrimSpace(column) == "" {
return nil, responsecheck.Error("sheet/get_cell_infos", "malformed_column_index", fmt.Sprintf("响应 colIndices[%d] 必须是非空字符串", index))
}
columns[index] = strings.TrimSpace(column)
}
rows := make([]any, len(rowsRaw))
for index, value := range rowsRaw {
number, ok := value.(float64)
if !ok || number < 1 || math.Trunc(number) != number {
return nil, responsecheck.Error("sheet/get_cell_infos", "malformed_row_index", fmt.Sprintf("响应 rowIndices[%d] 必须是正整数", index))
}
rows[index] = int(number)
}
if len(cellsRaw) != len(rows) {
return nil, responsecheck.Error("sheet/get_cell_infos", "row_count_mismatch", "响应 cells 行数与 rowIndices 数量不一致")
}
cells := make([]any, len(cellsRaw))
for rowIndex, rowValue := range cellsRaw {
row, ok := rowValue.([]any)
if !ok {
return nil, responsecheck.Error("sheet/get_cell_infos", "malformed_cell_row", fmt.Sprintf("响应 cells[%d] 必须是数组", rowIndex))
}
if len(row) != len(columns) {
return nil, responsecheck.Error("sheet/get_cell_infos", "column_count_mismatch", fmt.Sprintf("响应 cells[%d] 列数与 colIndices 数量不一致", rowIndex))
}
clean := make([]any, len(row))
for columnIndex, cellValue := range row {
cell, ok := cellValue.(map[string]any)
if !ok {
return nil, responsecheck.Error("sheet/get_cell_infos", "malformed_cell", fmt.Sprintf("响应 cells[%d][%d] 必须是对象", rowIndex, columnIndex))
}
clean[columnIndex] = cell
}
cells[rowIndex] = clean
}
hasMoreValue, present := envelope["hasMore"]
if !present {
return nil, responsecheck.Error("sheet/get_cell_infos", "missing_completion_evidence", "响应缺少 hasMore,无法证明读取完整")
}
hasMore, ok := hasMoreValue.(bool)
if !ok {
return nil, responsecheck.Error("sheet/get_cell_infos", "malformed_completion_evidence", "响应 hasMore 必须是布尔值")
}
reasonsRaw, err := requireSheetArray(envelope, "truncationReasons")
if err != nil {
return nil, err
}
reasons := make([]any, len(reasonsRaw))
for index, value := range reasonsRaw {
reason, ok := value.(string)
if !ok || strings.TrimSpace(reason) == "" {
return nil, responsecheck.Error("sheet/get_cell_infos", "malformed_truncation_reason", fmt.Sprintf("响应 truncationReasons[%d] 必须是非空字符串", index))
}
reasons[index] = strings.TrimSpace(reason)
}
if hasMore {
if len(reasons) == 0 {
return nil, responsecheck.Error("sheet/get_cell_infos", "missing_truncation_reason", "响应 hasMore=true 但缺少截断原因")
}
return nil, responsecheck.Error("sheet/get_cell_infos", "incomplete_result", "工作表读取结果被服务端截断;请缩小 --range 后重试,不能把部分数据当作完整成功")
}
if len(reasons) != 0 {
return nil, responsecheck.Error("sheet/get_cell_infos", "conflicting_completion_evidence", "响应 hasMore=false 但仍声明截断原因")
}
out := map[string]any{
"cells": cells, "colIndices": columns, "rowIndices": rows,
"complete": true, "hasMore": false, "truncationReasons": reasons,
}
for _, key := range []string{"resolvedRange", "returnedRange", "message"} {
if value, present := envelope[key]; present {
text, ok := value.(string)
if !ok {
return nil, responsecheck.Error("sheet/get_cell_infos", "malformed_optional_field", fmt.Sprintf("响应 %s 必须是字符串", key))
}
out[key] = text
}
}
return out, nil
}
func requireSheetArray(object map[string]any, key string) ([]any, error) {
value, present := object[key]
if !present {
return nil, responsecheck.Error("sheet/get_cell_infos", "missing_"+key, fmt.Sprintf("响应缺少 %s 数组", key))
}
array, ok := value.([]any)
if !ok {
return nil, responsecheck.Error("sheet/get_cell_infos", "malformed_"+key, fmt.Sprintf("响应 %s 必须是数组", key))
}
return array, nil
}
// Write updates a worksheet range with a 2D JSON array of cell objects.
// Append appends rows to the end of a worksheet.
// CSVGet reads worksheet data as RFC 4180 CSV.
+342
View File
@@ -0,0 +1,342 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package sheet
import (
"context"
"errors"
"io"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
type sheetCoverageCaller struct {
responses map[string][]string
failures map[string]error
history []string
arguments []map[string]any
}
func (caller *sheetCoverageCaller) CallTool(_ context.Context, _, tool string, arguments map[string]any) (*edition.ToolResult, error) {
caller.history = append(caller.history, tool)
caller.arguments = append(caller.arguments, arguments)
if err := caller.failures[tool]; err != nil {
return nil, err
}
queue := caller.responses[tool]
if len(queue) == 0 {
return nil, errors.New("missing fake response for " + tool)
}
caller.responses[tool] = queue[1:]
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: queue[0]}}}, nil
}
func (*sheetCoverageCaller) Format() string { return "json" }
func (*sheetCoverageCaller) DryRun() bool { return false }
func (*sheetCoverageCaller) Fields() string { return "" }
func (*sheetCoverageCaller) JQ() string { return "" }
func runSheetCoverage(t *testing.T, declaration shortcut.Shortcut, caller *sheetCoverageCaller, args ...string) error {
t.Helper()
helpers.InitDepsForTest(t, caller)
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
root.PersistentFlags().Bool("yes", false, "")
root.PersistentFlags().Bool("dry-run", false, "")
root.PersistentFlags().String("format", "json", "")
ctx, _ := output.WithResultStore(context.Background())
root.SetContext(ctx)
service := &cobra.Command{Use: "sheet"}
service.AddCommand(corecmd.New(shortcut.FromShortcut(declaration)))
root.AddCommand(service)
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(append([]string{"sheet", declaration.Command}, args...))
return root.Execute()
}
func TestCrossPlatformCoverageSheetListRejectsFalseEmptySuccess(t *testing.T) {
for name, payload := range map[string]map[string]any{
"empty response": {},
"missing success": {"sheets": []any{}},
"success false": {"success": false, "errorMessage": "denied"},
"missing collection": {"success": true},
"wrong collection type": {"success": true, "sheets": map[string]any{}},
"bad element": {"success": true, "sheets": []any{"bad"}},
"empty element": {"success": true, "sheets": []any{map[string]any{}}},
"missing stable id": {"success": true, "sheets": []any{map[string]any{"name": "Sheet1"}}},
"wrong stable id type": {"success": true, "sheets": []any{map[string]any{"sheetId": 1, "name": "Sheet1"}}},
"missing title": {"success": true, "sheets": []any{map[string]any{"sheetId": "s1"}}},
"duplicate stable id": {"success": true, "sheets": []any{map[string]any{"sheetId": "s1", "name": "One"}, map[string]any{"sheetId": "s1", "name": "Two"}}},
"wrong optional index": {"success": true, "sheets": []any{map[string]any{"sheetId": "s1", "name": "One", "sheetIndex": -1.0}}},
"wrong optional count": {"success": true, "sheets": []any{map[string]any{"sheetId": "s1", "name": "One", "rowCount": "many"}}},
"wrong column count": {"success": true, "sheets": []any{map[string]any{"sheetId": "s1", "name": "One", "colCount": 1.5}}},
"empty visibility": {"success": true, "sheets": []any{map[string]any{"sheetId": "s1", "name": "One", "visibility": " "}}},
"wrong visibility": {"success": true, "sheets": []any{map[string]any{"sheetId": "s1", "name": "One", "hidden": 1}}},
} {
t.Run(name, func(t *testing.T) {
if _, err := projectSheetList(payload, ""); err == nil {
t.Fatalf("payload unexpectedly accepted: %#v", payload)
}
})
}
payload := map[string]any{"success": true, "sheets": []any{
map[string]any{"sheetId": "s1", "name": "Alpha"},
map[string]any{"sheetId": "s2", "name": "Beta"},
}}
nonempty, err := projectSheetList(payload, "Alpha")
if err != nil || len(nonempty) != 1 || nonempty[0]["sheetId"] != "s1" {
t.Fatalf("known nonempty projection = %#v, err=%v", nonempty, err)
}
empty, err := projectSheetList(payload, "guaranteed-missing")
if err != nil || len(empty) != 0 {
t.Fatalf("guaranteed empty projection = %#v, err=%v", empty, err)
}
optionalPayload := map[string]any{"success": true, "sheets": []any{
map[string]any{
"sheetId": "s1", "title": "Alpha", "index": float64(0),
"hidden": true, "row_count": float64(5), "colCount": float64(2),
},
map[string]any{"sheetId": "s2", "title": "Beta", "visibility": " visible "},
}}
optional, err := projectSheetList(optionalPayload, "")
if err != nil || len(optional) != 2 {
t.Fatalf("optional field projection=%#v err=%v", optional, err)
}
if optional[0]["index"] != float64(0) || optional[0]["visibility"] != true || optional[0]["rowCount"] != float64(5) || optional[0]["columnCount"] != float64(2) {
t.Fatalf("numeric/bool optional projection=%#v", optional[0])
}
if optional[1]["visibility"] != "visible" {
t.Fatalf("string visibility projection=%#v", optional[1])
}
}
func TestCrossPlatformCoverageSheetReadRejectsMalformedAndIncompleteResults(t *testing.T) {
valid := func() map[string]any {
return map[string]any{
"success": true, "cells": []any{[]any{map[string]any{"value": "x"}}},
"colIndices": []any{"A"}, "rowIndices": []any{float64(1)},
"hasMore": false, "truncationReasons": []any{},
"resolvedRange": "A1:A1", "returnedRange": "A1:A1",
}
}
for name, mutate := range map[string]func(map[string]any){
"missing success": func(v map[string]any) { delete(v, "success") },
"success false": func(v map[string]any) { v["success"] = false },
"missing cells": func(v map[string]any) { delete(v, "cells") },
"wrong cells type": func(v map[string]any) { v["cells"] = map[string]any{} },
"bad row": func(v map[string]any) { v["cells"] = []any{"bad"} },
"bad cell": func(v map[string]any) { v["cells"] = []any{[]any{"bad"}} },
"missing columns": func(v map[string]any) { delete(v, "colIndices") },
"bad column": func(v map[string]any) { v["colIndices"] = []any{1} },
"missing rows": func(v map[string]any) { delete(v, "rowIndices") },
"bad row index": func(v map[string]any) { v["rowIndices"] = []any{1.5} },
"row count mismatch": func(v map[string]any) { v["rowIndices"] = []any{} },
"column count mismatch": func(v map[string]any) { v["cells"] = []any{[]any{}} },
"missing hasMore": func(v map[string]any) { delete(v, "hasMore") },
"wrong hasMore type": func(v map[string]any) { v["hasMore"] = "false" },
"truncated": func(v map[string]any) {
v["hasMore"] = true
v["truncationReasons"] = []any{"max_cells"}
},
"truncated without reason": func(v map[string]any) {
v["hasMore"] = true
v["truncationReasons"] = []any{}
},
"missing truncation reasons": func(v map[string]any) { delete(v, "truncationReasons") },
"wrong truncation reason": func(v map[string]any) { v["truncationReasons"] = []any{1} },
"contradictory completion": func(v map[string]any) { v["truncationReasons"] = []any{"max_cells"} },
"wrong optional field": func(v map[string]any) { v["message"] = true },
} {
t.Run(name, func(t *testing.T) {
payload := valid()
mutate(payload)
if _, err := projectSheetRead(payload); err == nil {
t.Fatalf("payload unexpectedly accepted: %#v", payload)
}
})
}
projected, err := projectSheetRead(valid())
if err != nil || projected["complete"] != true || projected["hasMore"] != false {
t.Fatalf("valid projection=%#v err=%v", projected, err)
}
}
func TestCrossPlatformCoverageSheetShortcutsValidateBeforeCallsAndUseExactTools(t *testing.T) {
noCall := &sheetCoverageCaller{responses: map[string][]string{}}
if err := runSheetCoverage(t, ListSheets, noCall); err == nil || len(noCall.history) != 0 {
t.Fatalf("missing node err=%v calls=%v", err, noCall.history)
}
for _, declaration := range []shortcut.Shortcut{ListSheets, Read} {
t.Run(declaration.Command+" blank node", func(t *testing.T) {
blank := &sheetCoverageCaller{responses: map[string][]string{}}
err := runSheetCoverage(t, declaration, blank, "--node", " ")
if err == nil || !strings.Contains(err.Error(), "--node") || len(blank.history) != 0 {
t.Fatalf("blank node err=%v calls=%v", err, blank.history)
}
})
}
for _, declaration := range []shortcut.Shortcut{ListSheets, Read} {
blankCommand := corecmd.New(shortcut.FromShortcut(declaration))
if err := blankCommand.Flags().Set("node", " "); err != nil {
t.Fatal(err)
}
err := declaration.Validate(shortcut.RuntimeContextForTest(blankCommand, declaration))
if err == nil || !strings.Contains(err.Error(), "--node") {
t.Fatalf("%s direct blank node validation=%v", declaration.Command, err)
}
}
for _, test := range []struct {
declaration shortcut.Shortcut
flag string
}{
{declaration: ListSheets, flag: "title"},
{declaration: Read, flag: "sheet-id"},
{declaration: Read, flag: "range"},
} {
t.Run(test.declaration.Command+" blank "+test.flag, func(t *testing.T) {
caller := &sheetCoverageCaller{responses: map[string][]string{}}
err := runSheetCoverage(t, test.declaration, caller, "--node", "node", "--"+test.flag, " ")
if err == nil || !strings.Contains(err.Error(), "--"+test.flag) || len(caller.history) != 0 {
t.Fatalf("blank --%s err=%v calls=%v", test.flag, err, caller.history)
}
})
}
listCaller := &sheetCoverageCaller{responses: map[string][]string{
"get_all_sheets": {`{"success":true,"sheets":[{"sheetId":"s1","name":"Alpha"}]}`},
}}
if err := runSheetCoverage(t, ListSheets, listCaller, "--node", "node", "--title", "Alpha"); err != nil {
t.Fatal(err)
}
if got := strings.Join(listCaller.history, ","); got != "get_all_sheets" {
t.Fatalf("list history=%q", got)
}
readCaller := &sheetCoverageCaller{responses: map[string][]string{
"get_cell_infos": {`{"success":true,"cells":[[{"value":"x"}]],"colIndices":["A"],"rowIndices":[1],"hasMore":false,"truncationReasons":[]}`},
}}
if err := runSheetCoverage(t, Read, readCaller,
"--node", "node", "--sheet-id", "sheet-1", "--range", "A1:A1", "--value-render-option", "raw_value"); err != nil {
t.Fatal(err)
}
if got := strings.Join(readCaller.history, ","); got != "get_cell_infos" {
t.Fatalf("read history=%q", got)
}
if len(readCaller.arguments) != 1 || readCaller.arguments[0]["nodeId"] != "node" || readCaller.arguments[0]["sheetId"] != "sheet-1" || readCaller.arguments[0]["range"] != "A1:A1" || readCaller.arguments[0]["valueRenderOption"] != "raw_value" {
t.Fatalf("read arguments=%#v", readCaller.arguments)
}
dryRun := &sheetCoverageCaller{responses: map[string][]string{}}
if err := runSheetCoverage(t, Read, dryRun,
"--node", "node", "--sheet-id", "sheet-1", "--range", "A1:A1", "--value-render-option", "raw_value", "--dry-run"); err != nil {
t.Fatalf("read dry-run: %v", err)
}
if len(dryRun.history) != 0 {
t.Fatalf("read dry-run reached remote calls=%v", dryRun.history)
}
readFailure := &sheetCoverageCaller{
responses: map[string][]string{},
failures: map[string]error{"get_cell_infos": errors.New("fixture transport failure")},
}
if err := runSheetCoverage(t, Read, readFailure, "--node", "node"); err == nil || len(readFailure.history) != 1 {
t.Fatalf("read transport err=%v calls=%v", err, readFailure.history)
}
incomplete := &sheetCoverageCaller{responses: map[string][]string{
"get_cell_infos": {`{"success":true,"cells":[[{"value":"x"}]],"colIndices":["A"],"rowIndices":[1],"hasMore":true,"truncationReasons":["max_cells"],"resolvedRange":"A1:A2","returnedRange":"A1:A1"}`},
}}
err := runSheetCoverage(t, Read, incomplete, "--node", "node")
if err == nil || !strings.Contains(err.Error(), "截断") || len(incomplete.history) != 1 {
t.Fatalf("incomplete err=%v calls=%v", err, incomplete.history)
}
}
func TestCrossPlatformCoverageSheetRuntimeRejectsEmptyMalformedAndWrongCollection(t *testing.T) {
for name, payload := range map[string]string{
"empty body": "",
"malformed json": "{",
"success string": `{"success":"true","sheets":[]}`,
"missing array": `{"success":true}`,
"wrong array type": `{"success":true,"sheets":{}}`,
"bad item": `{"success":true,"sheets":["bad"]}`,
} {
t.Run(name, func(t *testing.T) {
caller := &sheetCoverageCaller{responses: map[string][]string{"get_all_sheets": {payload}}}
err := runSheetCoverage(t, ListSheets, caller, "--node", "node")
if err == nil || len(caller.history) != 1 {
t.Fatalf("payload=%q err=%v calls=%v", payload, err, caller.history)
}
})
}
}
func TestCrossPlatformCoverageSheetPublicContractsAreReviewedAndUnified(t *testing.T) {
for _, declaration := range []shortcut.Shortcut{ListSheets, Read} {
if declaration.Contract.Empty() || declaration.Contract.Result == nil {
t.Errorf("%s missing Contract.Result", declaration.Command)
}
if declaration.Contract.Pagination != nil {
t.Errorf("%s publishes unsupported pagination", declaration.Command)
}
if declaration.OutputRollout != output.RolloutUnifiedActive {
t.Errorf("%s rollout=%q", declaration.Command, declaration.OutputRollout)
}
if declaration.Safety.Confirmation != "not_required" || declaration.Safety.Effect != "read" {
t.Errorf("%s safety=%+v", declaration.Command, declaration.Safety)
}
}
if Read.Contract.DryRun == nil || Read.Contract.DryRun.PreviewKind != contract.DryRunPreviewRequest || Read.Contract.DryRun.RemoteReads {
t.Errorf("read dry-run contract=%+v", Read.Contract.DryRun)
}
for _, test := range []struct {
declaration shortcut.Shortcut
wantProperties map[string]string
}{
{declaration: ListSheets, wantProperties: map[string]string{"node": "node", "title": "title"}},
{declaration: Read, wantProperties: map[string]string{"node": "node", "sheet-id": "sheetId", "range": "range", "value-render-option": "valueRenderOption"}},
} {
got := make(map[string]string, len(test.declaration.Contract.Parameters))
for _, parameter := range test.declaration.Contract.Parameters {
got[parameter.Name] = parameter.Property
}
if !reflect.DeepEqual(got, test.wantProperties) {
t.Errorf("%s parameter properties=%v, want %v", test.declaration.Command, got, test.wantProperties)
}
}
for _, test := range []struct {
declaration shortcut.Shortcut
sensitive []string
constraints []string
}{
{declaration: ListSheets, sensitive: []string{"sheets.sheetId", "sheets.title"}, constraints: []string{"node", "title"}},
{declaration: Read, sensitive: []string{"cells"}, constraints: []string{"node", "sheet-id", "range"}},
} {
if !reflect.DeepEqual(test.declaration.Contract.Result.SensitivePaths, test.sensitive) {
t.Errorf("%s sensitive paths=%v, want %v", test.declaration.Command, test.declaration.Contract.Result.SensitivePaths, test.sensitive)
}
gotConstraints := make([]string, 0, len(test.declaration.Constraints))
for _, constraint := range test.declaration.Constraints {
if constraint.Kind != shortcut.ConstraintCustom || len(constraint.Flags) != 1 || !strings.Contains(constraint.Description, "--"+constraint.Flags[0]) {
t.Errorf("%s malformed custom constraint=%+v", test.declaration.Command, constraint)
continue
}
gotConstraints = append(gotConstraints, constraint.Flags[0])
}
if !reflect.DeepEqual(gotConstraints, test.constraints) {
t.Errorf("%s custom constraint flags=%v, want %v", test.declaration.Command, gotConstraints, test.constraints)
}
}
}
+837
View File
@@ -0,0 +1,837 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
// Package whiteboard declares strict Shortcut adapters for DingTalk document
// whiteboards. The document card remains owned by doc; this package only reads
// and updates the OpenNodes content of an already identified whiteboard part.
package whiteboard
import (
"bytes"
"encoding/json"
"fmt"
"io"
"math"
"math/big"
"strconv"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/responsecheck"
)
const (
serverWhiteboard = "whiteboard"
toolQuery = "read_whiteboard_content"
toolUpdate = "update_whiteboard"
)
var whiteboardMarshalNodes = json.Marshal
type updateFile struct {
Overwrite bool `json:"overwrite"`
Source *openSource `json:"source"`
}
type openSource struct {
SchemaVersion string `json:"schemaVersion"`
CatalogVersion string `json:"catalogVersion"`
Nodes json.RawMessage `json:"nodes"`
}
type parsedUpdate struct {
Overwrite bool
NodesJSON string
Nodes []map[string]any
}
func whiteboardReadSafety() contract.SafetySpec {
return contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
}
}
func whiteboardWriteSafety() contract.SafetySpec {
return contract.SafetySpec{
Effect: "write", Risk: "high",
Confirmation: "user_required", Idempotency: "unknown",
}
}
func queryResultSpec() *contract.ResultSpec {
return &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: json.RawMessage(`{
"type":"object",
"description":"严格校验且绑定到同一文档与白板 part 的 OpenNodes 快照",
"properties":{
"nodeId":{"type":"string","description":"承载白板的稳定文档身份"},
"partId":{"type":"string","description":"文档内白板的稳定 part 身份"},
"source":{"type":"object","description":"显式 OpenNodes V1 快照,包含 pages 以及每页的 nodes 数组","additionalProperties":true},
"summary":{"type":"object","description":"服务端完整性计数、字节数与摘要证据","additionalProperties":true},
"message":{"type":"string","description":"可选服务说明"}
},
"required":["nodeId","partId","source","summary"],
"additionalProperties":false
}`),
SensitivePaths: []string{"nodeId", "partId", "source.pages.nodes.id"},
}
}
func updateResultSpec() *contract.ResultSpec {
return &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: json.RawMessage(`{
"type":"object",
"description":"白板更新回执与同一稳定目标的精确读回验证",
"properties":{
"nodeId":{"type":"string","description":"承载白板的稳定文档身份"},
"partId":{"type":"string","description":"文档内白板的稳定 part 身份"},
"mode":{"type":"string","description":"实际执行的 append 或 overwrite 模式"},
"verified":{"type":"boolean","description":"远端更新成功并完成独立读回时为 true;dry-run 请求预览为 false"},
"verifiedNodeCount":{"type":"integer","description":"按请求稳定节点身份读回验证的节点数"},
"source":{"type":"object","description":"更新后的严格 OpenNodes V1 快照","additionalProperties":true},
"summary":{"type":"object","description":"更新后快照的完整性计数与摘要证据","additionalProperties":true},
"receipt":{"type":"object","description":"显式 success=true 的下游写回执;最终成功仍以独立读回为准","additionalProperties":true}
},
"required":["nodeId","partId","mode","verified","verifiedNodeCount","source","summary","receipt"],
"additionalProperties":false
}`),
SensitivePaths: []string{"nodeId", "partId", "source.pages.nodes.id", "receipt.resultJson.createdNodeIds", "receipt.resultJson.idMap"},
}
}
func whiteboardContract(command, name, description, interfaceReason string, result *contract.ResultSpec, dryRun *contract.DryRunSpec, params []contract.ParamDecl, useWhen, avoidWhen, example string) corecmd.ContractDecl {
path := "whiteboard " + command
return corecmd.ContractDecl{
Identity: contract.ToolIdentitySpec{
ProductID: "whiteboard", Name: name, CanonicalPath: "whiteboard." + name,
CLIPath: path, PrimaryCLIPath: path,
},
Description: description,
Interface: &contract.InterfaceSpec{
Mode: contract.InterfaceModeComposite,
Availability: contract.InterfaceAvailable,
Reason: interfaceReason,
},
Selection: contract.SelectionSpec{
AgentSummary: description,
UseWhen: []string{useWhen},
AvoidWhen: []string{avoidWhen},
Examples: []string{example},
},
Parameters: params,
Result: result,
DryRun: dryRun,
}
}
// Query reads one already identified DingTalk document whiteboard.
var Query = shortcut.Shortcut{
OutputRollout: output.RolloutUnifiedActive,
Service: "whiteboard",
Command: "+query",
Product: serverWhiteboard,
Description: "严格读取已有文档白板的 OpenNodes 快照",
Intent: "已知文档 nodeId 与白板 partId,需要读取节点、页面和服务端完整性摘要,并拒绝未知或畸形结果时",
Risk: shortcut.RiskRead,
Safety: whiteboardReadSafety(),
Contract: whiteboardContract(
"+query", "shortcut_query", "严格读取已有文档白板的 OpenNodes 快照",
"Reviewed composite adapter validates the whiteboard success envelope, every explicit pages[].nodes collection, stable page/node identities, and summary completeness before unified output.",
queryResultSpec(), nil,
[]contract.ParamDecl{
{Name: "node", Property: "nodeId"},
{Name: "part-id", Property: "partId"},
},
"已知文档 nodeId 与白板 partId,需要读取节点、页面和服务端完整性摘要,并拒绝未知或畸形结果时",
"创建白板卡片路由到 doc whiteboard insert;Lark 风格 preview/SVG/source 导出当前不可由本命令替代",
"dws whiteboard +query --node <DOC_ID> --part-id <WHITEBOARD_PART_ID>",
),
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "承载白板的钉钉文档 ID 或 URL;--node 去除空白后不能为空", Required: true},
{Name: "part-id", Type: shortcut.FlagString, Desc: "文档内白板 part ID;--part-id 去除空白后不能为空", Required: true},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintCustom, Flags: []string{"node"}, Description: "--node 去除空白后不能为空"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"part-id"}, Description: "--part-id 去除空白后不能为空"},
},
Tips: []string{"dws whiteboard +query --node <DOC_ID> --part-id <WHITEBOARD_PART_ID>"},
Validate: func(rt *shortcut.RuntimeContext) error {
return validateWhiteboardTarget(rt)
},
Execute: func(rt *shortcut.RuntimeContext) error {
data, err := rt.CallMCPData(serverWhiteboard, toolQuery, whiteboardTarget(rt))
if err != nil {
return err
}
projected, err := projectWhiteboardQuery(data, rt.Str("node"), rt.Str("part-id"))
if err != nil {
return err
}
return rt.Output(projected)
},
}
// Update appends or overwrites verified OpenNodes on one stable whiteboard.
var Update = shortcut.Shortcut{
OutputRollout: output.RolloutUnifiedActive,
Service: "whiteboard",
Command: "+update",
Product: serverWhiteboard,
Description: "确认后更新白板并按同一稳定目标精确读回",
Intent: "已有合规 OpenNodes V1 内容,用户确认后要 append 或 overwrite,并要求按请求节点身份验证同一白板读回时",
Risk: shortcut.RiskHighWrite,
Safety: whiteboardWriteSafety(),
Contract: whiteboardContract(
"+update", "shortcut_update", "确认后更新白板并按同一稳定目标精确读回",
"Reviewed composite adapter validates OpenNodes locally, requires confirmation, verifies the terminal receipt and request-to-real ID mapping, then reads the same target back exactly.",
updateResultSpec(), &contract.DryRunSpec{PreviewKind: contract.DryRunPreviewRequest, RemoteReads: false},
[]contract.ParamDecl{
{Name: "node", Property: "nodeId"},
{Name: "part-id", Property: "partId"},
{Name: "source", Property: "source"},
},
"已有合规 OpenNodes V1 内容,用户确认后要 append 或 overwrite,并要求按请求节点身份验证同一白板读回时",
"只读使用 whiteboard +query;创建卡片使用 doc whiteboard insert;Mermaid、PlantUML、SVG 和真实节点局部更新当前不可用",
`dws whiteboard +update --node <DOC_ID> --part-id <WHITEBOARD_PART_ID> --source '{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[{"id":"sample-shape","type":"shape","x":40,"y":40,"width":120,"height":80,"geometry":"dml:roundRect"}]}}'`,
),
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "承载白板的钉钉文档 ID 或 URL;--node 去除空白后不能为空", Required: true},
{Name: "part-id", Type: shortcut.FlagString, Desc: "文档内白板 part ID;--part-id 去除空白后不能为空", Required: true},
{Name: "source", Type: shortcut.FlagString, Desc: "OpenNodes V1 JSON,不能为空;支持字面量、@相对文件或 - 从 stdin 读取", Required: true, Input: []string{"file", "stdin"}},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintCustom, Flags: []string{"node"}, Description: "--node 去除空白后不能为空"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"part-id"}, Description: "--part-id 去除空白后不能为空"},
{
Kind: shortcut.ConstraintCustom,
Flags: []string{"source"},
Description: "--source 不能为空且必须是单一 OpenNodes V1 对象;source.nodes 是含稳定唯一 id 和非空 type 的显式数组,append 模式至少一个节点",
},
},
Tips: []string{"dws whiteboard +update --node <DOC_ID> --part-id <WHITEBOARD_PART_ID> --source @whiteboard.json"},
Validate: func(rt *shortcut.RuntimeContext) error {
if err := validateWhiteboardTarget(rt); err != nil {
return err
}
_, err := parseWhiteboardSource(rt.Str("source"))
return err
},
Execute: func(rt *shortcut.RuntimeContext) error {
parsed, err := parseWhiteboardSource(rt.Str("source"))
if err != nil {
return err
}
mode := "append"
if parsed.Overwrite {
mode = "overwrite"
}
target := whiteboardTarget(rt)
request := map[string]any{
"nodeId": target["nodeId"], "partId": target["partId"],
"mode": mode, "nodes": parsed.NodesJSON,
}
if rt.DryRun() {
return rt.Output(map[string]any{
"nodeId": target["nodeId"], "partId": target["partId"], "mode": mode,
"verified": false, "verifiedNodeCount": 0,
"source": map[string]any{"schemaVersion": "1.0", "catalogVersion": "dml-v1", "nodes": parsed.Nodes, "pages": []any{}},
"summary": map[string]any{"preview": true}, "receipt": map[string]any{"dryRun": true, "executed": false},
})
}
receiptData, err := rt.CallMCPWriteDataStrict(serverWhiteboard, toolUpdate, request)
if err != nil {
return err
}
receipt, err := requireWhiteboardUpdateReceipt(receiptData, target, mode, parsed)
if err != nil {
return err
}
readback, err := rt.CallMCPData(serverWhiteboard, toolQuery, target)
if err != nil {
return err
}
projected, err := projectWhiteboardQuery(readback, rt.Str("node"), rt.Str("part-id"))
if err != nil {
return err
}
if err := verifyWhiteboardUpdate(parsed, projected, receipt.IDMap); err != nil {
return err
}
return rt.Output(map[string]any{
"nodeId": target["nodeId"], "partId": target["partId"], "mode": mode,
"verified": true, "verifiedNodeCount": len(parsed.Nodes),
"source": projected["source"], "summary": projected["summary"], "receipt": receipt.Envelope,
})
},
}
func validateWhiteboardTarget(rt *shortcut.RuntimeContext) error {
if strings.TrimSpace(rt.Str("node")) == "" {
return apperrors.NewValidation("--node 去除空白后不能为空")
}
if strings.TrimSpace(rt.Str("part-id")) == "" {
return apperrors.NewValidation("--part-id 去除空白后不能为空")
}
return nil
}
func whiteboardTarget(rt *shortcut.RuntimeContext) map[string]any {
return map[string]any{"nodeId": rt.Str("node"), "partId": rt.Str("part-id")}
}
func parseWhiteboardSource(raw string) (*parsedUpdate, error) {
if strings.TrimSpace(raw) == "" {
return nil, apperrors.NewValidation("--source 必须是非空 OpenNodes V1 JSON")
}
decoder := json.NewDecoder(strings.NewReader(raw))
decoder.DisallowUnknownFields()
decoder.UseNumber()
var input updateFile
if err := decoder.Decode(&input); err != nil {
return nil, apperrors.NewValidation(fmt.Sprintf("--source 不是合法 OpenNodes V1 JSON: %v", err))
}
if err := requireJSONEOF(decoder); err != nil {
return nil, apperrors.NewValidation(fmt.Sprintf("--source 不是单一 JSON 对象: %v", err))
}
if input.Source == nil {
return nil, apperrors.NewValidation("--source 缺少 source 对象")
}
if input.Source.SchemaVersion != "1.0" {
return nil, apperrors.NewValidation(`--source source.schemaVersion 必须为 "1.0"`)
}
if input.Source.CatalogVersion != "dml-v1" {
return nil, apperrors.NewValidation(`--source source.catalogVersion 必须为 "dml-v1"`)
}
nodes, err := decodeNodeArray(input.Source.Nodes, "--source source.nodes")
if err != nil {
return nil, err
}
if !input.Overwrite && len(nodes) == 0 {
return nil, apperrors.NewValidation("append 模式至少需要一个 source.nodes 节点")
}
encoded, err := whiteboardMarshalNodes(nodes)
if err != nil {
return nil, apperrors.NewInternal(fmt.Sprintf("编码 OpenNodes 请求失败: %v", err))
}
return &parsedUpdate{Overwrite: input.Overwrite, NodesJSON: string(encoded), Nodes: nodes}, nil
}
func decodeNodeArray(raw json.RawMessage, field string) ([]map[string]any, error) {
if len(raw) == 0 {
return nil, apperrors.NewValidation(field + " 必须是显式数组")
}
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.UseNumber()
var values []any
if err := decoder.Decode(&values); err != nil {
return nil, apperrors.NewValidation(fmt.Sprintf("%s 必须是数组: %v", field, err))
}
if values == nil {
return nil, apperrors.NewValidation(field + " 不能为 null")
}
nodes := make([]map[string]any, len(values))
seen := make(map[string]struct{}, len(values))
for index, value := range values {
node, ok := value.(map[string]any)
if !ok || len(node) == 0 {
return nil, apperrors.NewValidation(fmt.Sprintf("%s[%d] 必须是非空对象", field, index))
}
id, ok := nonEmptyString(node["id"])
if !ok {
return nil, apperrors.NewValidation(fmt.Sprintf("%s[%d].id 必须是非空稳定身份", field, index))
}
if _, duplicate := seen[id]; duplicate {
return nil, apperrors.NewValidation(fmt.Sprintf("%s 含重复节点 id %q", field, id))
}
seen[id] = struct{}{}
if _, ok := nonEmptyString(node["type"]); !ok {
return nil, apperrors.NewValidation(fmt.Sprintf("%s[%d].type 必须是非空字符串", field, index))
}
nodes[index] = node
}
return nodes, nil
}
func projectWhiteboardQuery(data map[string]any, nodeID, partID string) (map[string]any, error) {
envelope, err := requireWhiteboardSuccess(data, toolQuery)
if err != nil {
return nil, err
}
value, present := envelope["resultJson"]
if !present || value == nil {
return nil, responsecheck.Error(serverWhiteboard+"/"+toolQuery, "missing_result_json", "成功响应缺少非空 resultJson")
}
result, err := decodeResultJSON(value)
if err != nil {
return nil, err
}
if version, ok := nonEmptyString(result["schemaVersion"]); !ok || version != "1.0" {
return nil, responsecheck.Error(serverWhiteboard+"/"+toolQuery, "invalid_schema_version", `resultJson.schemaVersion 必须为 "1.0"`)
}
if version, ok := nonEmptyString(result["catalogVersion"]); !ok || version != "dml-v1" {
return nil, responsecheck.Error(serverWhiteboard+"/"+toolQuery, "invalid_catalog_version", `resultJson.catalogVersion 必须为 "dml-v1"`)
}
pagesValue, present := result["pages"]
if !present {
return nil, responsecheck.Error(serverWhiteboard+"/"+toolQuery, "missing_pages", "resultJson 缺少显式 pages 数组")
}
pages, nodes, err := validateWhiteboardPages(pagesValue)
if err != nil {
return nil, err
}
summaryValue, present := envelope["resultSummary"]
if !present {
return nil, responsecheck.Error(serverWhiteboard+"/"+toolQuery, "missing_result_summary", "成功响应缺少 resultSummary 完整性证据")
}
summary, ok := summaryValue.(map[string]any)
if !ok || len(summary) == 0 {
return nil, responsecheck.Error(serverWhiteboard+"/"+toolQuery, "malformed_result_summary", "resultSummary 必须是非空对象")
}
if err := validateSummary(summary, len(nodes), len(pages)); err != nil {
return nil, err
}
result["pages"] = mapsToAny(pages)
out := map[string]any{
"nodeId": strings.TrimSpace(nodeID), "partId": strings.TrimSpace(partID),
"source": result, "summary": summary,
}
if message, present := envelope["message"]; present {
text, ok := message.(string)
if !ok {
return nil, responsecheck.Error(serverWhiteboard+"/"+toolQuery, "malformed_message", "响应 message 必须是字符串")
}
out["message"] = text
}
return out, nil
}
func requireWhiteboardSuccess(data map[string]any, tool string) (map[string]any, error) {
envelope, err := responsecheck.RequireSuccess(data, serverWhiteboard+"/"+tool)
if err != nil {
return nil, err
}
for _, key := range []string{"error", "errorMessage", "errorMsg"} {
if text, ok := envelope[key].(string); ok && strings.TrimSpace(text) != "" {
return nil, responsecheck.Error(serverWhiteboard+"/"+tool, "conflicting_error", "响应同时声明 success=true 与非空 "+key)
}
}
return envelope, nil
}
type verifiedUpdateReceipt struct {
Envelope map[string]any
Result map[string]any
IDMap map[string]string
}
func requireWhiteboardUpdateReceipt(data, target map[string]any, mode string, expected *parsedUpdate) (*verifiedUpdateReceipt, error) {
receipt, err := requireWhiteboardSuccess(data, toolUpdate)
if err != nil {
return nil, err
}
for _, field := range []string{"nodeId", "partId"} {
value, ok := nonEmptyString(receipt[field])
wanted, _ := target[field].(string)
if !ok || value != strings.TrimSpace(wanted) {
return nil, responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "receipt_target_mismatch", "写回执的 "+field+" 与请求稳定目标不一致")
}
}
value, present := receipt["resultJson"]
if !present || value == nil {
return nil, responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "missing_terminal_receipt", "成功写响应缺少非空 resultJson 终态回执;远端效果未知")
}
result, err := decodeResultJSONForTool(value, toolUpdate)
if err != nil {
return nil, err
}
receiptMode, ok := nonEmptyString(result["mode"])
if !ok || receiptMode != mode {
return nil, responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "receipt_mode_mismatch", "写回执 mode 与请求不一致")
}
message, ok := nonEmptyString(result["message"])
if !ok {
return nil, responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "missing_terminal_receipt", "成功写响应缺少非空 resultJson.message 终态回执;远端效果未知")
}
result["message"] = message
created, err := nonEmptyStringArray(result["createdNodeIds"], "resultJson.createdNodeIds")
if err != nil {
return nil, err
}
if len(created) != len(expected.Nodes) {
return nil, responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "receipt_count_mismatch", "createdNodeIds 数量与请求节点数不一致")
}
idMapValue, ok := result["idMap"].(map[string]any)
if !ok {
return nil, responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "malformed_id_map", "resultJson.idMap 必须是显式对象")
}
idMap := make(map[string]string, len(idMapValue))
for key, raw := range idMapValue {
requestID := strings.TrimSpace(key)
realID, valid := nonEmptyString(raw)
if requestID == "" || !valid {
return nil, responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "malformed_id_map", "resultJson.idMap 含空请求或真实节点身份")
}
idMap[requestID] = realID
}
if len(idMap) != len(expected.Nodes) {
return nil, responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "receipt_count_mismatch", "idMap 数量与请求节点数不一致")
}
for index, node := range expected.Nodes {
requestID, _ := nonEmptyString(node["id"])
if idMap[requestID] != created[index] {
return nil, responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "receipt_identity_mismatch", "idMap 没有按请求顺序精确映射 createdNodeIds")
}
}
deleted, ok := nonNegativeInt(result["deletedNodeCount"])
if !ok || (!expected.Overwrite && deleted != 0) {
return nil, responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "malformed_deleted_count", "deletedNodeCount 必须是非负整数,append 时必须为 0")
}
receipt["resultJson"] = result
return &verifiedUpdateReceipt{Envelope: receipt, Result: result, IDMap: idMap}, nil
}
func decodeResultJSON(value any) (map[string]any, error) {
return decodeResultJSONForTool(value, toolQuery)
}
func decodeResultJSONForTool(value any, tool string) (map[string]any, error) {
switch typed := value.(type) {
case map[string]any:
if len(typed) == 0 {
return nil, responsecheck.Error(serverWhiteboard+"/"+tool, "empty_result_json", "resultJson 对象为空")
}
return typed, nil
case string:
if strings.TrimSpace(typed) == "" {
return nil, responsecheck.Error(serverWhiteboard+"/"+tool, "empty_result_json", "resultJson 字符串为空")
}
decoder := json.NewDecoder(strings.NewReader(typed))
decoder.UseNumber()
var result map[string]any
if err := decoder.Decode(&result); err != nil {
return nil, responsecheck.Error(serverWhiteboard+"/"+tool, "invalid_result_json", fmt.Sprintf("resultJson 不是合法 JSON 对象: %v", err))
}
if err := requireJSONEOF(decoder); err != nil || len(result) == 0 {
return nil, responsecheck.Error(serverWhiteboard+"/"+tool, "invalid_result_json", "resultJson 必须是单一非空 JSON 对象")
}
return result, nil
default:
return nil, responsecheck.Error(serverWhiteboard+"/"+tool, "malformed_result_json", fmt.Sprintf("resultJson 应为对象或 JSON 字符串,实际为 %T", value))
}
}
func validateWhiteboardPages(value any) ([]map[string]any, []map[string]any, error) {
raw, ok := value.([]any)
if !ok {
return nil, nil, responsecheck.Error(serverWhiteboard+"/"+toolQuery, "malformed_collection", fmt.Sprintf("resultJson.pages 必须是数组,实际为 %T", value))
}
if len(raw) == 0 {
return nil, nil, responsecheck.Error(serverWhiteboard+"/"+toolQuery, "missing_page", "单页白板必须显式返回一个 page")
}
pages := make([]map[string]any, len(raw))
nodes := make([]map[string]any, 0)
pageIDs := make(map[string]struct{}, len(raw))
nodeIDs := make(map[string]struct{})
for pageIndex, item := range raw {
page, ok := item.(map[string]any)
if !ok || len(page) == 0 {
return nil, nil, responsecheck.Error(serverWhiteboard+"/"+toolQuery, "malformed_item", fmt.Sprintf("resultJson.pages[%d] 必须是非空对象", pageIndex))
}
pageID, ok := nonEmptyString(page["id"])
if !ok {
return nil, nil, responsecheck.Error(serverWhiteboard+"/"+toolQuery, "missing_page_identity", fmt.Sprintf("resultJson.pages[%d].id 必须是非空稳定身份", pageIndex))
}
if _, duplicate := pageIDs[pageID]; duplicate {
return nil, nil, responsecheck.Error(serverWhiteboard+"/"+toolQuery, "duplicate_page_identity", "resultJson.pages 含重复 page id")
}
pageIDs[pageID] = struct{}{}
nodesValue, present := page["nodes"]
if !present {
return nil, nil, responsecheck.Error(serverWhiteboard+"/"+toolQuery, "missing_nodes", fmt.Sprintf("resultJson.pages[%d] 缺少显式 nodes 数组", pageIndex))
}
pageNodes, err := whiteboardNodeArray(nodesValue, fmt.Sprintf("resultJson.pages[%d].nodes", pageIndex), nodeIDs)
if err != nil {
return nil, nil, err
}
page["id"] = pageID
page["nodes"] = mapsToAny(pageNodes)
pages[pageIndex] = page
nodes = append(nodes, pageNodes...)
}
return pages, nodes, nil
}
func whiteboardNodeArray(value any, field string, seen map[string]struct{}) ([]map[string]any, error) {
raw, ok := value.([]any)
if !ok {
return nil, responsecheck.Error(serverWhiteboard+"/"+toolQuery, "malformed_collection", fmt.Sprintf("%s 必须是数组,实际为 %T", field, value))
}
items := make([]map[string]any, len(raw))
for index, item := range raw {
object, ok := item.(map[string]any)
if !ok || len(object) == 0 {
return nil, responsecheck.Error(serverWhiteboard+"/"+toolQuery, "malformed_item", fmt.Sprintf("%s[%d] 必须是非空对象", field, index))
}
id, ok := nonEmptyString(object["id"])
if !ok {
return nil, responsecheck.Error(serverWhiteboard+"/"+toolQuery, "missing_node_identity", fmt.Sprintf("%s[%d].id 必须是非空稳定身份", field, index))
}
if _, duplicate := seen[id]; duplicate {
return nil, responsecheck.Error(serverWhiteboard+"/"+toolQuery, "duplicate_node_identity", "resultJson.pages 跨页含重复节点 id")
}
seen[id] = struct{}{}
if _, ok := nonEmptyString(object["type"]); !ok {
return nil, responsecheck.Error(serverWhiteboard+"/"+toolQuery, "missing_node_type", fmt.Sprintf("%s[%d].type 必须是非空字符串", field, index))
}
object["id"] = id
items[index] = object
}
return items, nil
}
func nonEmptyStringArray(value any, field string) ([]string, error) {
raw, ok := value.([]any)
if !ok {
return nil, responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "malformed_collection", field+" 必须是显式数组")
}
result := make([]string, len(raw))
seen := make(map[string]struct{}, len(raw))
for index, item := range raw {
id, ok := nonEmptyString(item)
if !ok {
return nil, responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "malformed_item", fmt.Sprintf("%s[%d] 必须是非空身份", field, index))
}
if _, duplicate := seen[id]; duplicate {
return nil, responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "duplicate_node_identity", field+" 含重复节点身份")
}
seen[id] = struct{}{}
result[index] = id
}
return result, nil
}
func validateSummary(summary map[string]any, nodeCount, pageCount int) error {
for key, expected := range map[string]int{"nodeCount": nodeCount, "pageCount": pageCount} {
value, ok := nonNegativeInt(summary[key])
if !ok || value != expected {
return responsecheck.Error(serverWhiteboard+"/"+toolQuery, "summary_count_mismatch", fmt.Sprintf("resultSummary.%s 必须等于显式数组长度 %d", key, expected))
}
}
readOnly, ok := nonNegativeInt(summary["readOnlyNodeCount"])
if !ok || readOnly > nodeCount {
return responsecheck.Error(serverWhiteboard+"/"+toolQuery, "malformed_read_only_count", "resultSummary.readOnlyNodeCount 必须是不超过 nodeCount 的非负整数")
}
unknown, ok := nonNegativeInt(summary["unknownNodeCount"])
if !ok || unknown > nodeCount {
return responsecheck.Error(serverWhiteboard+"/"+toolQuery, "malformed_unknown_count", "resultSummary.unknownNodeCount 必须是不超过 nodeCount 的非负整数")
}
if _, ok := nonNegativeInt(summary["resultBytes"]); !ok {
return responsecheck.Error(serverWhiteboard+"/"+toolQuery, "malformed_result_bytes", "resultSummary.resultBytes 必须是非负整数")
}
if _, ok := nonEmptyString(summary["resultSha256"]); !ok {
return responsecheck.Error(serverWhiteboard+"/"+toolQuery, "missing_result_digest", "resultSummary.resultSha256 必须是非空字符串")
}
return nil
}
func verifyWhiteboardUpdate(expected *parsedUpdate, projected map[string]any, idMap map[string]string) error {
source, ok := projected["source"].(map[string]any)
if !ok {
return responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "missing_readback_source", "更新后读回缺少 OpenNodes source")
}
_, readback, err := validateWhiteboardPages(source["pages"])
if err != nil {
return err
}
byID := make(map[string]map[string]any, len(readback))
for _, node := range readback {
id, _ := nonEmptyString(node["id"])
byID[id] = node
}
if expected.Overwrite && whiteboardPageOwnedNodeCount(readback) != len(expected.Nodes) {
return responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "overwrite_count_mismatch", "overwrite 后读回节点数量与请求不一致")
}
for _, requested := range expected.Nodes {
requestID, _ := nonEmptyString(requested["id"])
realID := idMap[requestID]
actual := byID[realID]
if actual == nil {
return responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "readback_identity_mismatch", fmt.Sprintf("更新后未按回执真实身份读回请求节点 %q", requestID))
}
requestedType, _ := nonEmptyString(requested["type"])
actualType, _ := nonEmptyString(actual["type"])
if requestedType != actualType {
return responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "readback_type_mismatch", fmt.Sprintf("节点 %q 的 type 读回不一致", requestID))
}
critical := make(map[string]any, len(requested)-1)
for key, value := range requested {
if key != "id" {
critical[key] = normalizeRequestedReadback(value, idMap, key)
}
}
if err := requireRequestedValue(critical, actual, "node "+requestID); err != nil {
return err
}
}
return nil
}
func normalizeRequestedReadback(value any, idMap map[string]string, field string) any {
switch typed := value.(type) {
case map[string]any:
normalized := make(map[string]any, len(typed))
requestScope, isRequestRef := typed["scope"].(string)
requestID, hasRequestID := nonEmptyString(typed["id"])
for key, child := range typed {
normalized[key] = normalizeRequestedReadback(child, idMap, key)
}
if isRequestRef && requestScope == "request" && hasRequestID {
if realID := idMap[requestID]; realID != "" {
normalized["scope"] = "document"
normalized["id"] = realID
}
}
return normalized
case []any:
normalized := make([]any, len(typed))
for index, child := range typed {
normalized[index] = normalizeRequestedReadback(child, idMap, field)
}
return normalized
case string:
if field == "parentId" {
if realID := idMap[typed]; realID != "" {
return realID
}
}
}
return value
}
func whiteboardPageOwnedNodeCount(nodes []map[string]any) int {
count := 0
for _, node := range nodes {
if source, ok := nonEmptyString(node["source"]); ok && source == "master" {
continue
}
count++
}
return count
}
func requireRequestedValue(expected, actual any, path string) error {
switch wanted := expected.(type) {
case map[string]any:
got, ok := actual.(map[string]any)
if !ok {
return responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "readback_field_mismatch", fmt.Sprintf("%s 读回类型不一致", path))
}
for key, value := range wanted {
readback, present := got[key]
if !present {
return responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "readback_field_missing", fmt.Sprintf("%s.%s 未读回", path, key))
}
if err := requireRequestedValue(value, readback, path+"."+key); err != nil {
return err
}
}
return nil
case []any:
got, ok := actual.([]any)
if !ok || len(got) != len(wanted) {
return responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "readback_field_mismatch", fmt.Sprintf("%s 数组读回不一致", path))
}
for index := range wanted {
if err := requireRequestedValue(wanted[index], got[index], fmt.Sprintf("%s[%d]", path, index)); err != nil {
return err
}
}
return nil
}
if expectedNumber, expectedOK := numericValue(expected); expectedOK {
actualNumber, actualOK := numericValue(actual)
if !actualOK || expectedNumber.Cmp(actualNumber) != 0 {
return responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "readback_field_mismatch", fmt.Sprintf("%s 数值读回不一致", path))
}
return nil
}
if expected != actual {
return responsecheck.Error(serverWhiteboard+"/"+toolUpdate, "readback_field_mismatch", fmt.Sprintf("%s 读回值不一致", path))
}
return nil
}
func numericValue(value any) (*big.Rat, bool) {
switch typed := value.(type) {
case json.Number:
parsed, ok := new(big.Rat).SetString(typed.String())
return parsed, ok
case float64:
if math.IsNaN(typed) || math.IsInf(typed, 0) {
return nil, false
}
return new(big.Rat).SetFloat64(typed), true
case int:
return new(big.Rat).SetInt64(int64(typed)), true
default:
return nil, false
}
}
func requireJSONEOF(decoder *json.Decoder) error {
var trailing any
if err := decoder.Decode(&trailing); err == nil {
return fmt.Errorf("multiple JSON values are not allowed")
} else if !errorsIsEOF(err) {
return err
}
return nil
}
func errorsIsEOF(err error) bool { return err == io.EOF }
func nonEmptyString(value any) (string, bool) {
text, ok := value.(string)
text = strings.TrimSpace(text)
return text, ok && text != ""
}
func nonNegativeInt(value any) (int, bool) {
switch typed := value.(type) {
case int:
return typed, typed >= 0
case float64:
if typed < 0 || typed != math.Trunc(typed) || math.IsNaN(typed) || math.IsInf(typed, 0) || typed > float64(math.MaxInt) {
return 0, false
}
return int(typed), true
case json.Number:
parsed, err := strconv.ParseInt(typed.String(), 10, 64)
if err != nil || parsed < 0 || parsed > int64(math.MaxInt) {
return 0, false
}
return int(parsed), true
default:
return 0, false
}
}
func mapsToAny(values []map[string]any) []any {
out := make([]any, len(values))
for index := range values {
out[index] = values[index]
}
return out
}
func init() {
shortcut.Register(Query, Update)
}
@@ -0,0 +1,709 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package whiteboard
import (
"context"
"encoding/json"
"errors"
"io"
"math"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
type whiteboardCoverageCall struct {
server string
tool string
args map[string]any
}
type whiteboardCoverageCaller struct {
responses map[string][]string
calls []whiteboardCoverageCall
dry bool
}
func (caller *whiteboardCoverageCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
caller.calls = append(caller.calls, whiteboardCoverageCall{server: server, tool: tool, args: args})
queue := caller.responses[tool]
if len(queue) == 0 {
return nil, errors.New("missing fake response for " + tool)
}
caller.responses[tool] = queue[1:]
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: queue[0]}}}, nil
}
func (*whiteboardCoverageCaller) Format() string { return "json" }
func (caller *whiteboardCoverageCaller) DryRun() bool { return caller.dry }
func (*whiteboardCoverageCaller) Fields() string { return "" }
func (*whiteboardCoverageCaller) JQ() string { return "" }
func runWhiteboardCoverage(t *testing.T, declaration shortcut.Shortcut, caller *whiteboardCoverageCaller, stdin string, args ...string) error {
t.Helper()
helpers.InitDepsForTest(t, caller)
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
root.PersistentFlags().Bool("yes", false, "")
root.PersistentFlags().Bool("dry-run", false, "")
root.PersistentFlags().String("format", "json", "")
ctx, _ := output.WithResultStore(context.Background())
root.SetContext(ctx)
root.SetIn(strings.NewReader(stdin))
root.SetOut(io.Discard)
root.SetErr(io.Discard)
service := &cobra.Command{Use: "whiteboard"}
service.AddCommand(corecmd.New(shortcut.FromShortcut(declaration)))
root.AddCommand(service)
root.SetArgs(append([]string{"whiteboard", declaration.Command}, args...))
return root.Execute()
}
func directWhiteboardRuntime(t *testing.T, declaration shortcut.Shortcut, caller *whiteboardCoverageCaller, args ...string) *shortcut.RuntimeContext {
t.Helper()
helpers.InitDepsForTest(t, caller)
cmd := corecmd.New(shortcut.FromShortcut(declaration))
ctx, _ := output.WithResultStore(context.Background())
cmd.SetContext(ctx)
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
if err := cmd.Flags().Parse(args); err != nil {
t.Fatal(err)
}
return shortcut.RuntimeContextForTest(cmd, declaration)
}
func validWhiteboardQueryResponse(nodes string) string {
var decoded []any
if err := json.Unmarshal([]byte(nodes), &decoded); err != nil {
panic(err)
}
result, err := json.Marshal(map[string]any{
"schemaVersion": "1.0", "catalogVersion": "dml-v1",
"pages": []any{map[string]any{"id": "page-1", "nodes": decoded}},
})
if err != nil {
panic(err)
}
envelope, err := json.Marshal(map[string]any{
"success": true, "resultJson": string(result),
"resultSummary": map[string]any{
"nodeCount": len(decoded), "pageCount": 1, "readOnlyNodeCount": 0,
"unknownNodeCount": 0, "resultBytes": len(result), "resultSha256": "0123456789abcdef",
},
})
if err != nil {
panic(err)
}
return string(envelope)
}
func validWhiteboardUpdateResponse(mode string, requestIDs, realIDs []string, deleted int) string {
idMap := make(map[string]any, len(requestIDs))
created := make([]any, len(realIDs))
for index := range requestIDs {
idMap[requestIDs[index]] = realIDs[index]
created[index] = realIDs[index]
}
result, err := json.Marshal(map[string]any{
"mode": mode, "createdNodeIds": created, "idMap": idMap,
"deletedNodeCount": deleted, "message": "completed",
})
if err != nil {
panic(err)
}
envelope, err := json.Marshal(map[string]any{
"success": true, "nodeId": "doc", "partId": "part", "resultJson": string(result),
})
if err != nil {
panic(err)
}
return string(envelope)
}
func TestCrossPlatformCoverageWhiteboardQueryRejectsFalseSuccessAndMalformedNodes(t *testing.T) {
broken := map[string]map[string]any{
"empty": {},
"missing success": {"resultJson": map[string]any{}},
"success false": {"success": false, "resultJson": map[string]any{}},
"success wrong type": {"success": "true", "resultJson": map[string]any{}},
"missing result": {"success": true},
"result wrong type": {"success": true, "resultJson": []any{}},
"result invalid string": {"success": true, "resultJson": "{"},
"missing pages": {"success": true, "resultJson": map[string]any{
"schemaVersion": "1.0", "catalogVersion": "dml-v1",
}},
"empty pages": {"success": true, "resultJson": map[string]any{
"schemaVersion": "1.0", "catalogVersion": "dml-v1", "pages": []any{},
}},
"missing page id": {"success": true, "resultJson": map[string]any{
"schemaVersion": "1.0", "catalogVersion": "dml-v1", "pages": []any{map[string]any{"nodes": []any{}}},
}},
"duplicate page id": {"success": true, "resultJson": map[string]any{
"schemaVersion": "1.0", "catalogVersion": "dml-v1", "pages": []any{
map[string]any{"id": "page", "nodes": []any{}}, map[string]any{"id": "page", "nodes": []any{}},
},
}},
"missing nodes": {"success": true, "resultJson": map[string]any{
"schemaVersion": "1.0", "catalogVersion": "dml-v1", "pages": []any{map[string]any{"id": "page"}},
}},
"nodes wrong type": {"success": true, "resultJson": map[string]any{
"schemaVersion": "1.0", "catalogVersion": "dml-v1", "pages": []any{map[string]any{"id": "page", "nodes": map[string]any{}}},
}},
"bad node": {"success": true, "resultJson": map[string]any{
"schemaVersion": "1.0", "catalogVersion": "dml-v1", "pages": []any{map[string]any{"id": "page", "nodes": []any{"bad"}}},
}},
"missing node id": {"success": true, "resultJson": map[string]any{
"schemaVersion": "1.0", "catalogVersion": "dml-v1", "pages": []any{map[string]any{"id": "page", "nodes": []any{map[string]any{"type": "text"}}}},
}},
"cross-page duplicate node id": {"success": true, "resultJson": map[string]any{
"schemaVersion": "1.0", "catalogVersion": "dml-v1", "pages": []any{
map[string]any{"id": "page-1", "nodes": []any{map[string]any{"id": "same", "type": "text"}}},
map[string]any{"id": "page-2", "nodes": []any{map[string]any{"id": "same", "type": "shape"}}},
},
}},
"missing summary": {"success": true, "resultJson": map[string]any{
"schemaVersion": "1.0", "catalogVersion": "dml-v1", "pages": []any{map[string]any{"id": "page", "nodes": []any{}}},
}},
"summary count conflict": {"success": true, "resultJson": map[string]any{
"schemaVersion": "1.0", "catalogVersion": "dml-v1", "pages": []any{map[string]any{"id": "page", "nodes": []any{}}},
}, "resultSummary": map[string]any{
"nodeCount": float64(1), "pageCount": float64(1), "readOnlyNodeCount": float64(0),
"unknownNodeCount": float64(0), "resultBytes": float64(2), "resultSha256": "hash",
}},
}
for name, payload := range broken {
t.Run(name, func(t *testing.T) {
if _, err := projectWhiteboardQuery(payload, "doc", "part"); err == nil {
t.Fatalf("payload unexpectedly accepted: %#v", payload)
}
})
}
explicitEmpty := map[string]any{
"success": true,
"resultJson": map[string]any{
"schemaVersion": "1.0", "catalogVersion": "dml-v1", "pages": []any{map[string]any{"id": "page", "nodes": []any{}}},
},
"resultSummary": map[string]any{
"nodeCount": float64(0), "pageCount": float64(1), "readOnlyNodeCount": float64(0),
"unknownNodeCount": float64(0), "resultBytes": float64(2), "resultSha256": "hash",
},
}
got, err := projectWhiteboardQuery(explicitEmpty, "doc", "part")
if err != nil || got["nodeId"] != "doc" || got["partId"] != "part" {
t.Fatalf("explicit empty result = %#v, err=%v", got, err)
}
source := got["source"].(map[string]any)
pages := source["pages"].([]any)
if len(pages) != 1 || len(pages[0].(map[string]any)["nodes"].([]any)) != 0 {
t.Fatalf("explicit nested empty was not preserved: %#v", source)
}
}
func TestCrossPlatformCoverageWhiteboardSourceValidationFailsClosed(t *testing.T) {
for name, source := range map[string]string{
"empty": "",
"invalid json": "{",
"trailing json": `{} {}`,
"missing source": `{}`,
"unknown top field": `{"extra":true}`,
"wrong schema": `{"source":{"schemaVersion":"2.0","catalogVersion":"dml-v1","nodes":[]}}`,
"wrong catalog": `{"source":{"schemaVersion":"1.0","catalogVersion":"v2","nodes":[]}}`,
"missing nodes": `{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1"}}`,
"nodes wrong type": `{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":{}}}`,
"append empty": `{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[]}}`,
"bad node": `{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[1]}}`,
"missing node id": `{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[{"type":"text"}]}}`,
"duplicate node id": `{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[{"id":"same","type":"text"},{"id":"same","type":"shape"}]}}`,
} {
t.Run(name, func(t *testing.T) {
if _, err := parseWhiteboardSource(source); err == nil {
t.Fatalf("source unexpectedly accepted: %q", source)
}
})
}
for _, source := range []string{
`{"overwrite":true,"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[]}}`,
`{"overwrite":false,"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[{"id":"n1","type":"text"}]}}`,
} {
if _, err := parseWhiteboardSource(source); err != nil {
t.Fatalf("valid source rejected: %v", err)
}
}
}
func TestCrossPlatformCoverageWhiteboardExactToolsConfirmationAndReadback(t *testing.T) {
queryCaller := &whiteboardCoverageCaller{responses: map[string][]string{
"read_whiteboard_content": {validWhiteboardQueryResponse(`[{"id":"n1","type":"text"}]`)},
}}
if err := runWhiteboardCoverage(t, Query, queryCaller, "", "--node", "doc", "--part-id", "part"); err != nil {
t.Fatal(err)
}
if len(queryCaller.calls) != 1 || queryCaller.calls[0].server != "whiteboard" || queryCaller.calls[0].tool != "read_whiteboard_content" {
t.Fatalf("query calls = %#v", queryCaller.calls)
}
source := `{"overwrite":false,"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[{"id":"n1","type":"text"}]}}`
declined := &whiteboardCoverageCaller{responses: map[string][]string{}}
if err := runWhiteboardCoverage(t, Update, declined, "", "--node", "doc", "--part-id", "part", "--source", source); err == nil {
t.Fatal("update without confirmation unexpectedly succeeded")
}
if len(declined.calls) != 0 {
t.Fatalf("calls before confirmation = %#v", declined.calls)
}
invalid := &whiteboardCoverageCaller{responses: map[string][]string{}}
if err := runWhiteboardCoverage(t, Update, invalid, "", "--node", "doc", "--part-id", "part", "--source", `{}`, "--yes"); err == nil {
t.Fatal("invalid update source unexpectedly succeeded")
}
if len(invalid.calls) != 0 {
t.Fatalf("invalid source reached remote calls = %#v", invalid.calls)
}
dryRun := &whiteboardCoverageCaller{dry: true, responses: map[string][]string{}}
if err := runWhiteboardCoverage(t, Update, dryRun, "", "--node", "doc", "--part-id", "part", "--source", source, "--dry-run"); err != nil {
t.Fatal(err)
}
if len(dryRun.calls) != 0 {
t.Fatalf("dry-run reached remote calls = %#v", dryRun.calls)
}
missingReceipt := &whiteboardCoverageCaller{responses: map[string][]string{
"update_whiteboard": {`{"success":true}`},
}}
if err := runWhiteboardCoverage(t, Update, missingReceipt, "", "--node", "doc", "--part-id", "part", "--source", source, "--yes"); err == nil {
t.Fatal("empty terminal receipt unexpectedly succeeded")
}
if len(missingReceipt.calls) != 1 {
t.Fatalf("missing receipt calls = %#v", missingReceipt.calls)
}
verified := &whiteboardCoverageCaller{responses: map[string][]string{
"update_whiteboard": {validWhiteboardUpdateResponse("append", []string{"n1"}, []string{"real-1"}, 0)},
"read_whiteboard_content": {validWhiteboardQueryResponse(`[{"id":"real-1","type":"text","source":"page"}]`)},
}}
if err := runWhiteboardCoverage(t, Update, verified, "", "--node", "doc", "--part-id", "part", "--source", source, "--yes"); err != nil {
t.Fatal(err)
}
if len(verified.calls) != 2 || verified.calls[0].tool != "update_whiteboard" || verified.calls[1].tool != "read_whiteboard_content" {
t.Fatalf("verified calls = %#v", verified.calls)
}
if verified.calls[0].args["nodeId"] != "doc" || verified.calls[0].args["partId"] != "part" || verified.calls[1].args["nodeId"] != "doc" || verified.calls[1].args["partId"] != "part" {
t.Fatalf("stable target identity was not preserved: %#v", verified.calls)
}
mismatch := &whiteboardCoverageCaller{responses: map[string][]string{
"update_whiteboard": {validWhiteboardUpdateResponse("append", []string{"n1"}, []string{"real-1"}, 0)},
"read_whiteboard_content": {validWhiteboardQueryResponse(`[{"id":"other","type":"text"}]`)},
}}
if err := runWhiteboardCoverage(t, Update, mismatch, "", "--node", "doc", "--part-id", "part", "--source", source, "--yes"); err == nil {
t.Fatal("readback mismatch unexpectedly succeeded")
}
if len(mismatch.calls) != 2 {
t.Fatalf("readback mismatch calls = %#v", mismatch.calls)
}
richSource := `{"overwrite":false,"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[{"id":"n1","type":"text","text":"wanted","style":{"fontSize":16},"points":[1,2]}]}}`
fieldMismatch := &whiteboardCoverageCaller{responses: map[string][]string{
"update_whiteboard": {validWhiteboardUpdateResponse("append", []string{"n1"}, []string{"real-1"}, 0)},
"read_whiteboard_content": {validWhiteboardQueryResponse(`[{"id":"real-1","type":"text","text":"different","style":{"fontSize":16},"points":[1,2]}]`)},
}}
if err := runWhiteboardCoverage(t, Update, fieldMismatch, "", "--node", "doc", "--part-id", "part", "--source", richSource, "--yes"); err == nil {
t.Fatal("request-critical field mismatch unexpectedly succeeded")
}
}
func TestCrossPlatformCoverageWhiteboardReceiptRejectsFalseSuccessAndBadIdentityEvidence(t *testing.T) {
source := `{"overwrite":false,"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[{"id":"n1","type":"text"}]}}`
for name, response := range map[string]string{
"missing target": `{"success":true,"resultJson":{"mode":"append","createdNodeIds":["real-1"],"idMap":{"n1":"real-1"},"deletedNodeCount":0,"message":"done"}}`,
"target mismatch": `{"success":true,"nodeId":"other","partId":"part","resultJson":{"mode":"append","createdNodeIds":["real-1"],"idMap":{"n1":"real-1"},"deletedNodeCount":0,"message":"done"}}`,
"missing collections": `{"success":true,"nodeId":"doc","partId":"part","resultJson":{"mode":"append","deletedNodeCount":0,"message":"done"}}`,
"created wrong type": `{"success":true,"nodeId":"doc","partId":"part","resultJson":{"mode":"append","createdNodeIds":{},"idMap":{"n1":"real-1"},"deletedNodeCount":0,"message":"done"}}`,
"id map wrong type": `{"success":true,"nodeId":"doc","partId":"part","resultJson":{"mode":"append","createdNodeIds":["real-1"],"idMap":[],"deletedNodeCount":0,"message":"done"}}`,
"identity disagreement": `{"success":true,"nodeId":"doc","partId":"part","resultJson":{"mode":"append","createdNodeIds":["real-1"],"idMap":{"n1":"real-2"},"deletedNodeCount":0,"message":"done"}}`,
"missing message": `{"success":true,"nodeId":"doc","partId":"part","resultJson":{"mode":"append","createdNodeIds":["real-1"],"idMap":{"n1":"real-1"},"deletedNodeCount":0}}`,
"append deleted nodes": `{"success":true,"nodeId":"doc","partId":"part","resultJson":{"mode":"append","createdNodeIds":["real-1"],"idMap":{"n1":"real-1"},"deletedNodeCount":1,"message":"done"}}`,
} {
t.Run(name, func(t *testing.T) {
caller := &whiteboardCoverageCaller{responses: map[string][]string{"update_whiteboard": {response}}}
if err := runWhiteboardCoverage(t, Update, caller, "", "--node", "doc", "--part-id", "part", "--source", source, "--yes"); err == nil {
t.Fatal("malformed terminal receipt unexpectedly succeeded")
}
if len(caller.calls) != 1 {
t.Fatalf("malformed receipt calls = %#v", caller.calls)
}
})
}
}
func TestCrossPlatformCoverageWhiteboardReadbackNormalizesRequestScopedReferences(t *testing.T) {
idMap := map[string]string{"frame": "real-frame", "left": "real-left"}
requested := map[string]any{
"parentId": "frame",
"start": map[string]any{
"type": "node",
"nodeRef": map[string]any{"scope": "request", "id": "left"},
},
}
actual := map[string]any{
"parentId": "real-frame",
"start": map[string]any{
"type": "node",
"nodeRef": map[string]any{"scope": "document", "id": "real-left"},
"resolvedPoint": map[string]any{"x": float64(1), "y": float64(2)},
},
}
normalized := normalizeRequestedReadback(requested, idMap, "node")
if err := requireRequestedValue(normalized, actual, "node connector"); err != nil {
t.Fatal(err)
}
}
func TestCrossPlatformCoverageWhiteboardPublicContractsStayStrictAndUnified(t *testing.T) {
for _, declaration := range []shortcut.Shortcut{Query, Update} {
if declaration.Contract.Empty() || declaration.Contract.Result == nil {
t.Errorf("%s missing Contract.Result", declaration.Command)
}
if declaration.Contract.Pagination != nil {
t.Errorf("%s publishes unsupported pagination", declaration.Command)
}
if declaration.OutputRollout != output.RolloutUnifiedActive {
t.Errorf("%s rollout=%q", declaration.Command, declaration.OutputRollout)
}
if declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != "available" || strings.TrimSpace(declaration.Contract.Interface.Reason) == "" {
t.Errorf("%s interface=%+v", declaration.Command, declaration.Contract.Interface)
}
}
if Query.Safety.Confirmation != "not_required" || Query.Safety.Effect != "read" {
t.Errorf("query safety=%+v", Query.Safety)
}
if Update.Safety.Confirmation != "user_required" || Update.Safety.Effect != "write" {
t.Errorf("update safety=%+v", Update.Safety)
}
}
func TestCrossPlatformCoverageWhiteboardExecutorErrorsAndExactCallOrder(t *testing.T) {
queryCallError := &whiteboardCoverageCaller{responses: map[string][]string{}}
if err := runWhiteboardCoverage(t, Query, queryCallError, "", "--node", "doc", "--part-id", "part"); err == nil || len(queryCallError.calls) != 1 {
t.Fatalf("query call error=%v calls=%#v", err, queryCallError.calls)
}
queryProjectionError := &whiteboardCoverageCaller{responses: map[string][]string{
toolQuery: {`{"success":true}`},
}}
if err := runWhiteboardCoverage(t, Query, queryProjectionError, "", "--node", "doc", "--part-id", "part"); err == nil || len(queryProjectionError.calls) != 1 {
t.Fatalf("query projection error=%v calls=%#v", err, queryProjectionError.calls)
}
invalidDirect := directWhiteboardRuntime(t, Update, &whiteboardCoverageCaller{responses: map[string][]string{}},
"--node", "doc", "--part-id", "part", "--source", `{}`)
if err := Update.Execute(invalidDirect); err == nil {
t.Fatal("direct update accepted invalid source")
}
overwriteSource := `{"overwrite":true,"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[]}}`
overwriteDryRun := &whiteboardCoverageCaller{dry: true, responses: map[string][]string{}}
if err := runWhiteboardCoverage(t, Update, overwriteDryRun, "", "--node", "doc", "--part-id", "part", "--source", overwriteSource, "--dry-run"); err != nil {
t.Fatal(err)
}
if len(overwriteDryRun.calls) != 0 {
t.Fatalf("overwrite dry-run calls=%#v", overwriteDryRun.calls)
}
appendSource := `{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[{"id":"n1","type":"text"}]}}`
writeCallError := &whiteboardCoverageCaller{responses: map[string][]string{}}
if err := runWhiteboardCoverage(t, Update, writeCallError, "", "--node", "doc", "--part-id", "part", "--source", appendSource, "--yes"); err == nil || len(writeCallError.calls) != 1 || writeCallError.calls[0].tool != toolUpdate {
t.Fatalf("write call error=%v calls=%#v", err, writeCallError.calls)
}
readCallError := &whiteboardCoverageCaller{responses: map[string][]string{
toolUpdate: {validWhiteboardUpdateResponse("append", []string{"n1"}, []string{"real-1"}, 0)},
}}
if err := runWhiteboardCoverage(t, Update, readCallError, "", "--node", "doc", "--part-id", "part", "--source", appendSource, "--yes"); err == nil || len(readCallError.calls) != 2 || readCallError.calls[1].tool != toolQuery {
t.Fatalf("read call error=%v calls=%#v", err, readCallError.calls)
}
readProjectionError := &whiteboardCoverageCaller{responses: map[string][]string{
toolUpdate: {validWhiteboardUpdateResponse("append", []string{"n1"}, []string{"real-1"}, 0)},
toolQuery: {`{"success":true}`},
}}
if err := runWhiteboardCoverage(t, Update, readProjectionError, "", "--node", "doc", "--part-id", "part", "--source", appendSource, "--yes"); err == nil || len(readProjectionError.calls) != 2 {
t.Fatalf("read projection error=%v calls=%#v", err, readProjectionError.calls)
}
typeMismatch := &whiteboardCoverageCaller{responses: map[string][]string{
toolUpdate: {validWhiteboardUpdateResponse("append", []string{"n1"}, []string{"real-1"}, 0)},
toolQuery: {validWhiteboardQueryResponse(`[{"id":"real-1","type":"shape"}]`)},
}}
if err := runWhiteboardCoverage(t, Update, typeMismatch, "", "--node", "doc", "--part-id", "part", "--source", appendSource, "--yes"); err == nil || len(typeMismatch.calls) != 2 {
t.Fatalf("type mismatch error=%v calls=%#v", err, typeMismatch.calls)
}
overwriteVerified := &whiteboardCoverageCaller{responses: map[string][]string{
toolUpdate: {validWhiteboardUpdateResponse("overwrite", nil, nil, 1)},
toolQuery: {validWhiteboardQueryResponse(`[{"id":"master-1","type":"shape","source":"master"}]`)},
}}
if err := runWhiteboardCoverage(t, Update, overwriteVerified, "", "--node", "doc", "--part-id", "part", "--source", overwriteSource, "--yes"); err != nil {
t.Fatal(err)
}
if len(overwriteVerified.calls) != 2 || overwriteVerified.calls[0].args["mode"] != "overwrite" || overwriteVerified.calls[1].tool != toolQuery {
t.Fatalf("overwrite call order=%#v", overwriteVerified.calls)
}
}
func TestCrossPlatformCoverageWhiteboardQueryEnvelopeSummaryAndMessageMatrix(t *testing.T) {
baseResult := func() map[string]any {
return map[string]any{
"schemaVersion": "1.0", "catalogVersion": "dml-v1",
"pages": []any{
map[string]any{
"id": "page",
"nodes": []any{
map[string]any{"id": "node", "type": "text"},
},
},
},
}
}
baseSummary := func() map[string]any {
return map[string]any{
"nodeCount": 1, "pageCount": 1, "readOnlyNodeCount": 0,
"unknownNodeCount": 0, "resultBytes": 1, "resultSha256": "hash",
}
}
fixtures := map[string]map[string]any{
"wrong schema": {"success": true, "resultJson": map[string]any{"schemaVersion": "2.0", "catalogVersion": "dml-v1", "pages": []any{}}, "resultSummary": baseSummary()},
"wrong catalog": {"success": true, "resultJson": map[string]any{"schemaVersion": "1.0", "catalogVersion": "v2", "pages": []any{}}, "resultSummary": baseSummary()},
"bad page item": {"success": true, "resultJson": map[string]any{"schemaVersion": "1.0", "catalogVersion": "dml-v1", "pages": []any{"bad"}}, "resultSummary": baseSummary()},
"missing node type": {"success": true, "resultJson": map[string]any{
"schemaVersion": "1.0", "catalogVersion": "dml-v1",
"pages": []any{
map[string]any{
"id": "page",
"nodes": []any{
map[string]any{"id": "node"},
},
},
},
}, "resultSummary": baseSummary()},
"empty summary": {"success": true, "resultJson": baseResult(), "resultSummary": map[string]any{}},
"wrong message": {"success": true, "resultJson": baseResult(), "resultSummary": baseSummary(), "message": 1},
"conflicting error": {"success": true, "resultJson": baseResult(), "resultSummary": baseSummary(), "errorMsg": "failed"},
"read only too high": {"success": true, "resultJson": baseResult(), "resultSummary": map[string]any{"nodeCount": 1, "pageCount": 1, "readOnlyNodeCount": 2, "unknownNodeCount": 0, "resultBytes": 1, "resultSha256": "hash"}},
"unknown too high": {"success": true, "resultJson": baseResult(), "resultSummary": map[string]any{"nodeCount": 1, "pageCount": 1, "readOnlyNodeCount": 0, "unknownNodeCount": 2, "resultBytes": 1, "resultSha256": "hash"}},
"bad result bytes": {"success": true, "resultJson": baseResult(), "resultSummary": map[string]any{"nodeCount": 1, "pageCount": 1, "readOnlyNodeCount": 0, "unknownNodeCount": 0, "resultBytes": -1, "resultSha256": "hash"}},
"missing digest": {"success": true, "resultJson": baseResult(), "resultSummary": map[string]any{"nodeCount": 1, "pageCount": 1, "readOnlyNodeCount": 0, "unknownNodeCount": 0, "resultBytes": 1}},
}
for name, fixture := range fixtures {
if projected, err := projectWhiteboardQuery(fixture, "doc", "part"); err == nil {
t.Errorf("%s returned success: %#v", name, projected)
}
}
valid := map[string]any{"success": true, "resultJson": baseResult(), "resultSummary": baseSummary(), "message": "ok"}
projected, err := projectWhiteboardQuery(valid, " doc ", " part ")
if err != nil || projected["nodeId"] != "doc" || projected["partId"] != "part" || projected["message"] != "ok" {
t.Fatalf("valid message projection=%#v err=%v", projected, err)
}
}
func TestCrossPlatformCoverageWhiteboardReceiptAndResultJSONRemainingMatrix(t *testing.T) {
expected, err := parseWhiteboardSource(`{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[{"id":"n1","type":"text"}]}}`)
if err != nil {
t.Fatal(err)
}
target := map[string]any{"nodeId": "doc", "partId": "part"}
fixtures := map[string]map[string]any{
"business failure": {"success": false},
"missing terminal result": {"success": true, "nodeId": "doc", "partId": "part"},
"empty result map": {"success": true, "nodeId": "doc", "partId": "part", "resultJson": map[string]any{}},
"empty result string": {"success": true, "nodeId": "doc", "partId": "part", "resultJson": " "},
"invalid result string": {"success": true, "nodeId": "doc", "partId": "part", "resultJson": "{"},
"trailing result string": {"success": true, "nodeId": "doc", "partId": "part", "resultJson": `{"mode":"append"} {}`},
"wrong result type": {"success": true, "nodeId": "doc", "partId": "part", "resultJson": []any{}},
"wrong mode": {"success": true, "nodeId": "doc", "partId": "part", "resultJson": map[string]any{
"mode": "overwrite", "message": "done", "createdNodeIds": []any{"real"}, "idMap": map[string]any{"n1": "real"}, "deletedNodeCount": 0,
}},
"created count mismatch": {"success": true, "nodeId": "doc", "partId": "part", "resultJson": map[string]any{
"mode": "append", "message": "done", "createdNodeIds": []any{}, "idMap": map[string]any{"n1": "real"}, "deletedNodeCount": 0,
}},
"empty created identity": {"success": true, "nodeId": "doc", "partId": "part", "resultJson": map[string]any{
"mode": "append", "message": "done", "createdNodeIds": []any{" "}, "idMap": map[string]any{"n1": "real"}, "deletedNodeCount": 0,
}},
"duplicate created identity": {"success": true, "nodeId": "doc", "partId": "part", "resultJson": map[string]any{
"mode": "append", "message": "done", "createdNodeIds": []any{"real", "real"}, "idMap": map[string]any{"n1": "real"}, "deletedNodeCount": 0,
}},
"empty id map key": {"success": true, "nodeId": "doc", "partId": "part", "resultJson": map[string]any{
"mode": "append", "message": "done", "createdNodeIds": []any{"real"}, "idMap": map[string]any{" ": "real"}, "deletedNodeCount": 0,
}},
"id map count mismatch": {"success": true, "nodeId": "doc", "partId": "part", "resultJson": map[string]any{
"mode": "append", "message": "done", "createdNodeIds": []any{"real"}, "idMap": map[string]any{}, "deletedNodeCount": 0,
}},
}
for name, fixture := range fixtures {
if receipt, err := requireWhiteboardUpdateReceipt(fixture, target, "append", expected); err == nil {
t.Errorf("%s returned success: %#v", name, receipt)
}
}
}
func TestCrossPlatformCoverageWhiteboardReadbackComparatorAndScalarMatrices(t *testing.T) {
if count := whiteboardPageOwnedNodeCount([]map[string]any{
{"id": "master", "source": "master"}, {"id": "page", "source": "page"}, {"id": "plain"},
}); count != 2 {
t.Fatalf("page-owned count=%d", count)
}
comparisons := []struct {
name string
expected any
actual any
wantErr bool
}{
{"map wrong type", map[string]any{"a": 1}, "bad", true},
{"map missing key", map[string]any{"a": 1}, map[string]any{}, true},
{"array wrong length", []any{1}, []any{}, true},
{"array child mismatch", []any{1}, []any{2}, true},
{"array equal", []any{1}, []any{float64(1)}, false},
{"number wrong type", json.Number("1"), "1", true},
{"number mismatch", json.Number("1"), float64(2), true},
{"number equal cross type", json.Number("1"), int(1), false},
{"large adjacent JSON integers mismatch", json.Number("9007199254740992"), json.Number("9007199254740993"), true},
{"large JSON integer equal", json.Number("9007199254740993"), json.Number("9007199254740993"), false},
{"scalar mismatch", "one", "two", true},
{"scalar equal", "same", "same", false},
}
for _, test := range comparisons {
err := requireRequestedValue(test.expected, test.actual, test.name)
if (err != nil) != test.wantErr {
t.Errorf("%s err=%v wantErr=%t", test.name, err, test.wantErr)
}
}
for _, test := range []struct {
value any
ok bool
}{
{json.Number("1.5"), true}, {json.Number("1e3"), true}, {json.Number("bad"), false},
{float64(1), true}, {math.NaN(), false}, {math.Inf(1), false},
{int(1), true}, {"1", false},
} {
_, ok := numericValue(test.value)
if ok != test.ok {
t.Errorf("numericValue(%#v) ok=%t want=%t", test.value, ok, test.ok)
}
}
for _, test := range []struct {
value any
want int
ok bool
}{
{int(1), 1, true}, {int(-1), -1, false},
{float64(2), 2, true}, {float64(-1), 0, false}, {1.5, 0, false}, {math.NaN(), 0, false}, {math.Inf(1), 0, false},
{json.Number("3"), 3, true}, {json.Number("bad"), 0, false}, {json.Number("-1"), 0, false},
{"4", 0, false},
} {
got, ok := nonNegativeInt(test.value)
if got != test.want || ok != test.ok {
t.Errorf("nonNegativeInt(%#v)=(%d,%t), want (%d,%t)", test.value, got, ok, test.want, test.ok)
}
}
decoder := json.NewDecoder(strings.NewReader(`{} ]`))
var first map[string]any
if err := decoder.Decode(&first); err != nil {
t.Fatal(err)
}
if err := requireJSONEOF(decoder); err == nil {
t.Fatal("malformed trailing JSON returned EOF success")
}
}
func TestCrossPlatformCoverageWhiteboardBlankTargetsStopBeforeRPC(t *testing.T) {
validSource := `{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[{"id":"n1","type":"text"}]}}`
tests := []struct {
name string
declaration shortcut.Shortcut
args []string
}{
{"query blank node", Query, []string{"--node", " ", "--part-id", "part"}},
{"query blank part", Query, []string{"--node", "doc", "--part-id", " "}},
{"update blank node", Update, []string{"--node", " ", "--part-id", "part", "--source", validSource, "--yes"}},
{"update blank part", Update, []string{"--node", "doc", "--part-id", " ", "--source", validSource, "--yes"}},
}
for _, test := range tests {
caller := &whiteboardCoverageCaller{responses: map[string][]string{}}
if err := runWhiteboardCoverage(t, test.declaration, caller, "", test.args...); err == nil {
t.Errorf("%s returned success", test.name)
}
if len(caller.calls) != 0 {
t.Errorf("%s crossed RPC boundary: %#v", test.name, caller.calls)
}
}
directBlankNode := directWhiteboardRuntime(t, Query, &whiteboardCoverageCaller{responses: map[string][]string{}}, "--node", " ", "--part-id", "part")
if err := Query.Validate(directBlankNode); err == nil {
t.Fatal("direct query validation accepted blank node")
}
directBlankPart := directWhiteboardRuntime(t, Query, &whiteboardCoverageCaller{responses: map[string][]string{}}, "--node", "doc", "--part-id", " ")
if err := Query.Validate(directBlankPart); err == nil {
t.Fatal("direct query validation accepted blank part")
}
directUpdate := directWhiteboardRuntime(t, Update, &whiteboardCoverageCaller{responses: map[string][]string{}},
"--node", " ", "--part-id", "part", "--source", validSource)
if err := Update.Validate(directUpdate); err == nil {
t.Fatal("direct update validation accepted blank node")
}
}
func TestCrossPlatformCoverageWhiteboardSourceAndDirectVerificationRemainingMatrix(t *testing.T) {
for name, source := range map[string]string{
"null nodes": `{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":null}}`,
"missing type": `{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[{"id":"node"}]}}`,
} {
if parsed, err := parseWhiteboardSource(source); err == nil {
t.Errorf("%s returned success: %#v", name, parsed)
}
}
t.Run("marshal failure", func(t *testing.T) {
testseam.Swap(t, &whiteboardMarshalNodes, func(any) ([]byte, error) {
return nil, errors.New("injected marshal failure")
})
if parsed, err := parseWhiteboardSource(`{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[{"id":"node","type":"text"}]}}`); err == nil {
t.Fatalf("marshal failure returned success: %#v", parsed)
}
})
expected := &parsedUpdate{Overwrite: true, Nodes: []map[string]any{{"id": "request", "type": "text"}}}
if err := verifyWhiteboardUpdate(expected, map[string]any{}, map[string]string{"request": "real"}); err == nil {
t.Fatal("missing source returned success")
}
if err := verifyWhiteboardUpdate(expected, map[string]any{"source": map[string]any{"pages": "bad"}}, map[string]string{"request": "real"}); err == nil {
t.Fatal("bad pages returned success")
}
overwriteCountMismatch := map[string]any{
"source": map[string]any{
"pages": []any{
map[string]any{
"id": "page",
"nodes": []any{
map[string]any{"id": "real", "type": "text"},
map[string]any{"id": "extra", "type": "text"},
},
},
},
},
}
if err := verifyWhiteboardUpdate(expected, overwriteCountMismatch, map[string]string{"request": "real"}); err == nil {
t.Fatal("overwrite count mismatch returned success")
}
}
+2
View File
@@ -32,6 +32,8 @@ SEMANTIC_PATHS = [
ROOT / "internal" / "shortcut" / "semantic_catalog_oa.json",
ROOT / "internal" / "shortcut" / "semantic_catalog_ding.json",
ROOT / "internal" / "shortcut" / "semantic_catalog_report.json",
ROOT / "internal" / "shortcut" / "semantic_catalog_sheet.json",
ROOT / "internal" / "shortcut" / "semantic_catalog_whiteboard.json",
]
+15 -9
View File
@@ -44,8 +44,12 @@ SERVICE_TO_SKILL = {
"report": ROOT / "skills" / "multi" / "dingtalk-misc" / "references" / "report.md",
"sheet": ROOT / "skills" / "multi" / "dingtalk-misc" / "references" / "sheet.md",
"todo": ROOT / "skills" / "multi" / "dingtalk-todo" / "SKILL.md",
"whiteboard": ROOT / "skills" / "multi" / "dingtalk-misc" / "references" / "whiteboard.md",
"wiki": ROOT / "skills" / "multi" / "dingtalk-wiki" / "SKILL.md",
}
SERVICE_TO_SKILL_MIRRORS = {
"whiteboard": [ROOT / "skills" / "mono" / "references" / "products" / "whiteboard.md"],
}
MONO_START = "<!-- VISIBLE_SHORTCUTS_OVERVIEW_START -->"
MONO_END = "<!-- VISIBLE_SHORTCUTS_OVERVIEW_END -->"
@@ -226,17 +230,19 @@ def update_product_skills(items: list[dict[str, Any]], check: bool) -> list[Path
for item in items:
by_service[item["service"]].append(item)
changed = []
for service, path in SERVICE_TO_SKILL.items():
for service, primary_path in SERVICE_TO_SKILL.items():
if service not in by_service:
continue
if not path.exists():
raise RuntimeError(f"skill file not found for {service}: {path}")
text = path.read_text(encoding="utf-8")
block = product_section(service, by_service[service])
anchor = "## 概念地图" if service == "devapp" else "## 意图表"
updated = replace_block(text, PRODUCT_START, PRODUCT_END, block, anchor)
if apply_update(path, text, updated, check):
changed.append(path)
paths = [primary_path, *SERVICE_TO_SKILL_MIRRORS.get(service, [])]
for path in paths:
if not path.exists():
raise RuntimeError(f"skill file not found for {service}: {path}")
text = path.read_text(encoding="utf-8")
block = product_section(service, by_service[service])
anchor = "## 概念地图" if service == "devapp" else "## 意图表"
updated = replace_block(text, PRODUCT_START, PRODUCT_END, block, anchor)
if apply_update(path, text, updated, check):
changed.append(path)
return changed
+1
View File
@@ -58,6 +58,7 @@ cli_version: ">=1.0.15"
| `report` | 4 | `dingtalk-misc` |
| `sheet` | 2 | `dingtalk-misc` |
| `todo` | 21 | `dingtalk-todo` |
| `whiteboard` | 2 | `dingtalk-misc` |
| `wiki` | 20 | `dingtalk-wiki` |
<!-- VISIBLE_SHORTCUTS_OVERVIEW_END -->
+6
View File
@@ -36,6 +36,7 @@ Agoal 是钉钉目标管理工具,支持战略解码、经营合约、计分
| `scorecard detail` | 获取计分卡详情 | `--selected-time` `--dept-id` | selectedTime 为 ISO-8601 字符串,如 `"2026-01-01T00:00:00+08:00"` |
| `scorecard entity-detail` | 获取计分卡实体详情 | `--sc-id` `--entity-id` | 根据计分卡 id 和实体 id 查询 |
| `scorecard update` | 更新计分卡 | `--dept-id` `--selected-time` `--id` `--tracking-period-type` `--content` | trackingPeriodType: MONTHLY/QUARTERLY |
| `scorecard search-entities` | 搜索计分卡指标与关键事项 | `--keyword` | 可选 `--page`、`--page-size`;keyword 为标题模糊匹配关键词 |
### user (用户目标管理)
@@ -75,6 +76,7 @@ Agoal 是钉钉目标管理工具,支持战略解码、经营合约、计分
- 查看详情 → `scorecard detail`
- 实体详情 → `scorecard entity-detail`
- 修改/更新 → `scorecard update`
- 搜索计分卡指标与关键事项 → `scorecard search-entities --keyword "关键词"`
用户说"目标/OKR/我的目标/个人目标":
- 规则周期 → `user rules`
@@ -121,6 +123,10 @@ dws agoal scorecard update --dept-id DEPT_ID --selected-time "2026-01-01T00:00:0
--content '[{"id":"dim1","title":"业绩","items":[{"id":"item1","title":"收入","target":"100"}]}]' \
--format json
# 搜索计分卡指标与关键事项
dws agoal scorecard search-entities --keyword "业绩" --format json
dws agoal scorecard search-entities --keyword "业绩" --page 1 --page-size 20 --format json
# 查询用户目标
dws agoal user rules --user-id USER_ID --format json
dws agoal user objectives --user-id USER_ID --rule-id RULE_ID --period-ids "period1,period2" --format json
+5 -1
View File
@@ -55,6 +55,8 @@ Example:
dws doc read --node "https://alidocs.dingtalk.com/document/edit?dentryKey=<DENTRY_KEY>"
dws doc read --node "https://alidocs.dingtalk.com/document/preview?dentryKey=<DENTRY_KEY>"
dws doc read --node <DOC_ID> --content-format jsonml --scope outline
dws doc read --node <PUBLIC_URL> --password <ACCESS_PASSWORD>
dws doc read --node <DOC_ID> --version 3
Flags:
--node string 文档 ID 或 URL (必填)
--content-format string 输出格式: markdown / jsonml
@@ -63,9 +65,11 @@ Flags:
--max-depth int 筛选遍历最大深度,0 表示不限
--start-block-id string range / section 起始块 UUID
--end-block-id string range 结束块 UUID
--password string 互联网公开文档密码保护时的访问密码
--version int 读取指定历史版本(版本号从 doc version list 获取,0 表示初始版本);缺省读最新版
```
`--scope` 只适用于 `--content-format jsonml`,用于按大纲、块区间、单块子树或自定义 tag 局部读取。筛选结果是虚拟 JSONML fragment,只能剥壳后消费 children,不得把 fragment 容器整体写回。完整规则见 [`doc-read.md`](./doc/doc-read.md)。
`--scope` 只适用于 `--content-format jsonml`,用于按大纲、块区间、单块子树或自定义 tag 局部读取。筛选结果是虚拟 JSONML fragment,只能剥壳后消费 children,不得把 fragment 容器整体写回。互联网公开文档(含密码保护)用 `--password`;读取历史版本内容用 `--version`(版本号来自 `dws doc version list`,0 表示初始版本,需要编辑权限)。完整规则见 [`doc-read.md`](./doc/doc-read.md)。
### 创建文档
```
@@ -25,6 +25,8 @@ Flags:
--max-depth int 筛选遍历最大深度,0 表示不限
--start-block-id string range / section 起始块 UUID
--end-block-id string range 结束块 UUID;空或 "-1" 表示到文档末尾
--password string 互联网公开文档开启密码保护时的访问密码;普通文档无需传入
--version int 读取指定历史版本内容(版本号从 doc version list 获取,0 表示初始版本,需要文档编辑权限);缺省读最新版
```
## 关键说明
@@ -33,6 +35,13 @@ Flags:
- 返回的 Markdown 中,附件以 OSS 临时下载链接形式给出(如 `https://alidocs2.oss-cn-zhangjiakou.aliyuncs.com/res/.../att/<resourceId>.ext?Expires=...`),**链接会过期**。链接过期后从 URL 路径中提取 `<resourceId>`(即 `/att/` 后、扩展名前的 UUID 部分),用 `media download --node <DOC_ID> --resource-id <resourceId>` 重新获取下载链接。
- `--content-format jsonml` 返回完整 JSONML 结构(含 `revision`),用于无损读改写;可直接配合 [`doc-update.md`](./doc-update.md) 的 `--content-format jsonml --content-file` 写回。`revision` 仅在并发敏感场景下需要透传给 update 触发并发检查(详见下方)。
## 互联网公开文档与历史版本
- **互联网公开文档**:公开链接直接传 `--node`;文档开启了密码保护时,通过 `--password <ACCESS_PASSWORD>` 提供访问密码,普通文档无需传入。密码只进入读取请求,不会回显在读取结果里(`--dry-run` 预览会包含所传参数,注意输出环境)。
- **历史版本**:`--version <N>` 读取指定历史版本内容(Markdown / JSONML / scope 均可组合);版本号从 `dws doc version list` 或 `dws doc +version-list` 获取,`0` 表示文档初始版本;需要文档编辑权限(EDITOR 及以上),缺省读最新版。
- **跨组织文档**:非互联网公开的跨组织文档,`doc read` 整体被组织边界拦截(提示「不支持跨组织访问数据」),最新版与历史版本内容都读不到(`doc info` 仍可用);互联网公开(含密码)文档的 `doc read` 不受影响,但 `doc version list` / `+version-list` 与写操作仍会被拦截,版本号无法从列表获取。
- 只要看历史内容、不打算恢复时用 `--version`;要把文档整体恢复到历史版本才用 `doc version revert`(危险操作,需确认)。
## content-format=jsonml 输出
输出 JSON 对象,包含 `revision`(版本号)和 `jsonml`(JSONML body 数组):
@@ -92,6 +101,12 @@ dws doc read --node <DOC_ID> --format json
# alidocs URL 直传
dws doc read --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>" --format json
# 互联网公开文档(含密码保护)
dws doc read --node <PUBLIC_URL> --password <ACCESS_PASSWORD> --format json
# 读取历史版本(版本号来自 dws doc version list)
dws doc read --node <DOC_ID> --version 3 --format json
# JSONML 完整结构 → 文件(无损改写前置)
dws doc read --node <DOC_ID> --content-format jsonml --output /tmp/doc.json
# 之后修改 /tmp/doc.json 中的 jsonml 数组,再用:
+31 -4
View File
@@ -27,13 +27,14 @@ Flags:
--instance-id string 审批实例 ID (必填)
```
### 审批附件授权与下载
### 审批附件授权、上传与下载
先从 `approval detail` 的返回中取得审批实例 `processInstanceId`、附件 `fileId`,以及授权下载所需的 `spaceId`。根据目标选择命令:
- 需要单个附件的临时下载链接:`attachment download-url`
- 已有钉盘 `spaceId/fileId`,需要为当前用户批量开通下载权限:`attachment authorize-download`
- 需要在审批场景内批量预览附件:`attachment authorize-preview`
- 需要把本地文件上传为审批附件(自动完成初始化+PUT+提交):`attachment upload`
#### 获取审批附件临时下载链接
@@ -78,6 +79,21 @@ Flags:
该命令只授权审批场景内的附件预览,不等同于下载授权。附件来自审批评论时增加 `--with-comment-attachment`。
#### 上传本地文件为审批附件
```
Usage:
dws oa approval attachment upload [flags]
Example:
dws oa approval attachment upload --file ./合同.pdf --format json
Flags:
--file string 本地文件路径 (必填)
--file-name string 完整文件名,例如 合同.pdf (可选,默认取本地文件名)
--md5 string 文件原始字节内容的 MD5,32位十六进制字符串 (可选,不传则自动计算)
```
该命令一条命令完成审批附件上传的全部三步:先调用 `oa/init_attachment_upload_info` 初始化获取 OSS 上传地址与签名凭证,再将本地文件二进制 HTTP PUT 上传到 OSS,最后调用 `oa/commit_attachment_upload_info` 提交入库;返回结果包含 fileId、spaceId、fileName、fileSize、fileType。`--file-name` 不传时默认使用本地文件名,`--md5` 不传时自动根据文件内容计算,无需手动初始化或提交。
### 同意审批
> **CAUTION:** 审批决策不可撤回 — 执行前必须向用户确认。
@@ -292,7 +308,7 @@ Flags:
- **严禁跳过 `form-schema`。** 未拿到表单 Schema 前,不得调用 `create-instance`。
- **严禁复用旧的 Schema 结果。** 每次发起实例前都必须重新调用 `form-schema`,模板可能已被修改。
- **严禁在存在不支持必填控件时强行发起。** 若 `form-schema` 返回的必填控件中有不支持类型(如附件等),直接告知用户不支持通过 CLI 发起。
- **严禁在存在不支持必填控件时强行发起。** 若 `form-schema` 返回的必填控件中有不支持类型(如计算公式、流水号、OCR 等),直接告知用户不支持通过 CLI 发起。**注意:附件控件 `DDAttachment` 已支持通过 CLI 提交**,先用 `dws oa approval attachment upload --file <path>` 获取字段再组装为 value 提交,不属于不支持类型。
- **严禁把 `form-schema` 返回的 `content` 当成可直接提交的 payload 模板。**
- **严禁把姓名直接写进 `approvers`、`ccList`、`directAppointedApprovers`、`targetSelectActioners` 或表单人员控件。** 必须先通过 `dws aisearch person --query "<姓名>" --dimension name --format json` 转成 userId。
- **严禁在未得到用户确认前直接执行真实提单。**
@@ -411,7 +427,7 @@ Flags:
| 部门控件 | `DepartmentField` | 部门 ID | `"12345"` | 多部门传 JSON 数组;multiple=true 时支持多选 |
| 省市区控件 | `AddressField` | JSON 数组字符串 | `'["浙江省","杭州市","西湖区"]'` | 三级联动;needDetail=true 时末尾加详细地址 |
| 图片控件 | `DDPhotoField` | URL 数组转义字符串 | `"[\"http://example.com/img1.jpg\"]"` | 支持 URL 直接提交;**不支持本地文件上传** |
| 附件控件 | `DDAttachment` | JSON 数组转义字符串 | `"[{\"spaceId\":\"xxx\",\"fileName\":\"a.pdf\",\"fileSize\":\"333\",\"fileType\":\"pdf\",\"fileId\":\"xxx\"}]"` | **当前不支持通过 CLI 提交**,需钉盘上传接口获取 fileId 等字段 |
| 附件控件 | `DDAttachment` | JSON 数组转义字符串 | `"[{\"spaceId\":\"xxx\",\"fileName\":\"a.pdf\",\"fileSize\":\"333\",\"fileType\":\"pdf\",\"fileId\":\"xxx\"}]"` | **支持通过 CLI 提交**:先用 `dws oa approval attachment upload --file <path>` 获取 fileId/spaceId/fileName/fileSize/fileType,再组装为 DDAttachment value 提交 |
| 评分控件 | `StarRatingField` | 数字字符串 | `"4"` | limit 控制最大星数(默认 5) |
| 关联审批单 | `RelateField` | 审批实例 ID | `"q-xxx"` | 须为当前组织下已存在的实例 |
| 明细控件 | `TableField` | JSON 数组字符串 | `'[{"子控件名":"值1"},{"子控件名":"值2"}]'` | 不可嵌套 TableField;不可含 DDMultiSelectField/DDPhotoField;最大 100 行 |
@@ -426,9 +442,19 @@ Flags:
- `CalculateField`(计算公式)— 由系统自动计算
- `SeqNumberField`(流水号)— 由系统自动生成
- `OcrTextField` / `OcrIdCardField`(OCR 识别)— 需要客户端交互
- **`DDAttachment`(附件控件)— 当前不支持通过 CLI 提交**,value 需要 spaceId、fileName、fileSize、fileType、fileId 字段,须通过钉盘上传附件接口获取
- **套件类控件(暂不支持)** — `InvoiceField`(发票)、`RecipientAccountField`(收款账户)等业务套件控件当前暂不支持通过 CLI 发起,包含这些控件的审批模板请直接在钉钉客户端操作
> **`DDAttachment`(附件控件)已支持通过 CLI 提交:** 采用两步流程——先用 `dws oa approval attachment upload --file <path>` 上传本地文件,返回 `fileId`、`spaceId`、`fileName`、`fileSize`、`fileType`;再将这些字段组装为 DDAttachment value(JSON 数组转义字符串)随 `create-instance` 提交。示例:
>
> ```bash
> # 1) 上传附件,拿到 fileId/spaceId/fileName/fileSize/fileType
> dws oa approval attachment upload --file ./a.pdf
> # 2) 组装 value 后先向用户展示提单汇总;确认前不要追加 --yes
> dws oa approval create-instance --process-code PROC-xxx \
> --form-values '{"附件":"[{\"spaceId\":\"163xxx\",\"fileName\":\"a.pdf\",\"fileSize\":\"333\",\"fileType\":\"pdf\",\"fileId\":\"643xxx\"}]"}'
> # 用户确认模板、表单值、流程路径和人员后,才可在同一命令末尾追加 --yes
> ```
> **部分支持的控件:** `DDPhotoField`(图片控件)**支持通过 URL 直接提交**(见上方速查表),仅不支持本地文件上传(CLI 未封装钉盘 CDN 上传流程)。若用户只有本地文件,需告知在钉钉客户端补充。
如果目标审批模板包含上述控件,不要硬拼 `form-values`;应告知用户这些字段无需填写或需要在钉钉客户端补充。
@@ -745,6 +771,7 @@ Flags:
用户说"下载审批附件/获取审批附件下载链接" → `approval attachment download-url`(需 --instance-id 和 --file-id;评论附件增加 --with-comment-attachment)
用户说"授权下载审批钉盘文件/批量开通附件下载权限" → `approval attachment authorize-download`(需 --file-infos,最多 10 项)
用户说"预览审批附件/批量授权预览附件" → `approval attachment authorize-preview`(需 --instance-id 和 --file-ids,最多 20 项;评论附件增加 --with-comment-attachment)
用户说"上传审批附件/把文件上传为审批附件" → `approval attachment upload`(需 --file;可选 --file-name 默认本地文件名、--md5 自动计算;一条命令完成 init+put+commit)
用户说"同意审批/批准" → 先 `tasks` 获取 taskId,再 `approve`
用户说"拒绝审批/驳回" → 先 `tasks` 获取 taskId,再 `reject`
用户说"撤回审批/取消审批" → `approval revoke`
@@ -239,14 +239,14 @@
### DDAttachment(附件控件)
> **[注意] 当前暂不支持通过 CLI 提交附件控件。** 附件控件的 value 需要包含 spaceId、fileName、fileSize、fileType 和 fileId 字段,这些字段需要通过调用钉盘的上传附件接口获取,CLI 尚未封装此流程。包含附件控件的审批模板请在钉钉客户端操作。
> **[支持] 已支持通过 CLI 提交附件控件。** 采用两步流程:先用 `dws oa approval attachment upload --file <path>` 上传本地文件,获取 spaceId、fileName、fileSize、fileType、fileId;再将这些字段组装为 DDAttachment value(JSON 数组转义字符串)随 `create-instance` 提交。
| 属性 | 说明 |
|------|------|
| `componentName` | `DDAttachment` |
| value 格式 | JSON 数组转义字符串,每个元素包含 spaceId、fileName、fileSize、fileType、fileId |
| 示例(参考) | `"[{\"spaceId\":\"163xxx\",\"fileName\":\"2644.JPG\",\"fileSize\":\"333\",\"fileType\":\"jpg\",\"fileId\":\"643xxx\"}]"` |
| 约束 | **当前不支持通过 CLI 提交**;各字段需通过钉盘上传附件接口获取 |
| 约束 | **支持通过 CLI 提交**;先用 `dws oa approval attachment upload --file <path>` 获取 spaceId、fileName、fileSize、fileType、fileId,再组装为 value 随 `create-instance` 提交 |
### StarRatingField(评分控件)
@@ -328,7 +328,6 @@
| 流水号 | `SeqNumberField` | 由系统自动生成 |
| OCR 文本识别 | `OcrTextField` | 需要客户端 OCR 交互 |
| OCR 身份证识别 | `OcrIdCardField` | 需要客户端 OCR 交互 |
| 附件控件 | `DDAttachment` | value 需要 spaceId、fileName、fileSize、fileType、fileId,须通过钉盘上传接口获取,CLI 尚未封装 |
> **部分支持的控件:** `DDPhotoField`(图片控件)**支持通过 URL 直接提交**,但不支持本地文件上传(CLI 未封装钉盘 CDN 上传流程)。若用户只有本地文件,需告知在钉钉客户端补充。详见本文 [DDPhotoField](#ddphotofield图片控件) 章节。
+13 -9
View File
@@ -54,6 +54,7 @@
| 图片写入单元格 / 浮动图片 | `write-image` / `media-upload` + `create-float-image` | [sheet-media-image](sheet/sheet-media-image.md) | 用 `range update` 写图片 |
| 条件高亮 / 标红 / 数据条 / 色阶 | `cond-format` | [sheet-conditional-format](sheet/sheet-conditional-format.md) | 用静态 `set-style` 冒充条件格式 |
| 单元格评论 / 批注 / @人讨论 | `comment list/create/reply/update/delete` | [sheet-comment](sheet/sheet-comment.md) | 把评论内容写进单元格值 |
| 查询当前 revision / 复核两个 revision 间的编辑 | `revision-get` / `changeset-get` | [sheet-revision-changeset](sheet/sheet-revision-changeset.md) | 把 revision 当历史快照 version,或把前向 change 当成当前最终值 |
## 触发必读
@@ -63,6 +64,7 @@
- 涉及合并、冻结、工作表增删改、最后非空边界:先读 [sheet-workbook](./sheet/sheet-workbook.md)。
- 涉及行列插删、隐藏、尺寸、移动、分组:先读 [sheet-dimension-operations](./sheet/sheet-dimension-operations.md)。
- 涉及多个写操作一次提交:先读 [sheet-batch-operations](./sheet/sheet-batch-operations.md)。
- 涉及 revision、changeset、编辑审计、语义变更、撤销或状态重置:先读 [sheet-revision-changeset](./sheet/sheet-revision-changeset.md)。
## Reference 索引
@@ -82,13 +84,14 @@
| [sheet-filter](sheet/sheet-filter.md) | 全局筛选。当用户说"筛选"、"过滤"、"只看某些行"(未说"筛选视图")时使用。禁止用"删除不符合条件的行"代替筛选。命令:`filter get`/`create`/`delete`/`update`/`clear-criteria`/`sort` |
| [sheet-filter-view](sheet/sheet-filter-view.md) | 筛选视图(个人化,不影响协作者)。当用户明确说"筛选视图"时使用,与全局筛选相互独立。命令:`filter-view list`/`create`/`update`/`delete`/`info`/`update-criteria`/`delete-criteria`/`list-criteria`/`get-criteria` |
| [sheet-conditional-format](sheet/sheet-conditional-format.md) | 条件格式规则。触发词:标红/标黄/高亮/突出/标记/数据条/色阶/颜色随数据变 → **强制**走条件格式,禁止 `range set-style` 静态样式替代。命令:`cond-format list`/`create`/`update`/`delete` |
| [sheet-export](sheet/sheet-export.md) | 导出表格为 xlsx。当用户说“导出”、“下载xlsx”、“存为Excel”时使用。单命令一站式,CLI 内部自动轮询,禁止 Agent 侧重试。命令:`export` |
| [sheet-export](sheet/sheet-export.md) | 导出表格为 xlsx。当用户说“导出”、“下载xlsx”、“存为Excel”时使用。单命令会自动等待结果,禁止 Agent 侧重复调用。命令:`export` |
| [sheet-import](sheet/sheet-import.md) | 导入本地表格文件为在线电子表格。当用户说“导入表格”、“把xlsx传成在线表格”、“上传Excel变在线表格”时使用。Agent 入口为 `import create`,`import` 保留兼容;超时后只用 `import get` 续查,禁止重新创建或用 `drive upload` 代替。命令:`import create`/`import get` |
| [sheet-chart](sheet/sheet-chart.md) | 浮动图表管理。当用户说“画图/数据可视化/趋势图/对比图/占比图/柱形图/折线图/饼图”时使用。禁止用本地脚本生成静态图片替代。命令:`chart list`/`create`/`update`/`delete` |
| [sheet-pivot-table](sheet/sheet-pivot-table.md) | 透视表管理。当用户说“透视表/分组汇总/交叉分析/按X统计Y”时使用。禁止用公式拼汇总表替代。命令:`pivot-table list`/`create`/`update`/`delete` |
| [sheet-comment](sheet/sheet-comment.md) | 单元格评论管理。当用户说“给单元格加评论/批注”、“看某格的评论”、“回复/更新/删除评论”、“@某人讨论这个数据”时使用。评论锚定单元格(`--sheet-id`+`--range`),禁止把评论写进单元格值。命令:`comment list`/`create`/`reply`/`update`/`delete` |
| sheet-template | 表格模板管理。当用户说“用模板创建表格”、“搜索模板”、“模板列表”时使用。命令:`template list`/`template search`/`template apply` |
| [sheet-version](./sheet/sheet-version.md) | 表格历史版本管理。当用户说“保存版本/存个快照/看历史版本/版本列表/回滚到某个版本/恢复到之前的表格”时使用。回滚为危险操作,默认二次确认;版本号从 `version list` 获取。命令:`version save`/`version list`/`version revert` |
| [sheet-version](./sheet/sheet-version.md) | 表格历史版本管理。当用户说“保存版本/存个快照/看历史版本/版本列表/回滚到某个版本或精确 revision/恢复到之前的表格”时使用。回滚为危险操作,默认二次确认;通常从 `version list` 选目标,用户明确要求时也可使用同一工作簿中已确认且仍可恢复的 revision。命令:`version save`/`version list`/`version revert` |
| [sheet-revision-changeset](./sheet/sheet-revision-changeset.md) | 工作簿 revision 与语义化前向 changeset。当用户说“当前 revision/两个版本之间改了什么/复核编辑/回滚到了哪里”时使用。命令:`revision-get`/`changeset-get` |
## 全局硬约束
@@ -99,12 +102,12 @@
5. **最后非空坐标只用 A1 语义**:`sheet info` 通过 `nonEmptyRange` 返回 A1/UI 边界;优先使用 `nonEmptyRange.range`,需要追加行/列时使用 `nonEmptyRange.lastRow` / `nonEmptyRange.lastColumn`。不要使用旧的 0-based 字段 `lastNonEmptyRow` / `lastNonEmptyColumn`
6. **`range update` 维度校验**:`--values` 行列数必须与 `--range` 完全一致;只接 `--values` 一个数据参数,cell `type` 仅支持 `text` / `richText`;整格超链接通过 cell-level `hyperlink` 表达,富文本片段链接才使用 `richText.texts[].type="link"`
7. **dataValidation 三语义**:不传 `dataValidation` 字段=保留原 DV;`dataValidation:{type:"none"}`=显式清除;`dataValidation:{type:"dropdown"/"checkbox",...}`=覆盖。`{}` 跳过亦保留原 DV
8. **hyperlink 三语义**:不传 `hyperlink` 字段=保留原整格超链接;`hyperlink:{type:"none"}`=显式清除;`hyperlink:{type:"path"/"sheet"/"range",link,...}`=覆盖。Agent 调用不要用 `hyperlink:null`,避免网关/Schema 过滤 null 字段
8. **hyperlink 三语义**:不传 `hyperlink` 字段=保留原整格超链接;`hyperlink:{type:"none"}`=显式清除;`hyperlink:{type:"path"/"sheet"/"range",link,...}`=覆盖。不要用 `hyperlink:null`,该写法不能可靠表达清除
9. **样式写法**:cell-level 样式用 `cellStyles` 或 `range set-style`;richText 片段级样式才用子项 `style`。不要在 `type:"text"` 顶层使用旧 `style` 字段
10. **用专用命令不用组合模拟**:搜索→`find`、替换→`replace`、清空→`range clear`、排序→`range sort`、填充→`range fill`、复制区域→`range copy-to`、移动区域→`range move-to`、移动行列→`move-dimension`
11. **大批量 CSV 值/公式用 `csv-put`**(>5 行或 >20 单元格,且无需富格式),不用 `range update`;需要 dataframe/table 语义(列名、dtypes、formats、跨 sheet specs)时用 `table-get` / `table-put`
12. **单元格图片用 `write-image`**(`range update` 不支持图片参数)
13. **`export` / `import create` 禁止自行轮询**(CLI 内部已完成渐进式退避,最多 30 次约 5 分钟);导入超时只按返回的 `next_command` 调用 `import get`,导出失败或超时直接转述,不自动重调
13. **`export` / `import create` 禁止自行轮询**(命令会自动等待结果,最长约 5 分钟);导入超时只按返回的 `next_command` 调用 `import get`,导出失败或超时直接转述,不自动重调
14. **单次调用上限**:`range update` / `set-style` 行数 ≤ 1000,单元格总数建议 ≤ 5000(硬限 30000)
15. **大整数精度保护**:超过 `9007199254740991` 的整数和长数字标识符必须按文本写入;`table-put` 中不要使用 JSON number 或 `int64` / `uint64` dtype,改用字符串值 + `object` dtype + `formats`/`cellStyles` 的 `@`
16. **批量写操作推荐用 `batch-update`**:对多个区域重复调用同一写入工具时,推荐合并为单次原子请求(详见 [sheet-batch-operations](./sheet/sheet-batch-operations.md));逐个调用非原子,中途失败会留下半成品。结构化 table 例外,`table-put` 不进 `batch-update`
@@ -115,9 +118,10 @@
- **公式**:支持通过 `range update` 写入、通过 `range read --value-render-option formula` 回读公式文本、通过 `formula-verify` 按错误类型聚合扫描、通过 `raw_value` / `formatted_value` 回读计算结果。只有 `formula-verify` 返回 `status=success`、`hasMore=false`、`totalErrors=0` 时,才能声称本次目标范围未发现公式错误;这仍不等价于业务数值一定正确。
- **视觉规范**:当前不维护独立的表格视觉方案文档;样式、条件格式、图表分别按对应子文档执行。
- **结构化 table**:`table-get` / `table-put` 是 table/dataframe 语义入口,不嵌入 `batch-update`;需要原子组合时只组合已支持的单元格/结构写操作。
- **历史版本**:支持通过 `version save`/`list`/`revert` 手动保存快照、查看版本列表、回滚到指定版本(底层复用 doc 域版本能力);回滚为危险操作,需二次确认。
- **历史版本与 revision**:`version save`/`list` 管理选定的历史版本;`version revert` 通常回滚到列表项,也可在用户明确要求时回滚到同一工作簿中已确认且仍可恢复的精确 revision。列表项 `version` 可作为 changeset 查询锚点,但相邻历史版本之间可能包含多个 revision;禁止猜测未确认的 revision。
- **单元格评论**:支持通过 `comment create`/`list`/`reply`/`update`/`delete` 管理锚定在 `--sheet-id` + `--range` 的评论;评论与单元格值相互独立,详见 [sheet-comment](sheet/sheet-comment.md)。
- **未暴露或未确认能力**:迷你图、历史 changeset 等能力未在本入口承诺;只有出现稳定 DWS 命令和可回读语义后再补充。
- **changeset 边界**:返回的是 `EDIT`/`UNDO`/`STATE_RESET` 事件及提交时的前向语义 change,不是统一的单元格 `oldValue/newValue` diff,也不保证代表当前最终状态;需要最终值必须额外回读。
- **未暴露或未确认能力**:迷你图等能力未在本入口承诺;只有出现稳定 DWS 命令和可回读语义后再补充。
## URL 粘贴场景
@@ -144,7 +148,7 @@ dws sheet import create --file ./report.xls --folder-token <FOLDER_TOKEN> --name
dws sheet import create --file ./data.xls --workspace <WORKSPACE_ID>
```
- 支持 `.xlsx` / `.xls`,固定产出**在线电子表格**;只新建、不覆盖已有表格
- CLI 内部自动完成「创建会话→上传→确认→渐进式退避轮询」(最多 30 次约 5 分钟),**禁止 Agent 侧再实现轮询或重试**
- 命令会自动上传、确认并等待结果(最长约 5 分钟),**禁止 Agent 侧再实现轮询或重试**
- 导入结果返回 `documentUrl` / `nodeId`,`nodeId` 可直接作为后续 `sheet` 命令的 `--node`
- **禁止用 `dws drive upload --convert` 代替**:drive upload 只上传文件、不在表格导入闭环内,产出不保证为可编辑在线电子表格
@@ -214,8 +218,8 @@ Flags:
| Shortcut | 风险 | 适用场景 |
|---|---|---|
| `dws sheet +list-sheets` | read | 获取表格文档中全部工作表列表 |
| `dws sheet +read` | read | 读取工作表指定范围的结构化单元格数据 |
| `dws sheet +list-sheets` | read | 严格列出在线电子表格的工作表,并可按完整标题精确筛选 |
| `dws sheet +read` | read | 完整读取并严格校验在线电子表格范围;截断结果失败关闭 |
<!-- VISIBLE_SHORTCUTS_END -->
## 意图表
@@ -12,7 +12,7 @@
- 仅支持钉钉在线电子表格(`extension=axls`)。`xlsx` / `xls` / `csv` 等本地表格不支持评论。
- 评论锚定在**单元格位置**:`create` / `list` 通过 `--sheet-id`(工作表 ID 或名称)+ `--range`(单元格坐标)定位;`reply` / `update` / `delete` 通过 `--comment-key` 操作,不依赖单元格位置。
- `create` / `list` 通过单元格位置定位;`reply` / `update` / `delete` 通过前一步返回的 `commentKey` 定位评论线程,不需要重新传单元格位置。
- 评论正文与单元格位置由服务端关联。Agent 只使用命令返回的 `commentKey` 继续回复、更新或删除,不解析或拼接内部关联字段。
- Agent 只使用命令返回的 `commentKey` 继续回复、更新或删除,不自行构造评论标识。
---
@@ -66,7 +66,7 @@ Flags:
查询指定范围内的下拉列表配置信息。
- **用途**:查看单元格已设置的下拉列表选项和配置。
- **场景**:在修改下拉列表前先查询现有配置;确认下拉列表是否设置成功。
- **返回**:`dataValidations` 数组由底层按配置聚合。Inline 组返回 `sourceType:"inline"`、`conditionValues`、`ranges` 和 `options`;SourceRange 组始终返回 `sourceType:"sourceRange"`、`sourceRangeStatus:"valid"/"invalid"`、`enableMultiSelect` 和 `ranges`,仅在 `sourceRangeStatus:"valid"` 时返回 `sourceRange:{sheetId,a1Notation}`。`invalid` 时仍保留配置组,但省略 `sourceRange`,不得依赖旧坐标修复。SourceRange 不会展开候选值,因此不返回 `conditionValues`、`options` 或颜色。范围内无下拉列表时 `hasDropdown` 为 false。
- **返回**:`dataValidations` 按相同配置分组。Inline 组返回 `sourceType:"inline"`、`conditionValues`、`ranges` 和 `options`;SourceRange 组始终返回 `sourceType:"sourceRange"`、`sourceRangeStatus:"valid"/"invalid"`、`enableMultiSelect` 和 `ranges`,仅在 `sourceRangeStatus:"valid"` 时返回 `sourceRange:{sheetId,a1Notation}`。`invalid` 时仍保留配置组,但省略 `sourceRange`,不得依赖旧坐标修复。SourceRange 不会展开候选值,因此不返回 `conditionValues`、`options` 或颜色。范围内无下拉列表时 `hasDropdown` 为 false。
### 删除下拉列表
```
@@ -99,5 +99,5 @@ Flags:
- ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node <NODE_ID> --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等)
- `set-dropdown` 的 Inline 模式使用 `--options`,每个元素包含 `value`(必填)和 `color`(可选,`#RRGGBB`);SourceRange 模式使用 `--source-sheet-id` + `--source-range`。两种模式均可用 `--multi-select`,并会覆盖目标范围已有下拉
- SourceRange 在已验证的重命名、引用前插入行/列、删除引用前行的场景会自动调整;已验证的 `move-dimension` 会使其变为 `invalid`。其他未覆盖删除/移动场景后先回读,仅 `invalid` 时重新选源写入;颜色写入暂不支持
- `get-dropdown` 查询指定范围内的下拉配置,聚合发生在底层服务。SourceRange 即使无效也保留一组并以 `sourceRangeStatus:"invalid"` 表示,但省略 `sourceRange`,不回退展开候选值
- `get-dropdown` 查询指定范围内的下拉配置,并按相同配置分组。SourceRange 即使无效也保留一组并以 `sourceRangeStatus:"invalid"` 表示,但省略 `sourceRange`,不回退展开候选值
- `delete-dropdown` 删除指定范围内的下拉列表配置,单元格恢复为普通文本格式。已填写的值不会被清除。目标范围不存在下拉列表时操作仍返回成功
@@ -5,11 +5,11 @@
### 导出
用户说"导出/下载xlsx/存为Excel/存成表格文件/把表格变成xlsx/导出表格/下载表格/导出为 excel":
- 导出表格 → `export`(单命令一站式,内部自动完成提交、轮询、可选下载)
- 导出表格 → `export`(单命令会自动等待完成,并可选下载)
- 仅需传 `--node`,可选 `--output` 指定本地文件/目录(不传则返回 downloadUrl)
- 需要落盘到本地 → `dws sheet export --node <NODE_ID> --output <path>`,命令自动下载 xlsx
- 禁止用 `range read` 全量读取后自行拼接 xlsx 来模拟导出,必须使用 `export` 命令(服务端原子导出,保留格式/合并/公式等属性)
- 禁止在 AI Agent 侧实现轮询或重试,CLI 内部已按渐进式退避策略完成(最多 30 次约 5 分钟)
- 禁止用 `range read` 全量读取后自行拼接 xlsx 来模拟导出;必须使用 `export`,才能保留格式、合并和公式等属性
- 禁止在 AI Agent 侧实现轮询或重试;命令会自动等待结果,最长约 5 分钟
用户说"导出 CSV/存成 csv/导出这个工作表为 csv":
- 导出单个工作表为纯 CSV → `export-csv`(**同步**,不走异步任务;与 `export` 是两条独立命令)
@@ -38,26 +38,14 @@ Flags:
--output string 本地保存路径(可选,支持文件路径或目录)
```
将钉钉在线电子表格导出为 Office xlsx 格式。**单命令一站式**:命令内部自动完成「提交任务 → 渐进式退避轮询 → (可选)下载文件」全流程,AI Agent 无需自行拆分步骤或实现轮询。
**内部流程**:
1. 调 `submit_export_job` 获取 `jobId`
2. 按渐进式退避策略轮询 `query_export_job` 直至任务终态或超时
3. 任务成功后取得 `downloadUrl`;若指定了 `--output`,自动 HTTP GET 下载 xlsx 到本地文件
**内置轮询策略(CLI 内实现,无需关心)**:
- 第 1~5 次:每次间隔 2 秒
- 第 6~10 次:每次间隔 5 秒
- 第 11~20 次:每次间隔 10 秒
- 第 21~30 次:每次间隔 15 秒
- **硬上限:最多轮询 30 次(约 5 分钟)**,超时后命令返回错误
将钉钉在线电子表格导出为 Office xlsx 格式。**单命令一站式**:命令会自动等待导出完成,并在指定 `--output` 时保存到本地。AI Agent 无需拆分步骤或自行轮询;最长等待约 5 分钟,超时后按命令错误处理。
**命令返回**:
- `--output` 未指定:进度日志 + 末尾输出 `jobId` 和 `downloadUrl`(链接有时效性,请尽快下载)
- `--output` 指定为文件路径:下载到该路径并输出 `导出完成: <path>`
- `--output` 指定为已存在目录:自动从 `downloadUrl` 推断文件名并保存到该目录下
**失败处理(命令内部已处理,Agent 仅需转述)**:
**失败处理**:
- 导出任务返回 `FAILED`:命令立即返回错误并附带失败原因,**禁止自动重试 `dws sheet export`**,告知用户稍后再试
- 轮询 30 次仍 `PROCESSING`:命令返回超时错误,告知用户稍后再试
@@ -111,7 +99,7 @@ dws sheet export-csv --node <NODE_ID> --sheet-id <SHEET_ID> --allow-truncated --
```bash
# ── 工作流 12: 导出表格为 xlsx(单命令一站式)──
# 场景 A:仅获取下载链接(命令内部自动完成提交+轮询,最终返回 downloadUrl)
# 场景 A:仅获取下载链接(命令自动等待完成并返回 downloadUrl)
dws sheet export --node <NODE_ID> --format json
# 传入 URL 也可:
# dws sheet export --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>" --format json
@@ -122,7 +110,7 @@ dws sheet export --node <NODE_ID> --output ./report.xlsx
# 场景 C:下载到目录,自动按链接推断文件名
dws sheet export --node <NODE_ID> --output ./
# 禁止在 Agent 侧实现任何轮询或重试,CLI 内部已按 2s/5s/10s/15s 渐进式退避自动完成(最多 30 次)。
# 禁止在 Agent 侧实现任何轮询或重试;命令会自动等待结果。
# 若命令返回失败或超时,直接告知用户稍后再试,不要自动重调 dws sheet export。
```
@@ -130,18 +118,18 @@ dws sheet export --node <NODE_ID> --output ./
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `export` | `downloadUrl`(未指定 --output)/ `outputPath`(指定 --output) | 直接下发给用户或告知文件已保存到本地。命令内部已完成轮询,不要再调用其他 export 相关命令 |
| `export` | `downloadUrl`(未指定 --output)/ `outputPath`(指定 --output) | 直接下发给用户或告知文件已保存到本地;不要再调用其他 export 相关命令 |
| `export` 超时中断 | 错误信息 | 直接报告失败或超时;当前没有独立续查命令,不自动重新提交导出 |
| `export-csv` | CSV 正文(未指定 --output,走 stdout)/ `导出完成: <path>`(指定 --output) | 直接把 CSV 交给下游处理,或告知文件已保存到本地。命令是同步的,无任务/轮询概念 |
## 注意事项
- ★ `export` 仅支持钉钉在线电子表格(axls)→ xlsx;传入钉钉文字文档会报 `invalidRequest.document.typeIllegal`
- ★ `export` 为单命令一站式,CLI 内部已自动完成「提交 → 渐进式退避轮询 → 可选下载」,**Agent 不得在外部实现轮询或重试**;命令返回成功后不再调用其他 export 相关命令
- ★ `export` 为单命令一站式,会自动等待结果并可选下载;**Agent 不得自行轮询或重试**,命令返回成功后不再调用其他 export 相关命令
- `export` 内置轮询策略:1~5 次间隔 2s、6~10 次间隔 5s、11~20 次间隔 10s、21~30 次间隔 15s,硬上限 30 次(约 5 分钟);超时后命令返回错误,告知用户稍后再试即可
- ★ `export` 命令返回失败或超时时,**禁止自动重调 `dws sheet export`**;直接告知用户导出失败并建议稍后再试
- `export` 未指定 `--output` 时,返回的 `downloadUrl` 具有时效性,获取后请尽快下载;若用户需要本地文件,优先直接传 `--output` 让 CLI 代为下载
- `export` 的 `--output` 可为文件路径或已存在目录;为目录时自动从 `downloadUrl` 推断文件名,为文件路径时直接按该路径保存
- 用户要求"导出表格/下载 xlsx"时,必须使用 `export` 单命令,禁止用 `range read` 读全量数据后自行拼 xlsx 模拟导出(服务端导出会保留格式/合并/公式等完整属性)
- 用户要求"导出表格/下载 xlsx"时,必须使用 `export` 单命令,禁止用 `range read` 读全量数据后自行拼 xlsx 模拟导出;`export` 会保留格式、合并、公式等属性
- `export-csv` 与 `export` 是两条独立命令:`export-csv` 同步导出**单个**工作表的纯值 CSV,不保留样式/合并/公式;要整篇工作簿或完整属性一律用 `export`
- ★ `export-csv` 遇到数据超出单次读取上限时默认报错、既不输出也不写文件;优先用 `--range` 分块导出,只有用户明确接受不完整数据时才加 `--allow-truncated`
@@ -173,7 +173,7 @@ Flags:
- `filter update` 前置:工作表必须已创建筛选
- `filter clear-criteria` 仅清除指定列的条件,不删除整个筛选。指定列无条件时不报错(幂等)
- `filter sort` 会实际改变数据行的物理顺序,不可撤销。前置:工作表必须已创建筛选
- ★ **筛选操作规范**(参照飞书 core-operations):
- ★ **筛选操作规范**:
- 当用户要求"筛选/只看/仅保留 X"时,**必须**通过 `filter create` / `filter update` 创建真实的筛选器。**禁止**用"删除不符合条件的行"或"新建工作表只放符合条件的行"来代替
- 创建/更新筛选后**必须** `filter get` 回读验证配置正确
- 更新已有筛选前先 `filter get` 读取当前配置,确认目标存在且了解现有条件后再操作
@@ -6,7 +6,7 @@
- 支持本地 `xlsx` / `xls`,文件上限 20MB
- `--folder-token` 与 `--workspace` 至少提供一个
- CLI 内部完成创建会话、上传文件、确认转换和轮询,Agent 不要自行拆分或重试
- `import create` 会自动上传文件、确认转换并等待结果,Agent 不要自行拆分或重试
- `drive upload` 只上传二进制文件,不会转换为可编辑的在线表格,不能替代本命令
## 命令
@@ -228,8 +228,8 @@ dws sheet write-image --node <NODE_ID> --sheet-id <SHEET_ID> --range B2:B2 --fil
## 注意事项
- ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node <NODE_ID> --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等)
- `media-upload` 是两步自动完成的流程 (获取附件上传凭证 → OSS 上传),无需手动分步操作
- `write-image` 是三步自动完成的流程 (获取附件上传凭证 → OSS 上传 → 写入图片到单元格),无需手动分步操作
- `media-upload` 会自动完成图片上传并返回后续命令需要的资源信息,无需手动拆分步骤
- `write-image` 会自动完成图片上传并写入目标单元格,无需手动拆分步骤
- ★ 向表格单元格中写入图片必须使用 `write-image`,禁止使用 `range update`。`range update` 不支持图片对象
- `write-image` 与 `media-upload` 的区别:`media-upload` 仅上传附件到表格获取 resourceId;`write-image` 在上传后还会将图片写入指定单元格
- `create-float-image` 创建浮动图片前必须先通过 `media-upload` 上传图片获取 `resourceUrl`,再将其作为 `--src` 传入。`--src` 的格式为 `/core/api/resources/img/...`,不能直接传外部 URL
@@ -191,7 +191,7 @@ Flags:
- 示例:`'Sheet1'!A1:D100`、`'销售数据'!A1:F500`
- 必须包含表头行(通常从第 1 行开始)
- sheet 名称用单引号包裹(含空格或特殊字符时必须)
- `source` 直接使用上述 A1 表示法;不要自行改写成内部对象结构
- `source` 直接使用上述 A1 表示法,不要改写成其他结构
## 高级功能示例
@@ -210,7 +210,7 @@ dws sheet pivot-table create --node <NODE_ID> \
}'
```
> collapse/show_data_as 为可选透传字段,CLI 和 MCP 层不做额外校验,直接传递给底层引擎处理。
> `collapse` / `show_data_as` 为可选字段;仅使用命令文档列出的取值,并按命令返回处理无效配置。
## 核心工作流
@@ -54,7 +54,7 @@ Flags:
- `rowIndices` — 行号映射数组(如 `[1,2,3]`)
- `hasMore` — 完整目标范围是否还有未返回数据。`true` 是部分成功,不能宣称已读完
- `truncationReasons` — 部分返回原因。`max_cells` 表示命中单次 30,000 单元格上限;`max_chars` 表示命中 CSV 字符上限,两者可同时出现
- `resolvedRange` — 仅在未传 `--range` 时返回,表示底层解析出的完整目标范围
- `resolvedRange` — 仅在未传 `--range` 时返回,表示本次读取所对应的完整目标范围
- `returnedRange` — 本次实际完整返回的精确范围;它不是服务端续读游标
`csv-get` 不返回合并单元格结构。若 CSV 中出现合并区域的非左上角单元格为空,不能据此判断该区域"无内容";需要先用 `dws sheet info --node <NODE_ID> --sheet-id <SHEET_ID> --format json` 读取 `mergedRanges`,再结合左上角单元格理解合并区域语义。
@@ -161,7 +161,7 @@ Flags:
| `dropdown` Inline | `enableMultiSelect: boolean` | 是否允许多选 |
| `dropdown` SourceRange | `sourceType: "sourceRange"` | 区域来源模式 |
| `dropdown` SourceRange | `sourceRange: {sheetId, a1Notation}` | 仅 `sourceRangeStatus:"valid"` 时返回的来源工作表和规范化 A1 区域;`invalid` 时省略 |
| `dropdown` SourceRange | `sourceRangeStatus: "valid" / "invalid"` | 底层引用是否可解析;无效引用仍返回配置,但不返回 `sourceRange` |
| `dropdown` SourceRange | `sourceRangeStatus: "valid" / "invalid"` | 来源引用是否有效;无效引用仍返回配置,但不返回 `sourceRange` |
| `checkbox` | `checked: boolean` | 当前勾选状态 |
SourceRange 模式不动态拉取或展开来源区域的候选值,因此不返回 `options` 或颜色。`invalid` 时只能依赖 `sourceRangeStatus` 判定需要重新选源,不得假定仍能从 `sourceRange` 取回旧坐标。
@@ -270,6 +270,7 @@ dws sheet range read --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:D10" --f
- ★ `csv-get` 和 `range read` 都必须检查 `hasMore`;`true` 时结合目标范围与 `returnedRange` 显式分批,CLI 不会自动续读
- ★ `forbidden.document.sizeOverLimit` 是工作簿装载失败,不是 30,000 单元格部分成功;缩小 `--range` 不能修复
- ★ `csv-get` / `range read` / `range get` 不返回合并单元格结构;查看合并范围必须用 `sheet info` 的 `mergedRanges`
- ★ 保护区域权限:`csv-get`、`table-get`、`range read`、`formula-verify` 等命令会检查本次实际读取范围。若返回权限错误,说明当前身份无法确认具有该范围的完整读取权限;不要解释为空数据或反复重试,应申请对目标内容的直接访问权限或请管理员执行。仅当任务本来就不需要受保护区域时,才可缩小 `--range` 避开无关区域,禁止拆分范围绕过权限。
- `table-get` 返回 table/dataframe 结构,适合需要 `columns` / `data` / `dtypes` / `formats` 的场景;它不返回 per-cell 元数据,也不替代 `range read`
- ★ 大整数和长数字标识符回读校验:精确 ID 应是字符串 / `object`;若看到 `float64`,说明它已经按数值路径处理,超过 `9007199254740991` 时可能已丢精度
- `range read` 遇到超时或响应过慢时,应缩小 `--range` 查询范围,**单次读取的单元格数量建议控制在 5000 个以内**;数据量较大时通过 `info` 的 `nonEmptyRange.range` 获取 A1 边界后分批读取,避免不传 `--range` 直接读取整个大工作表
@@ -0,0 +1,306 @@
# 工作簿 revision 与 changeset
## 何时使用
当用户要知道在线电子表格当前 revision,或要复核两个 revision 之间提交了哪些变更、是否发生撤销或整表状态替换时使用。
- 当前 revision → `dws sheet revision-get`
- revision 区间内的变更 → `dws sheet changeset-get`
- 可保存、展示或回滚的历史版本 → [sheet-version](./sheet-version.md)
- 当前单元格值、公式或工作簿结构 → 使用对应 read/list/get 命令
两条命令都是工作簿级能力,只接受 `--node`,不接受 `--sheet-id`。`--node` 可传在线电子表格文档 ID 或完整 URL;`spreadsheetv2` URL 必须原样传入。
revision 表示工作簿的编辑进度;历史版本是选定的保存或回滚点。`version list` 返回的 `version` 可以作为 changeset 查询锚点,但相邻历史版本之间可能包含多个 revision。
当用户明确要求恢复到某个精确 revision 时,可以把已确认属于同一工作簿的 revision 传给 `version revert --version`,即使它没有出现在 `version list` 中。默认仍应从版本列表选择稳定的历史版本;未列入列表的 revision 只有在服务端仍可恢复时才会成功,禁止猜测或自动改用相邻 revision。具体安全流程见 [sheet-version](./sheet-version.md)。
## 获取当前 revision
```bash
dws sheet revision-get --node <NODE_ID_OR_URL> --format json
```
成功时从统一输出的 `data.revision` 读取当前 revision:
```json
{
"ok": true,
"outcome": "success",
"data": {
"success": true,
"logId": "trace-id",
"revision": 142
}
}
```
- `revision=0` 是合法的空工作簿基线。
- revision 不是工作表数量,也不是历史版本列表序号。
- `logId` 仅用于失败反馈和问题排查,不是 revision。
## 获取 revision 区间 changeset
```bash
dws sheet changeset-get \
--node <NODE_ID_OR_URL> \
--start-revision 120 \
--end-revision 140 \
--format json
```
也可以省略 `--end-revision`,查询到本次请求开始时的最新 revision:
```bash
dws sheet changeset-get \
--node <NODE_ID_OR_URL> \
--start-revision 120 \
--format json
```
参数规则:
- `--start-revision` 必填,必须为非负整数;`0` 合法。
- `--end-revision` 可选;提供时必须大于或等于 start。
- 查询区间固定为 `(startRevision, endRevision]`:不包含 start,包含 end。
- `startRevision == endRevision` 合法,返回空 `changesets`。
- 单次最多查询 20 个 revision,即 `endRevision - startRevision <= 20`;更大区间必须连续分段查询。
- 省略 end 时,本次响应会固定一个 `endRevision`;查询期间产生的新 revision 不进入本次结果。
- 无法完整取得所请求区间时整次失败,不会把缺失的 revision 当作成功结果。
成功后直接读取统一输出中的 `data.changesets` 数组。DWS 已把它转换为可遍历的 JSON 对象,不需要额外解析。
## 先看区间摘要
`data.summary` 用于快速判断本次结果是否完整以及哪些工作表受到影响:
| 字段 | 含义 |
|------|------|
| `changeCount` | `changes[]` 中变更对象的总数 |
| `completeChangeCount` | 详情完整的变更数量 |
| `partialChangeCount` | 只返回部分可信详情的变更数量 |
| `unsupportedChangeCount` | 当前无法解释的变更数量 |
| `containsStateReset` | 区间内是否发生整表状态替换 |
| `containsIncompleteChanges` | 是否存在不完整或无法解释的详情 |
| `affectedSheets` | 受影响工作表及其 A1 范围摘要 |
`affectedSheets[]` 常用字段:
| 字段 | 含义 |
|------|------|
| `sheetId` | 工作表 ID |
| `sheetName` | 可用时的工作表名称;缺失时不要从 ID 猜测 |
| `ranges` | 去重后的 1-based A1 范围;工作表级变更可为空数组,整行/整列可表示为 `1:3` / `A:C` |
摘要用于定位,不是完整变更副本。要回答改了什么,仍需遍历 `changesets[].changes[]`。
## 读取 `changesets[]`
每个 changeset 对应一个 revision:
| 字段 | 含义 | 使用规则 |
|------|------|----------|
| `revision` | 该事件对应的 revision | 按升序解释 |
| `createTime` | 事件创建时间 | 时间仅用于说明,顺序以 revision 为准 |
| `isSelfEdit` | 是否由当前请求用户提交 | `false` 不能用于识别具体编辑者,也不能判断是否由人工或自动化产生 |
| `eventType` | `EDIT`、`UNDO` 或 `STATE_RESET` | 先按事件类型分流 |
| `detailsStatus` | `COMPLETE`、`PARTIAL` 或 `UNAVAILABLE` | 决定能否把详情当作完整描述 |
| `changes` | 本 revision 的变更数组 | `STATE_RESET` 时为空数组 |
| `reset` | 整表状态替换信息 | 只在 `STATE_RESET` 时读取 |
事件类型:
- `EDIT`:一次普通编辑提交产生的效果。
- `UNDO`:一次撤销提交产生的效果。只描述这次撤销实际改变了什么,不要反推被撤销操作的完整原貌。
- `STATE_RESET`:工作簿整体状态被替换。不要跨过该事件拼接推导完整状态;需要重新读取工作簿。
changeset 描述的是该 revision 提交的效果,不是统一的变更前/变更后快照,也不保证仍是当前最终状态。
## 读取 `reset`
| 字段 | 含义 |
|------|------|
| `type` | 状态替换类型:`ROLLBACK`、`OVERWRITE`、`UPGRADE`、`TEMPLATE`、`PRETTIFY` 或 `UNKNOWN_RESET` |
| `targetRevision` | 可确认时的目标 revision;`0` 是合法空基线 |
| `targetStatus` | `KNOWN` 表示目标已返回;`NOT_APPLICABLE` 表示该事件没有目标 revision;`UNAVAILABLE` 表示目标无法确认 |
当 `targetStatus=UNAVAILABLE` 时,禁止把目标猜成 `revision-1`。即使目标 revision 已知,也要通过读取命令确认需要的当前内容。
回滚完成后,可用回滚前后的 revision 查询 changeset:若返回 `STATE_RESET`、`reset.type=ROLLBACK` 且 `reset.targetRevision` 等于请求目标,表示回滚事件指向了该 revision;工作簿内容是否符合预期仍需独立回读。
## 读取 `changes[]`
每个 change 都按以下顺序读取:
1. `type`:发生了哪类变更。
2. `targets`:变更作用在哪个工作簿、工作表或范围。
3. `detailsStatus`:详情是否完整。
4. `details`:本次变更的可用详情。
5. `omissions`:哪些详情未能提供。
公共字段:
| 字段 | 含义 |
|------|------|
| `type` | 语义变更类型,见下方字典 |
| `targets` | 工作簿、工作表或 A1 范围定位数组 |
| `details` | 与 `type` 对应的变更详情;无详情时为空对象 |
| `detailsStatus` | `COMPLETE`、`PARTIAL` 或 `UNAVAILABLE` |
| `omissions` | 未提供详情的原因;没有省略时为空数组 |
`detailsStatus` 的处理规则:
- `COMPLETE`:已完整解释本类型可提供的本次变更详情,但不代表包含旧值或当前值。
- `PARTIAL`:已返回字段可信,同时存在缺失;回答时要明确“仅确认到这些内容”。
- `UNAVAILABLE`:不能可靠说明具体详情;只报告事件、类型和可信位置,不猜内容。
`omissions[].fields` 可指出受影响的详情字段。`omissions[].code` 只用于判断存在何种缺口;面向用户时翻译成“部分详情未提供”“公式文本不可用”“位置无法确认”等自然语言,不需要展示内部原因码。
## 读取 `targets[]`
| 字段 | 含义 |
|------|------|
| `scope` | `WORKBOOK`、`SHEET` 或 `RANGE` |
| `sheetId` | 工作表 ID |
| `sheetName` | 可用时的工作表名称 |
| `sheetNameSource` | 名称是变更时名称、当前名称或无法确认 |
| `a1Range` | 1-based A1 范围 |
| `role` | `AFFECTED`、`SOURCE` 或 `DESTINATION`;缺失时按 `AFFECTED` 处理 |
- `SOURCE` 是读取或复制来源,不表示该位置被修改。
- `DESTINATION` 是写入目标,应算作受影响位置。
- `WORKBOOK` 级 target 通常没有工作表或 A1 范围,不要补造。
- `sheetName` 缺失或来源无法确认时,优先使用 `sheetId` 定位并按需调用 `sheet list`。
## 变更类型字典
### 行列与工作表
| type | 含义 | 常用 `details` |
|------|------|----------------|
| `ROWS_INSERTED` / `COLUMNS_INSERTED` | 插入行或列 | `startNumber`、`count`、`styleSource?` |
| `ROWS_DELETED` / `COLUMNS_DELETED` | 删除行或列 | `startNumber`、`count` |
| `ROWS_UPDATED` / `COLUMNS_UPDATED` | 更新行列属性 | `startNumber`、`count`、`properties?`、`relativeChanges?`、`replace?` |
| `SHEET_CREATED` | 创建工作表 | `position?`、`properties?`、`copiedFromSheetId?` |
| `SHEET_DELETED` | 删除工作表 | 通常由 target 定位 |
| `SHEET_UPDATED` | 更新工作表名称、位置或属性 | `position?`、`properties?` |
| `CUSTOM_TAB_ADDED` / `CUSTOM_TAB_DELETED` / `CUSTOM_TAB_UPDATED` | 新增、删除或更新自定义 Tab | `tabId`、`position?`、`properties?` |
`startNumber` 和 `position` 为 1-based。行列 `properties` 常见字段包括 `size`、`hidden`、`sticky`、`cellType`;工作表属性常见字段包括 `name`、`hidden`、`tabColor`、冻结行列数和默认行列尺寸。只解释实际返回的字段。
### 单元格与范围
| type | 含义 | 常用 `details` |
|------|------|----------------|
| `CELLS_INSERTED` / `CELLS_DELETED` | 在 target 内插入或删除单元格并平移 | `axis` |
| `RANGE_PASTED` | 将来源内容粘贴到目标范围 | `pasteMode`、`isCut`、`step?`、`includedParts?`、`contentPattern?` |
| `RANGE_AUTOFILLED` | 自动填充目标范围 | `fillMode`、`copyStyle`、`step?` |
| `RANGE_CLEARED` | 清除范围内指定内容 | `clearParts?`、`preservedCellTypes?` |
| `RANGE_CONTENT_SET` | 对整个范围写入相同内容或公式 | `cell?`、`replaceMode?` |
| `RANGE_BORDER_SET` | 设置范围边框 | `border?`、`borderSides?` |
| `RANGE_STYLE_SET` | 设置范围样式 | `style`、`replace?` |
| `RANGE_TAG_SET` | 设置或清除范围标签 | `tag?`、`replace?` |
| `RANGE_SORTED` | 对范围排序 | `sortByRange?`、`beforeOrder?`、`afterOrder?`、`indexesAreRelative` |
| `CELLS_CONTENT_SET` | 按相对位置分别修改内容 | `relativeChanges`、`replaceMode?` |
| `CELLS_STYLE_SET` | 按相对位置分别修改样式 | `relativeChanges?`、`styles?`、`styleMode?`、`replace?` |
| `CELLS_TAG_SET` | 按相对位置分别修改标签 | `relativeChanges`、`replace?` |
粘贴和自动填充的 `SOURCE` target 是来源,`DESTINATION` target 是写入位置。自动填充的 `DESTINATION` 可能是包含 `SOURCE` 的完整填充跨度;重叠部分不表示来源值发生了变化。描述新增填充区域时使用 `DESTINATION` 减去 `SOURCE`,核对最终结果时仍回读完整 `DESTINATION`。`step.rows` / `step.columns` 表示重复模式的大小。`contentPattern.cells[]` 使用 `rowOffset` / `columnOffset` 表示相对模式左上角的位置,并可返回写入的 `value`、`formula`、`cellType` 或 `link`。
`RANGE_SORTED.beforeOrder` / `afterOrder` 只列实际换位的行或列,未移动的位置会省略;数组短于 target 的行列数不代表详情缺失。`indexesAreRelative` 决定这些索引是相对 target 还是工作表绝对索引。
`relativeChanges[]` 中的 `rowOffset` / `columnOffset` 都是相对 target 左上角的 0-based 偏移,不是工作表绝对行列号;必须与 target 起点相加后再换算成 A1。
### 分组、数据验证和结构对象
| type | 含义 | 常用 `details` |
|------|------|----------------|
| `DIMENSION_GROUP_ADDED` / `DIMENSION_GROUP_REMOVED` / `DIMENSION_GROUP_UPDATED` | 新增、删除或更新行列分组 | `axis`、`startNumber`、`count`、`depth?`、`collapsed?` |
| `DATA_VALIDATION_SET` | 设置数据验证规则 | `ruleId?`、`dataValidation` |
| `DATA_VALIDATION_CLEARED` | 清除数据验证规则 | `ruleId?` |
| `CELLS_MERGED` / `CELLS_UNMERGED` | 合并或取消合并单元格 | `featureId?`、`effectless?` |
| `NAMED_RANGE_SET` | 设置命名范围 | `name`、`baseSheetId?`、`formula?` |
| `NAMED_RANGE_CLEARED` | 清除命名范围 | `name`、`baseSheetId?` |
| `FEATURE_ADDED` / `FEATURE_DELETED` / `FEATURE_UPDATED` | 新增、删除或更新筛选、评论、透视表、保护范围等扩展对象 | `featureType`、`featureId?` |
扩展对象通常只返回类型、标识和可信 target,不保证包含完整配置。用户询问当前配置时,调用该对象对应的 list/get 命令。
`dataValidation` 常用字段:
| 字段 | 含义 |
|------|------|
| `type` | 验证规则类型 |
| `sourceType` | 下拉来源:`inline` 或 `sourceRange` |
| `options` | 内联下拉选项数组,每项包含 `value` 和可选 `color` |
| `sourceRange` | 已确认的来源工作表与 A1 范围 |
| `sourceRangeStatus` | `RESOLVED`、`UNRESOLVED` 或 `INVALID` |
| `sourceRangeExpression` | 可读的来源表达式 |
| `enableMultiSelect` | 是否允许多选 |
| `criteria` | 非下拉规则的运算符和条件值 |
| `settings` | 是否允许空值、是否显示提示、错误提示等行为设置 |
只有 `sourceRangeStatus=RESOLVED` 时,才能把 `sourceRange` 当作已确认范围;否则说明来源未解析或无效,并在需要时回读当前规则。
### 工作簿设置与其他变更
| type | 含义 | 常用 `details` |
|------|------|----------------|
| `WORKBOOK_SETTING_UPDATED` | 更新工作簿设置 | `setting`、`changes` |
| `EXTERNAL_REFERENCES_REPLACED` | 替换外部引用 | `referenceCount` |
| `UNSUPPORTED_CHANGE` | 存在当前无法可靠解释的变更 | `category?` |
`WORKBOOK_SETTING_UPDATED` 的 `setting` 可表示计算设置或背景设置;只解释 `changes` 中实际返回的字段。遇到 `UNSUPPORTED_CHANGE` 时,只说明存在未能解释的变更,不根据相邻事件猜测其内容。
## 常用详情值
### 单元格内容
`cell` 常见字段:
- `value`:带类型的值;`kind` 可为 `STRING`、`NUMBER`、`BOOLEAN`、`NULL` 或 `MULTI`,分别读取对应的 `stringValue`、`numberValue`、`booleanValue` 或 `values`。
- `formula`:本次写入的可读公式。
- `formulaCleared=true`:本次清除了公式。
- `cellType`:`general`、`checkbox` 或 `select`;select 可带 `options`。
- `link`:可用时返回工作簿内范围链接及目标 `sheetId` / `a1Range`。
### 清除、null 与字段缺失
- 对象整体为 `{ "cleared": true }`:本次清除了该对象。
- `formulaCleared=true`:本次清除了公式。
- `styleCleared=true`:本次清除了该相对位置的样式。
- 对象内某个已知属性为 `null`:本次清除该属性或恢复默认。
- 字段缺失:本次未提供或不适用;若状态不是 `COMPLETE`,结合 `omissions` 判断缺口。
不要把整体清除、单属性清除和字段缺失混为一谈,也不要补造清除前内容。
## Agent 解读顺序
1. 检查统一输出 `ok=true`、`outcome=success` 和 `data.success=true`。
2. 直接遍历 `data.changesets`,不要把它当作字符串。
3. 用 `startRevision` / `endRevision` 准确表述 `(start,end]`。
4. 先看 `summary.containsStateReset` 和 `summary.containsIncompleteChanges`。
5. 每个事件先看 `eventType`;`STATE_RESET` 读 `reset`,`EDIT` / `UNDO` 再读 `changes`。
6. 每条 change 按 `type -> targets -> detailsStatus -> details -> omissions` 解读。
7. `PARTIAL` 只使用已返回字段并说明缺口;`UNAVAILABLE` 和 `UNSUPPORTED_CHANGE` 不猜。
8. `isSelfEdit=false` 不用于归因具体用户、系统或自动化。
9. 用户问“现在是什么”时,按 target 使用对应 read/list/get 命令回读。
10. 看到可用 revision 不代表已获准回滚;只有用户明确要求并确认覆盖风险后,才按 [sheet-version](./sheet-version.md) 执行 `version revert`。
## 最终状态回读
changeset 用于解释“某个 revision 提交了什么”,不是“当前最终是什么”。
- 值和公式:用 `csv-get`、`range read` 或 `table-get` 回读目标范围。
- 行列、分组、隐藏和尺寸:用 `sheet info --include ...` 或对应结构读取命令。
- 数据验证、筛选、评论、透视表等对象:用对应 list/get 命令回读。
- 粘贴或自动填充:优先回读 `DESTINATION`,不要把 `SOURCE` 当作已修改范围。
- `STATE_RESET`:重新读取工作簿当前状态。
- 分段查询时,只要任一段失败或包含不完整变更,就不能声称整个跨度已完整解释。
## 常见失败处理
- revision 非法或超出范围:先调用 `revision-get`,再修正或分段查询。
- 区间内容不可用:说明无法获得完整 changeset,不要解释为空 changeset。
- changeset 过大:缩小 revision 区间;不要假设结果会自动截断。
- 读取权限不足或无法确认受保护内容的完整读取权限:申请对目标内容的直接访问权限,或请管理员执行;不要把权限错误解释为空数据,也不要拆分范围绕过权限。
- `containsIncompleteChanges=true`:可以报告已确认部分和明确缺口,不能声称所有改动均已完整解析。
@@ -99,7 +99,7 @@ Flags:
- `--font-line` 为单选:`underline` 置下划线、`line-through` 置删除线、`none` 清除两者
- 至少需传入一个样式参数。单次调用建议:行数 ≤ 1000,单元格总数 ≤ 5000(服务端硬限 30000)
- 枚举值按驼峰书写:`autoWrap`、`bold`、`normal`、`center`、`italic` 等
- 底层走 `set_cell_range` 的 `cellStyles`(仅设样式、保留原值),因此**纯样式设置可直接作用于含合并单元格的区域**,无需先取消合并
- 纯样式设置只修改样式并保留原值,因此可直接作用于含合并单元格的区域,无需先取消合并
### 批量设置单元格样式(服务端原子事务)
```
@@ -161,7 +161,7 @@ Flags:
- 所有项在下发前先做本地校验(每项与 `set-style` 一致:至少一项样式字段 + rows ≤ 1000 + rows×cols ≤ 30000 + 枚举合法),**任一项校验不过即整批不下发**
- 批量上限:最多 **100 个区域**,且**所有区域累计不超过 200000 个单元格**(一次请求要把全部单元格矩阵建好,累计量才是真正的峰值约束)。超限同样是下发前本地报错、整批不执行;请拆成多次调用
- `--continue-on-error` 透传给服务端:遇失败继续执行其余项(软批量)
- 底层每项走 `set_cell_range` 的 `cellStyles`/`borderStyles`,因此纯样式批量同样可作用于含合并单元格的区域
- 批量纯样式设置同样只修改样式并保留原值,可直接作用于含合并单元格的区域
### 合并单元格
```
@@ -304,7 +304,7 @@ dws sheet merge-cells --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:C3" --m
- ★ `range update` / `range set-style` / `range batch-set-style` 单次调用上限(强制):行数 ≤ 1000,单元格总数(行×列)建议≤ 5000(服务端硬限 30000);超限请拆分多次调用。CLI 会在调用前做本地预校验,服务端超 30000 会直接报错
- `range set-style` / `range batch-set-style` 的样式枚举按驼峰书写:`wordWrap` 取 `overflow`/`clip`/`autoWrap`,`fontWeight` 取 `bold`/`normal`,`hAlign` 取 `left`/`center`/`right`/`general`,`vAlign` 取 `top`/`middle`/`bottom`,`fontStyle` 取 `normal`/`italic`;背景色/字体颜色统一使用 `#RRGGBB` 格式
- `--font-line` 取 `none`/`underline`/`line-through`(单选,非驼峰):`underline` 置下划线、`line-through` 置删除线、`none` 同时清除;`--font-family` 传字体族名(如 `Arial`/`微软雅黑`);`--font-style`/`--font-line`/`--font-family` 均为整区共用标量,无 `*-json` 逐格形式
- `range set-style` / `range batch-set-style` 底层走 `set_cell_range` 的 `cellStyles`(仅设样式、保留原值);纯样式设置可直接作用于**含合并单元格**的区域,无需先取消合并(写值到合并区仍会被拦截)
- `range set-style` / `range batch-set-style` 只修改样式并保留原值;可直接作用于**含合并单元格**的区域,无需先取消合并(写值到合并区仍会被拦截)
- `range update` 支持通过 `cellStyles` 在写值时附带 per-cell 样式,适合少量单元格写值 + 样式一步到位的场景。批量设置整片区域的统一样式时,仍应使用 `set-style` / `batch-set-style`
- `merge-cells` 合并时只保留左上角单元格的值,其他单元格的值会被丢弃
- `merge-cells` 的 `--merge-type` 不传时默认为 `mergeAll`(合并所有单元格)
@@ -5,10 +5,10 @@
管理钉钉在线电子表格的历史版本快照。当用户说"保存版本/存个快照/看历史版本/版本列表/回滚到某个版本/恢复到之前的表格"时使用。
- 手动保存当前表格为一个版本快照 → `version save`
- 查看表格的历史版本列表(含版本号、名称、创建人、创建时间) → `version list`(别名 `ls`)
- 把表格回滚到指定历史版本 → `version revert`(危险操作,默认需二次确认)
- 查看表格的历史版本列表 → `version list`(别名 `ls`)。返回不含版本名称;列表项包含 `version`、`type`、`userId`、`createTime`、`updateTime`、`editorList` 等服务端字段
- 把表格回滚到指定历史版本或已确认的精确 revision → `version revert`(危险操作,默认需二次确认)
三个命令统一用 `--node` 指定表格文档(ID 或 URL)。回滚用的**版本号 `--version` 从 `version list` 的返回中获取**,禁止臆测。
三个命令统一用 `--node` 指定表格文档(ID 或 URL)。默认从 `version list` 选择稳定的历史版本。用户明确要求恢复到某个精确 revision 时,也可把已从同一工作簿真实查询结果确认的 revision 传给 `--version`,即使它没有出现在版本列表中。禁止猜测版本号或 revision。
## 命令详细参考
@@ -38,32 +38,47 @@ Flags:
--limit int 返回版本数量上限 (可选)
--cursor string 分页游标 (可选,游标分页)
```
返回表格的历史版本列表;回滚前先用它拿到目标 `version`(版本号)。
返回表格的历史版本列表;顶层包含 `versions`、`nextCursor`、`hasMore`,列表项不含 `name`。回滚前先从列表项拿到真实 `version`(版本号)。
### 回滚表格到指定版本
```
Usage:
dws sheet version revert [flags]
Example:
dws sheet version revert --node SHEET_ID --version 3 --yes
dws sheet version revert --node SHEET_ID --version 3
Flags:
--node string 表格文档 ID 或 URL (必填)
--version int 目标版本号 (必填,从 version list 获取)
--version int 目标历史版本或已确认 revision (必填,通常从 version list 获取)
```
把表格回滚到指定历史版本。**危险操作**:会覆盖当前内容,默认弹出二次确认;AI Agent 或脚本执行时可加全局 `--yes` 跳过确认。
把表格回滚到指定历史版本或精确 revision。**危险操作**:会覆盖当前内容,默认要求二次确认。本文不提供带 `--yes` 的可复制示例;执行器只有在当前流程中向用户展示完整目标参数和覆盖风险、并获得明确确认后,才可动态追加全局 `--yes`。
`version list` 只列选定的保存或回滚点,并不包含每一个 revision。未列入版本列表的 revision 只有在服务端仍可恢复时才能回滚成功;过旧或内容不可用时应直接报告失败,禁止改猜相邻 revision 重试。
### 精确 revision 回滚
只有用户明确要求恢复到某个 revision 时才走此流程:
1. 用 `revision-get` 记录回滚前的当前 revision。
2. 目标 revision 必须来自同一工作簿的真实查询结果,或由用户明确提供;不要根据次数、时间或相邻版本推算。
3. 向用户展示工作簿、目标 revision 以及“当前内容将被覆盖”的风险,然后停止并等待明确确认;确认前禁止调用回滚工具,也禁止预先添加全局 `--yes`。
4. 只有用户对当前展示的参数明确确认后,执行器才可对同一条命令动态追加全局 `--yes` 并执行;任一参数发生变化都必须重新确认。
5. 用 `csv-get`、`range read` 或其他对应读取命令回读所需内容。
6. 需要审计回滚事件时,用回滚前后的 revision 查询 `changeset-get`,确认出现 `STATE_RESET`,且 `reset.targetRevision` 等于目标 revision。
## 上下文传递
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `version list` | `version`(版本号) | 作为 `version revert --version` 的入参 |
| `revision-get` / `changeset-get` | 已确认属于同一工作簿的 `revision` | 用户明确要求精确恢复时,作为 `version revert --version` 的入参 |
| `version save` | 版本快照结果 | 确认已生成快照 |
| `version revert` | 回滚结果 | 确认回滚完成,回读表格内容验证 |
## 注意事项
- ★ 回滚 `version revert` 会**覆盖当前表格内容**,属危险操作,默认二次确认;确认无误再执行,脚本/Agent 场景加 `--yes`
- ★ `--version` 必须来自 `version list` 的真实返回,禁止臆测版本号
- 底层复用 doc 域的历史版本能力(`save_doc_version` / `list_doc_versions` / `revert_doc_version`),`--node` 传在线电子表格文档即可
- ★ 回滚 `version revert` 会**覆盖当前表格内容**,属危险操作;确认前禁止调用工具。只有用户对当前完整参数明确确认后,执行器才可动态追加全局 `--yes`;任一参数变化都必须重新确认
- ★ 默认从 `version list` 选择历史版本;只有用户明确要求精确 revision 时,才使用同一工作簿真实查询结果中已确认的 revision
- ★ 未列入版本列表的 revision 不保证仍可恢复;失败时直接说明,不猜测其他 revision,不自动重试
- ★ `version list` 返回的 `version` 可作为 `changeset-get` 的 start/end 锚点,但相邻历史版本之间可能包含多个 revision。需要查看逐次变更时读 [sheet-revision-changeset](./sheet-revision-changeset.md)
- 回滚后应用独立读命令(`csv-get` / `range read`)回读确认,避免"写返回不等于完成"
@@ -70,12 +70,12 @@ Flags:
--styles string 建表时一并应用的视觉处理 JSON(需与 --values 或 --sheets 搭配)
```
建表并写入初始数据的多步编排:建文档 → 探活 → 定位默认工作表 → 写数据 → 回读校验 →(可选)应用样式。**只要一个空表格请用 `dws sheet create`**(单次调用);本命令必须给数据。
本命令会创建表格、写入初始数据并回读校验,还可选择应用样式。**只要一个空表格请用 `dws sheet create`**;本命令必须给数据。
**写入初始数据**(`--values` 与 `--sheets` **二选一,必须给一个**):
- `--values`:二维 JSON 数组,裸值写入默认工作表 A1 起。适合单表快速建表,无表头/类型语义,内部复用 csv-put 通道,自动识别数字/布尔。单元格只能是字符串/数字/布尔/null;上限 30000 单元格、编码为 CSV 后 2000000 字符。
- `--sheets`:typed table 数组,一次创建多个带数据的工作表,内部复用 table-put 通道。每项形如
- `--values`:二维 JSON 数组,裸值写入默认工作表 A1 起。适合单表快速建表,无表头/类型语义,并自动识别数字/布尔。单元格只能是字符串/数字/布尔/null;上限 30000 单元格、编码后 2000000 字符。
- `--sheets`:typed table 数组,一次创建多个带数据的工作表。每项形如
`{"name":"表名","columns":["列1","列2"],"data":[[...]],"dtypes":{...},"formats":{...},"cellStyles":[...]}`;
`name`、`columns` **必填**。第一个条目写入默认工作表(自动重命名为其 `name`,避免残留空表),其余按 `name` 自动新建。
- 字段名为 camelCase,只接受 `name` / `columns` / `data` / `dtypes` / `formats` / `cellStyles` / `startCell` / `mode`(`overwrite`|`append`) / `header` / `allowOverwrite`;**未知键与 snake_case 变体一律拒绝**(服务端会静默丢弃写错的键,`{"datas":[...]}` 会写出一张只有表头的表却报成功)
@@ -104,7 +104,7 @@ Flags:
**行为要点**:
- 所有 JSON 结构、字段类型与枚举都在**创建文档之前**校验(`--sheets` 按 table-put 的输入契约逐字段校验),非法配置直接失败,不会留下白建的空文档
- 创建后 CLI 会先探活(新建文档服务端仍在初始化,此时写入可能返回成功但不落盘),再写数据
- 命令会等待新建表格可写后再写入数据,并通过回读确认结果
- 写完会回读**首个预期非空单元格**校验确实落盘(不是死盯 A1:`--sheets` 会按 `startCell` / `header` / `mode` 推算实际写入位置);若未落盘会报错并提示用 `csv-put` / `range update` / `table-put` 补写
- 报错信息里始终带上已创建的 `nodeId`,便于在部分成功时继续操作同一份文档
+16 -3
View File
@@ -8,14 +8,27 @@ OpenNodes V1 的完整字段、节点类型、目录枚举和错误语义按需
`geometry`、`catalogId` 等枚举值。渐变卡片、Frame 分支、SVG/Vector 等完整
工作流见 [常用 Recipes](./whiteboard/recipes.md)。
<!-- VISIBLE_SHORTCUTS_START -->
## Shortcuts(无专用脚本/recipe 时优先)
以下 shortcut 同时进入公开 catalog 与 Runtime Schema。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。命令已选中时直接执行;只在参数或安全语义不确定时读取 Agent leaf Schema(例如 `dws schema --cli-path "whiteboard +<shortcut>" --compact --format json`),在当前 Cobra flags 不确定时读取 `dws whiteboard <shortcut> --help`。只有参数映射、接口绑定或 provenance 审计才省略 `--compact`。仅当现有路由和 reference 都无法定位低频能力时,才用 `dws shortcut list --service whiteboard --format json` 批量发现。
| Shortcut | 风险 | 适用场景 |
|---|---|---|
| `dws whiteboard +query` | read | 严格读取已有文档白板的 OpenNodes 快照 |
| `dws whiteboard +update` | high-risk-write | 确认后更新白板并按同一稳定目标精确读回 |
<!-- VISIBLE_SHORTCUTS_END -->
## 标准流程
1. 从用户输入或真实文档 JSONML 取得 `nodeId` 和 card `metadata.id`(partId)。
2. `dws whiteboard query --node <DOC_ID> --part-id <PART_ID> --format json` 保存当前内容。
2. `dws whiteboard +query --node <DOC_ID> --part-id <PART_ID> --format json` 保存并严格校验当前内容。
3. 生成 OpenNodes V1 文件;不能把 query 响应直接回写。
4. 向用户展示写入范围并取得确认。
5. `dws whiteboard update --node <DOC_ID> --part-id <PART_ID> --source <FILE> --yes --format json`。
6. 再次 query 验证节点、层级和连接关系。
5. `dws whiteboard +update --node <DOC_ID> --part-id <PART_ID> --source <FILE> --format json`;该 shortcut 会验证终态 receipt,并按同一白板执行独立读回。
6. 仅在需要人工审阅完整节点细节时,再执行一次 `+query`。
执行层只能在已取得本次写入确认后添加 `--yes`;可复制的存储示例不得预置该参数。
更新文件:
@@ -52,6 +52,8 @@ dws doc +version-revert --node <DOC_ID> --version <N> --format json
`+version-save/list/revert` 分别用于快照、浏览和恢复,命中后直接执行,不预读 Help。`+history-*` 仅兼容已有调用,不用于新的 Agent 选路。重要内容更新优先使用 `+checkpoint-update`,不要手工编排保存、写入和回读。回滚必须确认,以 leaf Schema 与 Runtime gate 为准。
只读某个历史版本的内容时,用 `dws doc +fetch --node <DOC_ID> --version <N>`(版本号同样来自 `+version-list`,`0` 表示初始版本,需要文档编辑权限);整体恢复到历史版本才用 `+version-revert`(危险操作,需确认)。互联网公开文档(含密码保护)的读取见 [doc-read.md](doc/doc-read.md) 的 `--password`。
## 权限与分享
- 查询或聚合权限:`+inspect --include-permissions`。
@@ -5,6 +5,8 @@
```bash
dws doc +fetch --node <DOC_ID_OR_URL> --format json
dws doc +fetch --query "项目周报" --scope keyword --keyword "结论" --format json
dws doc +fetch --node <PUBLIC_URL> --password <ACCESS_PASSWORD> --format json
dws doc +fetch --node <DOC_ID> --version <VERSION> --format json
```
- 已知 ID 或 URL:传 `--node`。
@@ -12,6 +14,13 @@ dws doc +fetch --query "项目周报" --scope keyword --keyword "结论" --forma
- `--node` 与 `--query` 必须且只能提供一个。
- 默认 `--detail simple --scope full`,适合普通阅读,避免加载不必要的 JSONML。
## 互联网公开文档与历史版本
- **互联网公开文档**:公开链接直接传 `--node`;文档开启了密码保护时,通过 `--password <ACCESS_PASSWORD>` 提供访问密码,普通文档无需传入。密码只进入读取请求,不会回显在读取结果里(`--dry-run` 预览会包含所传参数,注意输出环境)。
- **历史版本**:`--version <N>` 读取指定历史版本内容;版本号从 `dws doc +version-list` 获取,`0` 表示文档初始版本,需要文档编辑权限(EDITOR 及以上);缺省读最新版。注意区分:`revision` 是文档编辑版本号(JSONML 读取响应返回、供 `+update --expected-revision` 条件写使用),不是历史版本号,`+fetch` 不支持 `--revision`。
- **跨组织文档**:非互联网公开的跨组织文档,`+fetch` 整体被组织边界拦截(提示「不支持跨组织访问数据」),最新版与历史版本内容都读不到;互联网公开(含密码)文档的 `+fetch` 不受影响,但 `+version-list` 与写操作仍会被拦截,版本号无法从列表获取。
- 只要看历史内容、不打算恢复时用 `--version`;要把文档整体恢复到历史版本才用 `+version-revert`(危险操作,需确认)。
## 局部读取
```bash
@@ -70,6 +70,7 @@ Agoal 是钉钉目标管理工具,支持战略解码、经营合约、计分
| `scorecard detail` | 获取计分卡详情 | `--selected-time` `--dept-id` | selectedTime 为 ISO-8601 字符串,如 `"2026-01-01T00:00:00+08:00"` |
| `scorecard entity-detail` | 获取计分卡实体详情 | `--sc-id` `--entity-id` | 根据计分卡 id 和实体 id 查询 |
| `scorecard update` | 更新计分卡 | `--dept-id` `--selected-time` `--id` `--tracking-period-type` `--content` | trackingPeriodType: MONTHLY/QUARTERLY |
| `scorecard search-entities` | 搜索计分卡指标与关键事项 | `--keyword` | 可选 `--page`、`--page-size`;keyword 为标题模糊匹配关键词 |
#### selectedTime 时间说明
@@ -163,6 +164,7 @@ items 每项包含:
- 查看详情 → `scorecard detail`
- 实体详情 → `scorecard entity-detail`
- 修改/更新 → `scorecard update`
- 搜索计分卡指标与关键事项 → `scorecard search-entities --keyword "关键词"`
用户说"目标/OKR/我的目标/个人目标":
- 规则周期 → `user rules`
@@ -218,6 +220,10 @@ dws agoal scorecard entity-detail --sc-id SC_ID --entity-id ENTITY_ID
# 10. 更新计分卡
dws agoal scorecard update --dept-id DEPT_ID --selected-time "2025-01-01T00:00:00+08:00" --id SC_ID --tracking-period-type MONTHLY --content '[{"id":"dim1","title":"业绩","items":[{"id":"item1","title":"收入","target":"100"}]}]'
# 10.1 搜索计分卡指标与关键事项
dws agoal scorecard search-entities --keyword "业绩"
dws agoal scorecard search-entities --keyword "业绩" --page 1 --page-size 20
# 11. 查看用户规则 → 提取 ruleId 和 periodId
dws agoal user rules --user-id USER_ID
@@ -286,6 +292,7 @@ dws agoal obj-template create-or-update --template-id TPL_ID --title "业绩模
| "查经营合约" | `dws agoal contract list` / `contract detail` |
| "更新经营合约" | 先 `contract detail` 获取完整内容,再 `contract update` 覆盖更新 |
| "查计分卡" | `dws agoal scorecard detail` / `scorecard entity-detail` |
| "搜索计分卡指标与关键事项" | `dws agoal scorecard search-entities --keyword <KW>` |
| "更新计分卡" | 先查详情,再按 [agoal.md](./agoal.md) 覆盖更新 |
| "查周月报统计/提交情况/跟催" | `dws agoal report list-statistics` / `report submit-detail` |
+31 -4
View File
@@ -31,13 +31,14 @@ Flags:
--instance-id string 审批实例 ID (必填)
```
### 审批附件授权与下载
### 审批附件授权、上传与下载
先从 `approval detail` 的返回中取得审批实例 `processInstanceId`、附件 `fileId`,以及授权下载所需的 `spaceId`。根据目标选择命令:
- 需要单个附件的临时下载链接:`attachment download-url`
- 已有钉盘 `spaceId/fileId`,需要为当前用户批量开通下载权限:`attachment authorize-download`
- 需要在审批场景内批量预览附件:`attachment authorize-preview`
- 需要把本地文件上传为审批附件(自动完成初始化+PUT+提交):`attachment upload`
#### 获取审批附件临时下载链接
@@ -82,6 +83,21 @@ Flags:
该命令只授权审批场景内的附件预览,不等同于下载授权。附件来自审批评论时增加 `--with-comment-attachment`。
#### 上传本地文件为审批附件
```
Usage:
dws oa approval attachment upload [flags]
Example:
dws oa approval attachment upload --file ./合同.pdf --format json
Flags:
--file string 本地文件路径 (必填)
--file-name string 完整文件名,例如 合同.pdf (可选,默认取本地文件名)
--md5 string 文件原始字节内容的 MD5,32位十六进制字符串 (可选,不传则自动计算)
```
该命令一条命令完成审批附件上传的全部三步:先调用 `oa/init_attachment_upload_info` 初始化获取 OSS 上传地址与签名凭证,再将本地文件二进制 HTTP PUT 上传到 OSS,最后调用 `oa/commit_attachment_upload_info` 提交入库;返回结果包含 fileId、spaceId、fileName、fileSize、fileType。`--file-name` 不传时默认使用本地文件名,`--md5` 不传时自动根据文件内容计算,无需手动初始化或提交。
### 同意审批
> **CAUTION:** 审批决策不可撤回 — 执行前必须向用户确认。
@@ -295,7 +311,7 @@ Flags:
- **严禁跳过 `form-schema`。** 未拿到表单 Schema 前,不得调用 `create-instance`。
- **严禁复用旧的 Schema 结果。** 每次发起实例前都必须重新调用 `form-schema`,模板可能已被修改。
- **严禁在存在不支持必填控件时强行发起。** 若 `form-schema` 返回的必填控件中有不支持类型(如附件等),直接告知用户不支持通过 CLI 发起。
- **严禁在存在不支持必填控件时强行发起。** 若 `form-schema` 返回的必填控件中有不支持类型(如计算公式、流水号、OCR 等),直接告知用户不支持通过 CLI 发起。**注意:附件控件 `DDAttachment` 已支持通过 CLI 提交**,先用 `dws oa approval attachment upload --file <path>` 获取字段再组装为 value 提交,不属于不支持类型。
- **严禁把 `form-schema` 返回的 `content` 当成可直接提交的 payload 模板。**
- **严禁把姓名直接写进 `approvers`、`ccList`、`directAppointedApprovers`、`targetSelectActioners` 或表单人员控件。** 必须先通过 `dws aisearch person --query "<姓名>" --dimension name --format json` 转成 userId。
- **严禁在未得到用户确认前直接执行真实提单。**
@@ -414,7 +430,7 @@ Flags:
| 部门控件 | `DepartmentField` | 部门 ID | `"12345"` | 多部门传 JSON 数组;multiple=true 时支持多选 |
| 省市区控件 | `AddressField` | JSON 数组字符串 | `'["浙江省","杭州市","西湖区"]'` | 三级联动;needDetail=true 时末尾加详细地址 |
| 图片控件 | `DDPhotoField` | URL 数组转义字符串 | `"[\"http://example.com/img1.jpg\"]"` | 支持 URL 直接提交;**不支持本地文件上传** |
| 附件控件 | `DDAttachment` | JSON 数组转义字符串 | `"[{\"spaceId\":\"xxx\",\"fileName\":\"a.pdf\",\"fileSize\":\"333\",\"fileType\":\"pdf\",\"fileId\":\"xxx\"}]"` | **当前不支持通过 CLI 提交**,需钉盘上传接口获取 fileId 等字段 |
| 附件控件 | `DDAttachment` | JSON 数组转义字符串 | `"[{\"spaceId\":\"xxx\",\"fileName\":\"a.pdf\",\"fileSize\":\"333\",\"fileType\":\"pdf\",\"fileId\":\"xxx\"}]"` | **支持通过 CLI 提交**:先用 `dws oa approval attachment upload --file <path>` 获取 fileId/spaceId/fileName/fileSize/fileType,再组装为 DDAttachment value 提交 |
| 评分控件 | `StarRatingField` | 数字字符串 | `"4"` | limit 控制最大星数(默认 5) |
| 关联审批单 | `RelateField` | 审批实例 ID | `"q-xxx"` | 须为当前组织下已存在的实例 |
| 明细控件 | `TableField` | JSON 数组字符串 | `'[{"子控件名":"值1"},{"子控件名":"值2"}]'` | 不可嵌套 TableField;不可含 DDMultiSelectField/DDPhotoField;最大 100 行 |
@@ -429,9 +445,19 @@ Flags:
- `CalculateField`(计算公式)— 由系统自动计算
- `SeqNumberField`(流水号)— 由系统自动生成
- `OcrTextField` / `OcrIdCardField`(OCR 识别)— 需要客户端交互
- **`DDAttachment`(附件控件)— 当前不支持通过 CLI 提交**,value 需要 spaceId、fileName、fileSize、fileType、fileId 字段,须通过钉盘上传附件接口获取
- **套件类控件(暂不支持)** — `InvoiceField`(发票)、`RecipientAccountField`(收款账户)等业务套件控件当前暂不支持通过 CLI 发起,包含这些控件的审批模板请直接在钉钉客户端操作
> **`DDAttachment`(附件控件)已支持通过 CLI 提交:** 采用两步流程——先用 `dws oa approval attachment upload --file <path>` 上传本地文件,返回 `fileId`、`spaceId`、`fileName`、`fileSize`、`fileType`;再将这些字段组装为 DDAttachment value(JSON 数组转义字符串)随 `create-instance` 提交。示例:
>
> ```bash
> # 1) 上传附件,拿到 fileId/spaceId/fileName/fileSize/fileType
> dws oa approval attachment upload --file ./a.pdf
> # 2) 组装 value 后先向用户展示提单汇总;确认前不要追加 --yes
> dws oa approval create-instance --process-code PROC-xxx \
> --form-values '{"附件":"[{\"spaceId\":\"163xxx\",\"fileName\":\"a.pdf\",\"fileSize\":\"333\",\"fileType\":\"pdf\",\"fileId\":\"643xxx\"}]"}'
> # 用户确认模板、表单值、流程路径和人员后,才可在同一命令末尾追加 --yes
> ```
> **部分支持的控件:** `DDPhotoField`(图片控件)**支持通过 URL 直接提交**(见上方速查表),仅不支持本地文件上传(CLI 未封装钉盘 CDN 上传流程)。若用户只有本地文件,需告知在钉钉客户端补充。
如果目标审批模板包含上述控件,不要硬拼 `form-values`;应告知用户这些字段无需填写或需要在钉钉客户端补充。
@@ -748,6 +774,7 @@ Flags:
用户说"下载审批附件/获取审批附件下载链接" → `approval attachment download-url`(需 --instance-id 和 --file-id;评论附件增加 --with-comment-attachment)
用户说"授权下载审批钉盘文件/批量开通附件下载权限" → `approval attachment authorize-download`(需 --file-infos,最多 10 项)
用户说"预览审批附件/批量授权预览附件" → `approval attachment authorize-preview`(需 --instance-id 和 --file-ids,最多 20 项;评论附件增加 --with-comment-attachment)
用户说"上传审批附件/把文件上传为审批附件" → `approval attachment upload`(需 --file;可选 --file-name 默认本地文件名、--md5 自动计算;一条命令完成 init+put+commit)
用户说"同意审批/批准" → 先 `tasks` 获取 taskId,再 `approve`
用户说"拒绝审批/驳回" → 先 `tasks` 获取 taskId,再 `reject`
用户说"撤回审批/取消审批" → `approval revoke`
@@ -239,14 +239,14 @@
### DDAttachment(附件控件)
> **[注意] 当前暂不支持通过 CLI 提交附件控件。** 附件控件的 value 需要包含 spaceId、fileName、fileSize、fileType 和 fileId 字段,这些字段需要通过调用钉盘的上传附件接口获取,CLI 尚未封装此流程。包含附件控件的审批模板请在钉钉客户端操作。
> **[支持] 已支持通过 CLI 提交附件控件。** 采用两步流程:先用 `dws oa approval attachment upload --file <path>` 上传本地文件,获取 spaceId、fileName、fileSize、fileType、fileId;再将这些字段组装为 DDAttachment value(JSON 数组转义字符串)随 `create-instance` 提交。
| 属性 | 说明 |
|------|------|
| `componentName` | `DDAttachment` |
| value 格式 | JSON 数组转义字符串,每个元素包含 spaceId、fileName、fileSize、fileType、fileId |
| 示例(参考) | `"[{\"spaceId\":\"163xxx\",\"fileName\":\"2644.JPG\",\"fileSize\":\"333\",\"fileType\":\"jpg\",\"fileId\":\"643xxx\"}]"` |
| 约束 | **当前不支持通过 CLI 提交**;各字段需通过钉盘上传附件接口获取 |
| 约束 | **支持通过 CLI 提交**;先用 `dws oa approval attachment upload --file <path>` 获取 spaceId、fileName、fileSize、fileType、fileId,再组装为 value 随 `create-instance` 提交 |
### StarRatingField(评分控件)
@@ -328,7 +328,6 @@
| 流水号 | `SeqNumberField` | 由系统自动生成 |
| OCR 文本识别 | `OcrTextField` | 需要客户端 OCR 交互 |
| OCR 身份证识别 | `OcrIdCardField` | 需要客户端 OCR 交互 |
| 附件控件 | `DDAttachment` | value 需要 spaceId、fileName、fileSize、fileType、fileId,须通过钉盘上传接口获取,CLI 尚未封装 |
> **部分支持的控件:** `DDPhotoField`(图片控件)**支持通过 URL 直接提交**,但不支持本地文件上传(CLI 未封装钉盘 CDN 上传流程)。若用户只有本地文件,需告知在钉钉客户端补充。详见本文 [DDPhotoField](#ddphotofield图片控件) 章节。
+13 -9
View File
@@ -54,6 +54,7 @@
| 图片写入单元格 / 浮动图片 | `write-image` / `media-upload` + `create-float-image` | [sheet-media-image](sheet/sheet-media-image.md) | 用 `range update` 写图片 |
| 条件高亮 / 标红 / 数据条 / 色阶 | `cond-format` | [sheet-conditional-format](sheet/sheet-conditional-format.md) | 用静态 `set-style` 冒充条件格式 |
| 单元格评论 / 批注 / @人讨论 | `comment list/create/reply/update/delete` | [sheet-comment](sheet/sheet-comment.md) | 把评论内容写进单元格值 |
| 查询当前 revision / 复核两个 revision 间的编辑 | `revision-get` / `changeset-get` | [sheet-revision-changeset](sheet/sheet-revision-changeset.md) | 把 revision 当历史快照 version,或把前向 change 当成当前最终值 |
## 触发必读
@@ -63,6 +64,7 @@
- 涉及合并、冻结、工作表增删改、最后非空边界:先读 [sheet-workbook](./sheet/sheet-workbook.md)。
- 涉及行列插删、隐藏、尺寸、移动、分组:先读 [sheet-dimension-operations](./sheet/sheet-dimension-operations.md)。
- 涉及多个写操作一次提交:先读 [sheet-batch-operations](./sheet/sheet-batch-operations.md)。
- 涉及 revision、changeset、编辑审计、语义变更、撤销或状态重置:先读 [sheet-revision-changeset](./sheet/sheet-revision-changeset.md)。
## Reference 索引
@@ -82,13 +84,14 @@
| [sheet-filter](sheet/sheet-filter.md) | 全局筛选。当用户说"筛选"、"过滤"、"只看某些行"(未说"筛选视图")时使用。禁止用"删除不符合条件的行"代替筛选。命令:`filter get`/`create`/`delete`/`update`/`clear-criteria`/`sort` |
| [sheet-filter-view](sheet/sheet-filter-view.md) | 筛选视图(个人化,不影响协作者)。当用户明确说"筛选视图"时使用,与全局筛选相互独立。命令:`filter-view list`/`create`/`update`/`delete`/`info`/`update-criteria`/`delete-criteria`/`list-criteria`/`get-criteria` |
| [sheet-conditional-format](sheet/sheet-conditional-format.md) | 条件格式规则。触发词:标红/标黄/高亮/突出/标记/数据条/色阶/颜色随数据变 → **强制**走条件格式,禁止 `range set-style` 静态样式替代。命令:`cond-format list`/`create`/`update`/`delete` |
| [sheet-export](sheet/sheet-export.md) | 导出表格为 xlsx。当用户说“导出”、“下载xlsx”、“存为Excel”时使用。单命令一站式,CLI 内部自动轮询,禁止 Agent 侧重试。命令:`export` |
| [sheet-export](sheet/sheet-export.md) | 导出表格为 xlsx。当用户说“导出”、“下载xlsx”、“存为Excel”时使用。单命令会自动等待结果,禁止 Agent 侧重复调用。命令:`export` |
| [sheet-import](sheet/sheet-import.md) | 导入本地表格文件为在线电子表格。当用户说“导入表格”、“把xlsx传成在线表格”、“上传Excel变在线表格”时使用。Agent 入口为 `import create`,`import` 保留兼容;超时后只用 `import get` 续查,禁止重新创建或用 `drive upload` 代替。命令:`import create`/`import get` |
| [sheet-chart](sheet/sheet-chart.md) | 浮动图表管理。当用户说“画图/数据可视化/趋势图/对比图/占比图/柱形图/折线图/饼图”时使用。禁止用本地脚本生成静态图片替代。命令:`chart list`/`create`/`update`/`delete` |
| [sheet-pivot-table](sheet/sheet-pivot-table.md) | 透视表管理。当用户说“透视表/分组汇总/交叉分析/按X统计Y”时使用。禁止用公式拼汇总表替代。命令:`pivot-table list`/`create`/`update`/`delete` |
| [sheet-comment](sheet/sheet-comment.md) | 单元格评论管理。当用户说“给单元格加评论/批注”、“看某格的评论”、“回复/更新/删除评论”、“@某人讨论这个数据”时使用。评论锚定单元格(`--sheet-id`+`--range`),禁止把评论写进单元格值。命令:`comment list`/`create`/`reply`/`update`/`delete` |
| sheet-template | 表格模板管理。当用户说“用模板创建表格”、“搜索模板”、“模板列表”时使用。命令:`template list`/`template search`/`template apply` |
| [sheet-version](./sheet/sheet-version.md) | 表格历史版本管理。当用户说“保存版本/存个快照/看历史版本/版本列表/回滚到某个版本/恢复到之前的表格”时使用。回滚为危险操作,默认二次确认;版本号从 `version list` 获取。命令:`version save`/`version list`/`version revert` |
| [sheet-version](./sheet/sheet-version.md) | 表格历史版本管理。当用户说“保存版本/存个快照/看历史版本/版本列表/回滚到某个版本或精确 revision/恢复到之前的表格”时使用。回滚为危险操作,默认二次确认;通常从 `version list` 选目标,用户明确要求时也可使用同一工作簿中已确认且仍可恢复的 revision。命令:`version save`/`version list`/`version revert` |
| [sheet-revision-changeset](./sheet/sheet-revision-changeset.md) | 工作簿 revision 与语义化前向 changeset。当用户说“当前 revision/两个版本之间改了什么/复核编辑/回滚到了哪里”时使用。命令:`revision-get`/`changeset-get` |
## 全局硬约束
@@ -99,12 +102,12 @@
5. **最后非空坐标只用 A1 语义**:`sheet info` 通过 `nonEmptyRange` 返回 A1/UI 边界;优先使用 `nonEmptyRange.range`,需要追加行/列时使用 `nonEmptyRange.lastRow` / `nonEmptyRange.lastColumn`。不要使用旧的 0-based 字段 `lastNonEmptyRow` / `lastNonEmptyColumn`
6. **`range update` 维度校验**:`--values` 行列数必须与 `--range` 完全一致;只接 `--values` 一个数据参数,cell `type` 仅支持 `text` / `richText`;整格超链接通过 cell-level `hyperlink` 表达,富文本片段链接才使用 `richText.texts[].type="link"`
7. **dataValidation 三语义**:不传 `dataValidation` 字段=保留原 DV;`dataValidation:{type:"none"}`=显式清除;`dataValidation:{type:"dropdown"/"checkbox",...}`=覆盖。`{}` 跳过亦保留原 DV
8. **hyperlink 三语义**:不传 `hyperlink` 字段=保留原整格超链接;`hyperlink:{type:"none"}`=显式清除;`hyperlink:{type:"path"/"sheet"/"range",link,...}`=覆盖。Agent 调用不要用 `hyperlink:null`,避免网关/Schema 过滤 null 字段
8. **hyperlink 三语义**:不传 `hyperlink` 字段=保留原整格超链接;`hyperlink:{type:"none"}`=显式清除;`hyperlink:{type:"path"/"sheet"/"range",link,...}`=覆盖。不要用 `hyperlink:null`,该写法不能可靠表达清除
9. **样式写法**:cell-level 样式用 `cellStyles` 或 `range set-style`;richText 片段级样式才用子项 `style`。不要在 `type:"text"` 顶层使用旧 `style` 字段
10. **用专用命令不用组合模拟**:搜索→`find`、替换→`replace`、清空→`range clear`、排序→`range sort`、填充→`range fill`、复制区域→`range copy-to`、移动区域→`range move-to`、移动行列→`move-dimension`
11. **大批量 CSV 值/公式用 `csv-put`**(>5 行或 >20 单元格,且无需富格式),不用 `range update`;需要 dataframe/table 语义(列名、dtypes、formats、跨 sheet specs)时用 `table-get` / `table-put`
12. **单元格图片用 `write-image`**(`range update` 不支持图片参数)
13. **`export` / `import create` 禁止自行轮询**(CLI 内部已完成渐进式退避,最多 30 次约 5 分钟);导入超时只按返回的 `next_command` 调用 `import get`,导出失败或超时直接转述,不自动重调
13. **`export` / `import create` 禁止自行轮询**(命令会自动等待结果,最长约 5 分钟);导入超时只按返回的 `next_command` 调用 `import get`,导出失败或超时直接转述,不自动重调
14. **单次调用上限**:`range update` / `set-style` 行数 ≤ 1000,单元格总数建议 ≤ 5000(硬限 30000)
15. **大整数精度保护**:超过 `9007199254740991` 的整数和长数字标识符必须按文本写入;`table-put` 中不要使用 JSON number 或 `int64` / `uint64` dtype,改用字符串值 + `object` dtype + `formats`/`cellStyles` 的 `@`
16. **批量写操作推荐用 `batch-update`**:对多个区域重复调用同一写入工具时,推荐合并为单次原子请求(详见 [sheet-batch-operations](./sheet/sheet-batch-operations.md));逐个调用非原子,中途失败会留下半成品。结构化 table 例外,`table-put` 不进 `batch-update`
@@ -115,9 +118,10 @@
- **公式**:支持通过 `range update` 写入、通过 `range read --value-render-option formula` 回读公式文本、通过 `formula-verify` 按错误类型聚合扫描、通过 `raw_value` / `formatted_value` 回读计算结果。只有 `formula-verify` 返回 `status=success`、`hasMore=false`、`totalErrors=0` 时,才能声称本次目标范围未发现公式错误;这仍不等价于业务数值一定正确。
- **视觉规范**:当前不维护独立的表格视觉方案文档;样式、条件格式、图表分别按对应子文档执行。
- **结构化 table**:`table-get` / `table-put` 是 table/dataframe 语义入口,不嵌入 `batch-update`;需要原子组合时只组合已支持的单元格/结构写操作。
- **历史版本**:支持通过 `version save`/`list`/`revert` 手动保存快照、查看版本列表、回滚到指定版本(底层复用 doc 域版本能力);回滚为危险操作,需二次确认。
- **历史版本与 revision**:`version save`/`list` 管理选定的历史版本;`version revert` 通常回滚到列表项,也可在用户明确要求时回滚到同一工作簿中已确认且仍可恢复的精确 revision。列表项 `version` 可作为 changeset 查询锚点,但相邻历史版本之间可能包含多个 revision;禁止猜测未确认的 revision。
- **单元格评论**:支持通过 `comment create`/`list`/`reply`/`update`/`delete` 管理锚定在 `--sheet-id` + `--range` 的评论;评论与单元格值相互独立,详见 [sheet-comment](sheet/sheet-comment.md)。
- **未暴露或未确认能力**:迷你图、历史 changeset 等能力未在本入口承诺;只有出现稳定 DWS 命令和可回读语义后再补充。
- **changeset 边界**:返回的是 `EDIT`/`UNDO`/`STATE_RESET` 事件及提交时的前向语义 change,不是统一的单元格 `oldValue/newValue` diff,也不保证代表当前最终状态;需要最终值必须额外回读。
- **未暴露或未确认能力**:迷你图等能力未在本入口承诺;只有出现稳定 DWS 命令和可回读语义后再补充。
## URL 粘贴场景
@@ -144,7 +148,7 @@ dws sheet import create --file ./report.xls --folder-token <FOLDER_TOKEN> --name
dws sheet import create --file ./data.xls --workspace <WORKSPACE_ID>
```
- 支持 `.xlsx` / `.xls`,固定产出**在线电子表格**;只新建、不覆盖已有表格
- CLI 内部自动完成「创建会话→上传→确认→渐进式退避轮询」(最多 30 次约 5 分钟),**禁止 Agent 侧再实现轮询或重试**
- 命令会自动上传、确认并等待结果(最长约 5 分钟),**禁止 Agent 侧再实现轮询或重试**
- 导入结果返回 `documentUrl` / `nodeId`,`nodeId` 可直接作为后续 `sheet` 命令的 `--node`
- **禁止用 `dws drive upload --convert` 代替**:drive upload 只上传文件、不在表格导入闭环内,产出不保证为可编辑在线电子表格
@@ -214,8 +218,8 @@ Flags:
| Shortcut | 风险 | 适用场景 |
|---|---|---|
| `dws sheet +list-sheets` | read | 获取表格文档中全部工作表列表 |
| `dws sheet +read` | read | 读取工作表指定范围的结构化单元格数据 |
| `dws sheet +list-sheets` | read | 严格列出在线电子表格的工作表,并可按完整标题精确筛选 |
| `dws sheet +read` | read | 完整读取并严格校验在线电子表格范围;截断结果失败关闭 |
<!-- VISIBLE_SHORTCUTS_END -->
## 意图表
@@ -12,7 +12,7 @@
- 仅支持钉钉在线电子表格(`extension=axls`)。`xlsx` / `xls` / `csv` 等本地表格不支持评论。
- 评论锚定在**单元格位置**:`create` / `list` 通过 `--sheet-id`(工作表 ID 或名称)+ `--range`(单元格坐标)定位;`reply` / `update` / `delete` 通过 `--comment-key` 操作,不依赖单元格位置。
- `create` / `list` 通过单元格位置定位;`reply` / `update` / `delete` 通过前一步返回的 `commentKey` 定位评论线程,不需要重新传单元格位置。
- 评论正文与单元格位置由服务端关联。Agent 只使用命令返回的 `commentKey` 继续回复、更新或删除,不解析或拼接内部关联字段。
- Agent 只使用命令返回的 `commentKey` 继续回复、更新或删除,不自行构造评论标识。
---
@@ -66,7 +66,7 @@ Flags:
查询指定范围内的下拉列表配置信息。
- **用途**:查看单元格已设置的下拉列表选项和配置。
- **场景**:在修改下拉列表前先查询现有配置;确认下拉列表是否设置成功。
- **返回**:`dataValidations` 数组由底层按配置聚合。Inline 组返回 `sourceType:"inline"`、`conditionValues`、`ranges` 和 `options`;SourceRange 组始终返回 `sourceType:"sourceRange"`、`sourceRangeStatus:"valid"/"invalid"`、`enableMultiSelect` 和 `ranges`,仅在 `sourceRangeStatus:"valid"` 时返回 `sourceRange:{sheetId,a1Notation}`。`invalid` 时仍保留配置组,但省略 `sourceRange`,不得依赖旧坐标修复。SourceRange 不会展开候选值,因此不返回 `conditionValues`、`options` 或颜色。范围内无下拉列表时 `hasDropdown` 为 false。
- **返回**:`dataValidations` 按相同配置分组。Inline 组返回 `sourceType:"inline"`、`conditionValues`、`ranges` 和 `options`;SourceRange 组始终返回 `sourceType:"sourceRange"`、`sourceRangeStatus:"valid"/"invalid"`、`enableMultiSelect` 和 `ranges`,仅在 `sourceRangeStatus:"valid"` 时返回 `sourceRange:{sheetId,a1Notation}`。`invalid` 时仍保留配置组,但省略 `sourceRange`,不得依赖旧坐标修复。SourceRange 不会展开候选值,因此不返回 `conditionValues`、`options` 或颜色。范围内无下拉列表时 `hasDropdown` 为 false。
### 删除下拉列表
```
@@ -99,5 +99,5 @@ Flags:
- ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node <NODE_ID> --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等)
- `set-dropdown` 的 Inline 模式使用 `--options`,每个元素包含 `value`(必填)和 `color`(可选,`#RRGGBB`);SourceRange 模式使用 `--source-sheet-id` + `--source-range`。两种模式均可用 `--multi-select`,并会覆盖目标范围已有下拉
- SourceRange 在已验证的重命名、引用前插入行/列、删除引用前行的场景会自动调整;已验证的 `move-dimension` 会使其变为 `invalid`。其他未覆盖删除/移动场景后先回读,仅 `invalid` 时重新选源写入;颜色写入暂不支持
- `get-dropdown` 查询指定范围内的下拉配置,聚合发生在底层服务。SourceRange 即使无效也保留一组并以 `sourceRangeStatus:"invalid"` 表示,但省略 `sourceRange`,不回退展开候选值
- `get-dropdown` 查询指定范围内的下拉配置,并按相同配置分组。SourceRange 即使无效也保留一组并以 `sourceRangeStatus:"invalid"` 表示,但省略 `sourceRange`,不回退展开候选值
- `delete-dropdown` 删除指定范围内的下拉列表配置,单元格恢复为普通文本格式。已填写的值不会被清除。目标范围不存在下拉列表时操作仍返回成功
@@ -5,11 +5,11 @@
### 导出
用户说"导出/下载xlsx/存为Excel/存成表格文件/把表格变成xlsx/导出表格/下载表格/导出为 excel":
- 导出表格 → `export`(单命令一站式,内部自动完成提交、轮询、可选下载)
- 导出表格 → `export`(单命令会自动等待完成,并可选下载)
- 仅需传 `--node`,可选 `--output` 指定本地文件/目录(不传则返回 downloadUrl)
- 需要落盘到本地 → `dws sheet export --node <NODE_ID> --output <path>`,命令自动下载 xlsx
- 禁止用 `range read` 全量读取后自行拼接 xlsx 来模拟导出,必须使用 `export` 命令(服务端原子导出,保留格式/合并/公式等属性)
- 禁止在 AI Agent 侧实现轮询或重试,CLI 内部已按渐进式退避策略完成(最多 30 次约 5 分钟)
- 禁止用 `range read` 全量读取后自行拼接 xlsx 来模拟导出;必须使用 `export`,才能保留格式、合并和公式等属性
- 禁止在 AI Agent 侧实现轮询或重试;命令会自动等待结果,最长约 5 分钟
用户说"导出 CSV/存成 csv/导出这个工作表为 csv":
- 导出单个工作表为纯 CSV → `export-csv`(**同步**,不走异步任务;与 `export` 是两条独立命令)
@@ -38,26 +38,14 @@ Flags:
--output string 本地保存路径(可选,支持文件路径或目录)
```
将钉钉在线电子表格导出为 Office xlsx 格式。**单命令一站式**:命令内部自动完成「提交任务 → 渐进式退避轮询 → (可选)下载文件」全流程,AI Agent 无需自行拆分步骤或实现轮询。
**内部流程**:
1. 调 `submit_export_job` 获取 `jobId`
2. 按渐进式退避策略轮询 `query_export_job` 直至任务终态或超时
3. 任务成功后取得 `downloadUrl`;若指定了 `--output`,自动 HTTP GET 下载 xlsx 到本地文件
**内置轮询策略(CLI 内实现,无需关心)**:
- 第 1~5 次:每次间隔 2 秒
- 第 6~10 次:每次间隔 5 秒
- 第 11~20 次:每次间隔 10 秒
- 第 21~30 次:每次间隔 15 秒
- **硬上限:最多轮询 30 次(约 5 分钟)**,超时后命令返回错误
将钉钉在线电子表格导出为 Office xlsx 格式。**单命令一站式**:命令会自动等待导出完成,并在指定 `--output` 时保存到本地。AI Agent 无需拆分步骤或自行轮询;最长等待约 5 分钟,超时后按命令错误处理。
**命令返回**:
- `--output` 未指定:进度日志 + 末尾输出 `jobId` 和 `downloadUrl`(链接有时效性,请尽快下载)
- `--output` 指定为文件路径:下载到该路径并输出 `导出完成: <path>`
- `--output` 指定为已存在目录:自动从 `downloadUrl` 推断文件名并保存到该目录下
**失败处理(命令内部已处理,Agent 仅需转述)**:
**失败处理**:
- 导出任务返回 `FAILED`:命令立即返回错误并附带失败原因,**禁止自动重试 `dws sheet export`**,告知用户稍后再试
- 轮询 30 次仍 `PROCESSING`:命令返回超时错误,告知用户稍后再试
@@ -111,7 +99,7 @@ dws sheet export-csv --node <NODE_ID> --sheet-id <SHEET_ID> --allow-truncated --
```bash
# ── 工作流 12: 导出表格为 xlsx(单命令一站式)──
# 场景 A:仅获取下载链接(命令内部自动完成提交+轮询,最终返回 downloadUrl)
# 场景 A:仅获取下载链接(命令自动等待完成并返回 downloadUrl)
dws sheet export --node <NODE_ID> --format json
# 传入 URL 也可:
# dws sheet export --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>" --format json
@@ -122,7 +110,7 @@ dws sheet export --node <NODE_ID> --output ./report.xlsx
# 场景 C:下载到目录,自动按链接推断文件名
dws sheet export --node <NODE_ID> --output ./
# 禁止在 Agent 侧实现任何轮询或重试,CLI 内部已按 2s/5s/10s/15s 渐进式退避自动完成(最多 30 次)。
# 禁止在 Agent 侧实现任何轮询或重试;命令会自动等待结果。
# 若命令返回失败或超时,直接告知用户稍后再试,不要自动重调 dws sheet export。
```
@@ -130,18 +118,18 @@ dws sheet export --node <NODE_ID> --output ./
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `export` | `downloadUrl`(未指定 --output)/ `outputPath`(指定 --output) | 直接下发给用户或告知文件已保存到本地。命令内部已完成轮询,不要再调用其他 export 相关命令 |
| `export` | `downloadUrl`(未指定 --output)/ `outputPath`(指定 --output) | 直接下发给用户或告知文件已保存到本地;不要再调用其他 export 相关命令 |
| `export` 超时中断 | 错误信息 | 直接报告失败或超时;当前没有独立续查命令,不自动重新提交导出 |
| `export-csv` | CSV 正文(未指定 --output,走 stdout)/ `导出完成: <path>`(指定 --output) | 直接把 CSV 交给下游处理,或告知文件已保存到本地。命令是同步的,无任务/轮询概念 |
## 注意事项
- ★ `export` 仅支持钉钉在线电子表格(axls)→ xlsx;传入钉钉文字文档会报 `invalidRequest.document.typeIllegal`
- ★ `export` 为单命令一站式,CLI 内部已自动完成「提交 → 渐进式退避轮询 → 可选下载」,**Agent 不得在外部实现轮询或重试**;命令返回成功后不再调用其他 export 相关命令
- ★ `export` 为单命令一站式,会自动等待结果并可选下载;**Agent 不得自行轮询或重试**,命令返回成功后不再调用其他 export 相关命令
- `export` 内置轮询策略:1~5 次间隔 2s、6~10 次间隔 5s、11~20 次间隔 10s、21~30 次间隔 15s,硬上限 30 次(约 5 分钟);超时后命令返回错误,告知用户稍后再试即可
- ★ `export` 命令返回失败或超时时,**禁止自动重调 `dws sheet export`**;直接告知用户导出失败并建议稍后再试
- `export` 未指定 `--output` 时,返回的 `downloadUrl` 具有时效性,获取后请尽快下载;若用户需要本地文件,优先直接传 `--output` 让 CLI 代为下载
- `export` 的 `--output` 可为文件路径或已存在目录;为目录时自动从 `downloadUrl` 推断文件名,为文件路径时直接按该路径保存
- 用户要求"导出表格/下载 xlsx"时,必须使用 `export` 单命令,禁止用 `range read` 读全量数据后自行拼 xlsx 模拟导出(服务端导出会保留格式/合并/公式等完整属性)
- 用户要求"导出表格/下载 xlsx"时,必须使用 `export` 单命令,禁止用 `range read` 读全量数据后自行拼 xlsx 模拟导出;`export` 会保留格式、合并、公式等属性
- `export-csv` 与 `export` 是两条独立命令:`export-csv` 同步导出**单个**工作表的纯值 CSV,不保留样式/合并/公式;要整篇工作簿或完整属性一律用 `export`
- ★ `export-csv` 遇到数据超出单次读取上限时默认报错、既不输出也不写文件;优先用 `--range` 分块导出,只有用户明确接受不完整数据时才加 `--allow-truncated`
@@ -173,7 +173,7 @@ Flags:
- `filter update` 前置:工作表必须已创建筛选
- `filter clear-criteria` 仅清除指定列的条件,不删除整个筛选。指定列无条件时不报错(幂等)
- `filter sort` 会实际改变数据行的物理顺序,不可撤销。前置:工作表必须已创建筛选
- ★ **筛选操作规范**(参照飞书 core-operations):
- ★ **筛选操作规范**:
- 当用户要求"筛选/只看/仅保留 X"时,**必须**通过 `filter create` / `filter update` 创建真实的筛选器。**禁止**用"删除不符合条件的行"或"新建工作表只放符合条件的行"来代替
- 创建/更新筛选后**必须** `filter get` 回读验证配置正确
- 更新已有筛选前先 `filter get` 读取当前配置,确认目标存在且了解现有条件后再操作
@@ -6,7 +6,7 @@
- 支持本地 `xlsx` / `xls`,文件上限 20MB
- `--folder-token` 与 `--workspace` 至少提供一个
- CLI 内部完成创建会话、上传文件、确认转换和轮询,Agent 不要自行拆分或重试
- `import create` 会自动上传文件、确认转换并等待结果,Agent 不要自行拆分或重试
- `drive upload` 只上传二进制文件,不会转换为可编辑的在线表格,不能替代本命令
## 命令
@@ -228,8 +228,8 @@ dws sheet write-image --node <NODE_ID> --sheet-id <SHEET_ID> --range B2:B2 --fil
## 注意事项
- ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node <NODE_ID> --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等)
- `media-upload` 是两步自动完成的流程 (获取附件上传凭证 → OSS 上传),无需手动分步操作
- `write-image` 是三步自动完成的流程 (获取附件上传凭证 → OSS 上传 → 写入图片到单元格),无需手动分步操作
- `media-upload` 会自动完成图片上传并返回后续命令需要的资源信息,无需手动拆分步骤
- `write-image` 会自动完成图片上传并写入目标单元格,无需手动拆分步骤
- ★ 向表格单元格中写入图片必须使用 `write-image`,禁止使用 `range update`。`range update` 不支持图片对象
- `write-image` 与 `media-upload` 的区别:`media-upload` 仅上传附件到表格获取 resourceId;`write-image` 在上传后还会将图片写入指定单元格
- `create-float-image` 创建浮动图片前必须先通过 `media-upload` 上传图片获取 `resourceUrl`,再将其作为 `--src` 传入。`--src` 的格式为 `/core/api/resources/img/...`,不能直接传外部 URL
@@ -191,7 +191,7 @@ Flags:
- 示例:`'Sheet1'!A1:D100`、`'销售数据'!A1:F500`
- 必须包含表头行(通常从第 1 行开始)
- sheet 名称用单引号包裹(含空格或特殊字符时必须)
- `source` 直接使用上述 A1 表示法;不要自行改写成内部对象结构
- `source` 直接使用上述 A1 表示法,不要改写成其他结构
## 高级功能示例
@@ -210,7 +210,7 @@ dws sheet pivot-table create --node <NODE_ID> \
}'
```
> collapse/show_data_as 为可选透传字段,CLI 和 MCP 层不做额外校验,直接传递给底层引擎处理。
> `collapse` / `show_data_as` 为可选字段;仅使用命令文档列出的取值,并按命令返回处理无效配置。
## 核心工作流
@@ -54,7 +54,7 @@ Flags:
- `rowIndices` — 行号映射数组(如 `[1,2,3]`)
- `hasMore` — 完整目标范围是否还有未返回数据。`true` 是部分成功,不能宣称已读完
- `truncationReasons` — 部分返回原因。`max_cells` 表示命中单次 30,000 单元格上限;`max_chars` 表示命中 CSV 字符上限,两者可同时出现
- `resolvedRange` — 仅在未传 `--range` 时返回,表示底层解析出的完整目标范围
- `resolvedRange` — 仅在未传 `--range` 时返回,表示本次读取所对应的完整目标范围
- `returnedRange` — 本次实际完整返回的精确范围;它不是服务端续读游标
`csv-get` 不返回合并单元格结构。若 CSV 中出现合并区域的非左上角单元格为空,不能据此判断该区域"无内容";需要先用 `dws sheet info --node <NODE_ID> --sheet-id <SHEET_ID> --format json` 读取 `mergedRanges`,再结合左上角单元格理解合并区域语义。
@@ -161,7 +161,7 @@ Flags:
| `dropdown` Inline | `enableMultiSelect: boolean` | 是否允许多选 |
| `dropdown` SourceRange | `sourceType: "sourceRange"` | 区域来源模式 |
| `dropdown` SourceRange | `sourceRange: {sheetId, a1Notation}` | 仅 `sourceRangeStatus:"valid"` 时返回的来源工作表和规范化 A1 区域;`invalid` 时省略 |
| `dropdown` SourceRange | `sourceRangeStatus: "valid" / "invalid"` | 底层引用是否可解析;无效引用仍返回配置,但不返回 `sourceRange` |
| `dropdown` SourceRange | `sourceRangeStatus: "valid" / "invalid"` | 来源引用是否有效;无效引用仍返回配置,但不返回 `sourceRange` |
| `checkbox` | `checked: boolean` | 当前勾选状态 |
SourceRange 模式不动态拉取或展开来源区域的候选值,因此不返回 `options` 或颜色。`invalid` 时只能依赖 `sourceRangeStatus` 判定需要重新选源,不得假定仍能从 `sourceRange` 取回旧坐标。
@@ -270,6 +270,7 @@ dws sheet range read --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:D10" --f
- ★ `csv-get` 和 `range read` 都必须检查 `hasMore`;`true` 时结合目标范围与 `returnedRange` 显式分批,CLI 不会自动续读
- ★ `forbidden.document.sizeOverLimit` 是工作簿装载失败,不是 30,000 单元格部分成功;缩小 `--range` 不能修复
- ★ `csv-get` / `range read` / `range get` 不返回合并单元格结构;查看合并范围必须用 `sheet info` 的 `mergedRanges`
- ★ 保护区域权限:`csv-get`、`table-get`、`range read`、`formula-verify` 等命令会检查本次实际读取范围。若返回权限错误,说明当前身份无法确认具有该范围的完整读取权限;不要解释为空数据或反复重试,应申请对目标内容的直接访问权限或请管理员执行。仅当任务本来就不需要受保护区域时,才可缩小 `--range` 避开无关区域,禁止拆分范围绕过权限。
- `table-get` 返回 table/dataframe 结构,适合需要 `columns` / `data` / `dtypes` / `formats` 的场景;它不返回 per-cell 元数据,也不替代 `range read`
- ★ 大整数和长数字标识符回读校验:精确 ID 应是字符串 / `object`;若看到 `float64`,说明它已经按数值路径处理,超过 `9007199254740991` 时可能已丢精度
- `range read` 遇到超时或响应过慢时,应缩小 `--range` 查询范围,**单次读取的单元格数量建议控制在 5000 个以内**;数据量较大时通过 `info` 的 `nonEmptyRange.range` 获取 A1 边界后分批读取,避免不传 `--range` 直接读取整个大工作表
@@ -0,0 +1,306 @@
# 工作簿 revision 与 changeset
## 何时使用
当用户要知道在线电子表格当前 revision,或要复核两个 revision 之间提交了哪些变更、是否发生撤销或整表状态替换时使用。
- 当前 revision → `dws sheet revision-get`
- revision 区间内的变更 → `dws sheet changeset-get`
- 可保存、展示或回滚的历史版本 → [sheet-version](./sheet-version.md)
- 当前单元格值、公式或工作簿结构 → 使用对应 read/list/get 命令
两条命令都是工作簿级能力,只接受 `--node`,不接受 `--sheet-id`。`--node` 可传在线电子表格文档 ID 或完整 URL;`spreadsheetv2` URL 必须原样传入。
revision 表示工作簿的编辑进度;历史版本是选定的保存或回滚点。`version list` 返回的 `version` 可以作为 changeset 查询锚点,但相邻历史版本之间可能包含多个 revision。
当用户明确要求恢复到某个精确 revision 时,可以把已确认属于同一工作簿的 revision 传给 `version revert --version`,即使它没有出现在 `version list` 中。默认仍应从版本列表选择稳定的历史版本;未列入列表的 revision 只有在服务端仍可恢复时才会成功,禁止猜测或自动改用相邻 revision。具体安全流程见 [sheet-version](./sheet-version.md)。
## 获取当前 revision
```bash
dws sheet revision-get --node <NODE_ID_OR_URL> --format json
```
成功时从统一输出的 `data.revision` 读取当前 revision:
```json
{
"ok": true,
"outcome": "success",
"data": {
"success": true,
"logId": "trace-id",
"revision": 142
}
}
```
- `revision=0` 是合法的空工作簿基线。
- revision 不是工作表数量,也不是历史版本列表序号。
- `logId` 仅用于失败反馈和问题排查,不是 revision。
## 获取 revision 区间 changeset
```bash
dws sheet changeset-get \
--node <NODE_ID_OR_URL> \
--start-revision 120 \
--end-revision 140 \
--format json
```
也可以省略 `--end-revision`,查询到本次请求开始时的最新 revision:
```bash
dws sheet changeset-get \
--node <NODE_ID_OR_URL> \
--start-revision 120 \
--format json
```
参数规则:
- `--start-revision` 必填,必须为非负整数;`0` 合法。
- `--end-revision` 可选;提供时必须大于或等于 start。
- 查询区间固定为 `(startRevision, endRevision]`:不包含 start,包含 end。
- `startRevision == endRevision` 合法,返回空 `changesets`。
- 单次最多查询 20 个 revision,即 `endRevision - startRevision <= 20`;更大区间必须连续分段查询。
- 省略 end 时,本次响应会固定一个 `endRevision`;查询期间产生的新 revision 不进入本次结果。
- 无法完整取得所请求区间时整次失败,不会把缺失的 revision 当作成功结果。
成功后直接读取统一输出中的 `data.changesets` 数组。DWS 已把它转换为可遍历的 JSON 对象,不需要额外解析。
## 先看区间摘要
`data.summary` 用于快速判断本次结果是否完整以及哪些工作表受到影响:
| 字段 | 含义 |
|------|------|
| `changeCount` | `changes[]` 中变更对象的总数 |
| `completeChangeCount` | 详情完整的变更数量 |
| `partialChangeCount` | 只返回部分可信详情的变更数量 |
| `unsupportedChangeCount` | 当前无法解释的变更数量 |
| `containsStateReset` | 区间内是否发生整表状态替换 |
| `containsIncompleteChanges` | 是否存在不完整或无法解释的详情 |
| `affectedSheets` | 受影响工作表及其 A1 范围摘要 |
`affectedSheets[]` 常用字段:
| 字段 | 含义 |
|------|------|
| `sheetId` | 工作表 ID |
| `sheetName` | 可用时的工作表名称;缺失时不要从 ID 猜测 |
| `ranges` | 去重后的 1-based A1 范围;工作表级变更可为空数组,整行/整列可表示为 `1:3` / `A:C` |
摘要用于定位,不是完整变更副本。要回答改了什么,仍需遍历 `changesets[].changes[]`。
## 读取 `changesets[]`
每个 changeset 对应一个 revision:
| 字段 | 含义 | 使用规则 |
|------|------|----------|
| `revision` | 该事件对应的 revision | 按升序解释 |
| `createTime` | 事件创建时间 | 时间仅用于说明,顺序以 revision 为准 |
| `isSelfEdit` | 是否由当前请求用户提交 | `false` 不能用于识别具体编辑者,也不能判断是否由人工或自动化产生 |
| `eventType` | `EDIT`、`UNDO` 或 `STATE_RESET` | 先按事件类型分流 |
| `detailsStatus` | `COMPLETE`、`PARTIAL` 或 `UNAVAILABLE` | 决定能否把详情当作完整描述 |
| `changes` | 本 revision 的变更数组 | `STATE_RESET` 时为空数组 |
| `reset` | 整表状态替换信息 | 只在 `STATE_RESET` 时读取 |
事件类型:
- `EDIT`:一次普通编辑提交产生的效果。
- `UNDO`:一次撤销提交产生的效果。只描述这次撤销实际改变了什么,不要反推被撤销操作的完整原貌。
- `STATE_RESET`:工作簿整体状态被替换。不要跨过该事件拼接推导完整状态;需要重新读取工作簿。
changeset 描述的是该 revision 提交的效果,不是统一的变更前/变更后快照,也不保证仍是当前最终状态。
## 读取 `reset`
| 字段 | 含义 |
|------|------|
| `type` | 状态替换类型:`ROLLBACK`、`OVERWRITE`、`UPGRADE`、`TEMPLATE`、`PRETTIFY` 或 `UNKNOWN_RESET` |
| `targetRevision` | 可确认时的目标 revision;`0` 是合法空基线 |
| `targetStatus` | `KNOWN` 表示目标已返回;`NOT_APPLICABLE` 表示该事件没有目标 revision;`UNAVAILABLE` 表示目标无法确认 |
当 `targetStatus=UNAVAILABLE` 时,禁止把目标猜成 `revision-1`。即使目标 revision 已知,也要通过读取命令确认需要的当前内容。
回滚完成后,可用回滚前后的 revision 查询 changeset:若返回 `STATE_RESET`、`reset.type=ROLLBACK` 且 `reset.targetRevision` 等于请求目标,表示回滚事件指向了该 revision;工作簿内容是否符合预期仍需独立回读。
## 读取 `changes[]`
每个 change 都按以下顺序读取:
1. `type`:发生了哪类变更。
2. `targets`:变更作用在哪个工作簿、工作表或范围。
3. `detailsStatus`:详情是否完整。
4. `details`:本次变更的可用详情。
5. `omissions`:哪些详情未能提供。
公共字段:
| 字段 | 含义 |
|------|------|
| `type` | 语义变更类型,见下方字典 |
| `targets` | 工作簿、工作表或 A1 范围定位数组 |
| `details` | 与 `type` 对应的变更详情;无详情时为空对象 |
| `detailsStatus` | `COMPLETE`、`PARTIAL` 或 `UNAVAILABLE` |
| `omissions` | 未提供详情的原因;没有省略时为空数组 |
`detailsStatus` 的处理规则:
- `COMPLETE`:已完整解释本类型可提供的本次变更详情,但不代表包含旧值或当前值。
- `PARTIAL`:已返回字段可信,同时存在缺失;回答时要明确“仅确认到这些内容”。
- `UNAVAILABLE`:不能可靠说明具体详情;只报告事件、类型和可信位置,不猜内容。
`omissions[].fields` 可指出受影响的详情字段。`omissions[].code` 只用于判断存在何种缺口;面向用户时翻译成“部分详情未提供”“公式文本不可用”“位置无法确认”等自然语言,不需要展示内部原因码。
## 读取 `targets[]`
| 字段 | 含义 |
|------|------|
| `scope` | `WORKBOOK`、`SHEET` 或 `RANGE` |
| `sheetId` | 工作表 ID |
| `sheetName` | 可用时的工作表名称 |
| `sheetNameSource` | 名称是变更时名称、当前名称或无法确认 |
| `a1Range` | 1-based A1 范围 |
| `role` | `AFFECTED`、`SOURCE` 或 `DESTINATION`;缺失时按 `AFFECTED` 处理 |
- `SOURCE` 是读取或复制来源,不表示该位置被修改。
- `DESTINATION` 是写入目标,应算作受影响位置。
- `WORKBOOK` 级 target 通常没有工作表或 A1 范围,不要补造。
- `sheetName` 缺失或来源无法确认时,优先使用 `sheetId` 定位并按需调用 `sheet list`。
## 变更类型字典
### 行列与工作表
| type | 含义 | 常用 `details` |
|------|------|----------------|
| `ROWS_INSERTED` / `COLUMNS_INSERTED` | 插入行或列 | `startNumber`、`count`、`styleSource?` |
| `ROWS_DELETED` / `COLUMNS_DELETED` | 删除行或列 | `startNumber`、`count` |
| `ROWS_UPDATED` / `COLUMNS_UPDATED` | 更新行列属性 | `startNumber`、`count`、`properties?`、`relativeChanges?`、`replace?` |
| `SHEET_CREATED` | 创建工作表 | `position?`、`properties?`、`copiedFromSheetId?` |
| `SHEET_DELETED` | 删除工作表 | 通常由 target 定位 |
| `SHEET_UPDATED` | 更新工作表名称、位置或属性 | `position?`、`properties?` |
| `CUSTOM_TAB_ADDED` / `CUSTOM_TAB_DELETED` / `CUSTOM_TAB_UPDATED` | 新增、删除或更新自定义 Tab | `tabId`、`position?`、`properties?` |
`startNumber` 和 `position` 为 1-based。行列 `properties` 常见字段包括 `size`、`hidden`、`sticky`、`cellType`;工作表属性常见字段包括 `name`、`hidden`、`tabColor`、冻结行列数和默认行列尺寸。只解释实际返回的字段。
### 单元格与范围
| type | 含义 | 常用 `details` |
|------|------|----------------|
| `CELLS_INSERTED` / `CELLS_DELETED` | 在 target 内插入或删除单元格并平移 | `axis` |
| `RANGE_PASTED` | 将来源内容粘贴到目标范围 | `pasteMode`、`isCut`、`step?`、`includedParts?`、`contentPattern?` |
| `RANGE_AUTOFILLED` | 自动填充目标范围 | `fillMode`、`copyStyle`、`step?` |
| `RANGE_CLEARED` | 清除范围内指定内容 | `clearParts?`、`preservedCellTypes?` |
| `RANGE_CONTENT_SET` | 对整个范围写入相同内容或公式 | `cell?`、`replaceMode?` |
| `RANGE_BORDER_SET` | 设置范围边框 | `border?`、`borderSides?` |
| `RANGE_STYLE_SET` | 设置范围样式 | `style`、`replace?` |
| `RANGE_TAG_SET` | 设置或清除范围标签 | `tag?`、`replace?` |
| `RANGE_SORTED` | 对范围排序 | `sortByRange?`、`beforeOrder?`、`afterOrder?`、`indexesAreRelative` |
| `CELLS_CONTENT_SET` | 按相对位置分别修改内容 | `relativeChanges`、`replaceMode?` |
| `CELLS_STYLE_SET` | 按相对位置分别修改样式 | `relativeChanges?`、`styles?`、`styleMode?`、`replace?` |
| `CELLS_TAG_SET` | 按相对位置分别修改标签 | `relativeChanges`、`replace?` |
粘贴和自动填充的 `SOURCE` target 是来源,`DESTINATION` target 是写入位置。自动填充的 `DESTINATION` 可能是包含 `SOURCE` 的完整填充跨度;重叠部分不表示来源值发生了变化。描述新增填充区域时使用 `DESTINATION` 减去 `SOURCE`,核对最终结果时仍回读完整 `DESTINATION`。`step.rows` / `step.columns` 表示重复模式的大小。`contentPattern.cells[]` 使用 `rowOffset` / `columnOffset` 表示相对模式左上角的位置,并可返回写入的 `value`、`formula`、`cellType` 或 `link`。
`RANGE_SORTED.beforeOrder` / `afterOrder` 只列实际换位的行或列,未移动的位置会省略;数组短于 target 的行列数不代表详情缺失。`indexesAreRelative` 决定这些索引是相对 target 还是工作表绝对索引。
`relativeChanges[]` 中的 `rowOffset` / `columnOffset` 都是相对 target 左上角的 0-based 偏移,不是工作表绝对行列号;必须与 target 起点相加后再换算成 A1。
### 分组、数据验证和结构对象
| type | 含义 | 常用 `details` |
|------|------|----------------|
| `DIMENSION_GROUP_ADDED` / `DIMENSION_GROUP_REMOVED` / `DIMENSION_GROUP_UPDATED` | 新增、删除或更新行列分组 | `axis`、`startNumber`、`count`、`depth?`、`collapsed?` |
| `DATA_VALIDATION_SET` | 设置数据验证规则 | `ruleId?`、`dataValidation` |
| `DATA_VALIDATION_CLEARED` | 清除数据验证规则 | `ruleId?` |
| `CELLS_MERGED` / `CELLS_UNMERGED` | 合并或取消合并单元格 | `featureId?`、`effectless?` |
| `NAMED_RANGE_SET` | 设置命名范围 | `name`、`baseSheetId?`、`formula?` |
| `NAMED_RANGE_CLEARED` | 清除命名范围 | `name`、`baseSheetId?` |
| `FEATURE_ADDED` / `FEATURE_DELETED` / `FEATURE_UPDATED` | 新增、删除或更新筛选、评论、透视表、保护范围等扩展对象 | `featureType`、`featureId?` |
扩展对象通常只返回类型、标识和可信 target,不保证包含完整配置。用户询问当前配置时,调用该对象对应的 list/get 命令。
`dataValidation` 常用字段:
| 字段 | 含义 |
|------|------|
| `type` | 验证规则类型 |
| `sourceType` | 下拉来源:`inline` 或 `sourceRange` |
| `options` | 内联下拉选项数组,每项包含 `value` 和可选 `color` |
| `sourceRange` | 已确认的来源工作表与 A1 范围 |
| `sourceRangeStatus` | `RESOLVED`、`UNRESOLVED` 或 `INVALID` |
| `sourceRangeExpression` | 可读的来源表达式 |
| `enableMultiSelect` | 是否允许多选 |
| `criteria` | 非下拉规则的运算符和条件值 |
| `settings` | 是否允许空值、是否显示提示、错误提示等行为设置 |
只有 `sourceRangeStatus=RESOLVED` 时,才能把 `sourceRange` 当作已确认范围;否则说明来源未解析或无效,并在需要时回读当前规则。
### 工作簿设置与其他变更
| type | 含义 | 常用 `details` |
|------|------|----------------|
| `WORKBOOK_SETTING_UPDATED` | 更新工作簿设置 | `setting`、`changes` |
| `EXTERNAL_REFERENCES_REPLACED` | 替换外部引用 | `referenceCount` |
| `UNSUPPORTED_CHANGE` | 存在当前无法可靠解释的变更 | `category?` |
`WORKBOOK_SETTING_UPDATED` 的 `setting` 可表示计算设置或背景设置;只解释 `changes` 中实际返回的字段。遇到 `UNSUPPORTED_CHANGE` 时,只说明存在未能解释的变更,不根据相邻事件猜测其内容。
## 常用详情值
### 单元格内容
`cell` 常见字段:
- `value`:带类型的值;`kind` 可为 `STRING`、`NUMBER`、`BOOLEAN`、`NULL` 或 `MULTI`,分别读取对应的 `stringValue`、`numberValue`、`booleanValue` 或 `values`。
- `formula`:本次写入的可读公式。
- `formulaCleared=true`:本次清除了公式。
- `cellType`:`general`、`checkbox` 或 `select`;select 可带 `options`。
- `link`:可用时返回工作簿内范围链接及目标 `sheetId` / `a1Range`。
### 清除、null 与字段缺失
- 对象整体为 `{ "cleared": true }`:本次清除了该对象。
- `formulaCleared=true`:本次清除了公式。
- `styleCleared=true`:本次清除了该相对位置的样式。
- 对象内某个已知属性为 `null`:本次清除该属性或恢复默认。
- 字段缺失:本次未提供或不适用;若状态不是 `COMPLETE`,结合 `omissions` 判断缺口。
不要把整体清除、单属性清除和字段缺失混为一谈,也不要补造清除前内容。
## Agent 解读顺序
1. 检查统一输出 `ok=true`、`outcome=success` 和 `data.success=true`。
2. 直接遍历 `data.changesets`,不要把它当作字符串。
3. 用 `startRevision` / `endRevision` 准确表述 `(start,end]`。
4. 先看 `summary.containsStateReset` 和 `summary.containsIncompleteChanges`。
5. 每个事件先看 `eventType`;`STATE_RESET` 读 `reset`,`EDIT` / `UNDO` 再读 `changes`。
6. 每条 change 按 `type -> targets -> detailsStatus -> details -> omissions` 解读。
7. `PARTIAL` 只使用已返回字段并说明缺口;`UNAVAILABLE` 和 `UNSUPPORTED_CHANGE` 不猜。
8. `isSelfEdit=false` 不用于归因具体用户、系统或自动化。
9. 用户问“现在是什么”时,按 target 使用对应 read/list/get 命令回读。
10. 看到可用 revision 不代表已获准回滚;只有用户明确要求并确认覆盖风险后,才按 [sheet-version](./sheet-version.md) 执行 `version revert`。
## 最终状态回读
changeset 用于解释“某个 revision 提交了什么”,不是“当前最终是什么”。
- 值和公式:用 `csv-get`、`range read` 或 `table-get` 回读目标范围。
- 行列、分组、隐藏和尺寸:用 `sheet info --include ...` 或对应结构读取命令。
- 数据验证、筛选、评论、透视表等对象:用对应 list/get 命令回读。
- 粘贴或自动填充:优先回读 `DESTINATION`,不要把 `SOURCE` 当作已修改范围。
- `STATE_RESET`:重新读取工作簿当前状态。
- 分段查询时,只要任一段失败或包含不完整变更,就不能声称整个跨度已完整解释。
## 常见失败处理
- revision 非法或超出范围:先调用 `revision-get`,再修正或分段查询。
- 区间内容不可用:说明无法获得完整 changeset,不要解释为空 changeset。
- changeset 过大:缩小 revision 区间;不要假设结果会自动截断。
- 读取权限不足或无法确认受保护内容的完整读取权限:申请对目标内容的直接访问权限,或请管理员执行;不要把权限错误解释为空数据,也不要拆分范围绕过权限。
- `containsIncompleteChanges=true`:可以报告已确认部分和明确缺口,不能声称所有改动均已完整解析。
@@ -99,7 +99,7 @@ Flags:
- `--font-line` 为单选:`underline` 置下划线、`line-through` 置删除线、`none` 清除两者
- 至少需传入一个样式参数。单次调用建议:行数 ≤ 1000,单元格总数 ≤ 5000(服务端硬限 30000)
- 枚举值按驼峰书写:`autoWrap`、`bold`、`normal`、`center`、`italic` 等
- 底层走 `set_cell_range` 的 `cellStyles`(仅设样式、保留原值),因此**纯样式设置可直接作用于含合并单元格的区域**,无需先取消合并
- 纯样式设置只修改样式并保留原值,因此可直接作用于含合并单元格的区域,无需先取消合并
### 批量设置单元格样式(服务端原子事务)
```
@@ -161,7 +161,7 @@ Flags:
- 所有项在下发前先做本地校验(每项与 `set-style` 一致:至少一项样式字段 + rows ≤ 1000 + rows×cols ≤ 30000 + 枚举合法),**任一项校验不过即整批不下发**
- 批量上限:最多 **100 个区域**,且**所有区域累计不超过 200000 个单元格**(一次请求要把全部单元格矩阵建好,累计量才是真正的峰值约束)。超限同样是下发前本地报错、整批不执行;请拆成多次调用
- `--continue-on-error` 透传给服务端:遇失败继续执行其余项(软批量)
- 底层每项走 `set_cell_range` 的 `cellStyles`/`borderStyles`,因此纯样式批量同样可作用于含合并单元格的区域
- 批量纯样式设置同样只修改样式并保留原值,可直接作用于含合并单元格的区域
### 合并单元格
```
@@ -304,7 +304,7 @@ dws sheet merge-cells --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:C3" --m
- ★ `range update` / `range set-style` / `range batch-set-style` 单次调用上限(强制):行数 ≤ 1000,单元格总数(行×列)建议≤ 5000(服务端硬限 30000);超限请拆分多次调用。CLI 会在调用前做本地预校验,服务端超 30000 会直接报错
- `range set-style` / `range batch-set-style` 的样式枚举按驼峰书写:`wordWrap` 取 `overflow`/`clip`/`autoWrap`,`fontWeight` 取 `bold`/`normal`,`hAlign` 取 `left`/`center`/`right`/`general`,`vAlign` 取 `top`/`middle`/`bottom`,`fontStyle` 取 `normal`/`italic`;背景色/字体颜色统一使用 `#RRGGBB` 格式
- `--font-line` 取 `none`/`underline`/`line-through`(单选,非驼峰):`underline` 置下划线、`line-through` 置删除线、`none` 同时清除;`--font-family` 传字体族名(如 `Arial`/`微软雅黑`);`--font-style`/`--font-line`/`--font-family` 均为整区共用标量,无 `*-json` 逐格形式
- `range set-style` / `range batch-set-style` 底层走 `set_cell_range` 的 `cellStyles`(仅设样式、保留原值);纯样式设置可直接作用于**含合并单元格**的区域,无需先取消合并(写值到合并区仍会被拦截)
- `range set-style` / `range batch-set-style` 只修改样式并保留原值;可直接作用于**含合并单元格**的区域,无需先取消合并(写值到合并区仍会被拦截)
- `range update` 支持通过 `cellStyles` 在写值时附带 per-cell 样式,适合少量单元格写值 + 样式一步到位的场景。批量设置整片区域的统一样式时,仍应使用 `set-style` / `batch-set-style`
- `merge-cells` 合并时只保留左上角单元格的值,其他单元格的值会被丢弃
- `merge-cells` 的 `--merge-type` 不传时默认为 `mergeAll`(合并所有单元格)
@@ -5,10 +5,10 @@
管理钉钉在线电子表格的历史版本快照。当用户说"保存版本/存个快照/看历史版本/版本列表/回滚到某个版本/恢复到之前的表格"时使用。
- 手动保存当前表格为一个版本快照 → `version save`
- 查看表格的历史版本列表(含版本号、名称、创建人、创建时间) → `version list`(别名 `ls`)
- 把表格回滚到指定历史版本 → `version revert`(危险操作,默认需二次确认)
- 查看表格的历史版本列表 → `version list`(别名 `ls`)。返回不含版本名称;列表项包含 `version`、`type`、`userId`、`createTime`、`updateTime`、`editorList` 等服务端字段
- 把表格回滚到指定历史版本或已确认的精确 revision → `version revert`(危险操作,默认需二次确认)
三个命令统一用 `--node` 指定表格文档(ID 或 URL)。回滚用的**版本号 `--version` 从 `version list` 的返回中获取**,禁止臆测。
三个命令统一用 `--node` 指定表格文档(ID 或 URL)。默认从 `version list` 选择稳定的历史版本。用户明确要求恢复到某个精确 revision 时,也可把已从同一工作簿真实查询结果确认的 revision 传给 `--version`,即使它没有出现在版本列表中。禁止猜测版本号或 revision。
## 命令详细参考
@@ -38,32 +38,47 @@ Flags:
--limit int 返回版本数量上限 (可选)
--cursor string 分页游标 (可选,游标分页)
```
返回表格的历史版本列表;回滚前先用它拿到目标 `version`(版本号)。
返回表格的历史版本列表;顶层包含 `versions`、`nextCursor`、`hasMore`,列表项不含 `name`。回滚前先从列表项拿到真实 `version`(版本号)。
### 回滚表格到指定版本
```
Usage:
dws sheet version revert [flags]
Example:
dws sheet version revert --node SHEET_ID --version 3 --yes
dws sheet version revert --node SHEET_ID --version 3
Flags:
--node string 表格文档 ID 或 URL (必填)
--version int 目标版本号 (必填,从 version list 获取)
--version int 目标历史版本或已确认 revision (必填,通常从 version list 获取)
```
把表格回滚到指定历史版本。**危险操作**:会覆盖当前内容,默认弹出二次确认;AI Agent 或脚本执行时可加全局 `--yes` 跳过确认。
把表格回滚到指定历史版本或精确 revision。**危险操作**:会覆盖当前内容,默认要求二次确认。本文不提供带 `--yes` 的可复制示例;执行器只有在当前流程中向用户展示完整目标参数和覆盖风险、并获得明确确认后,才可动态追加全局 `--yes`。
`version list` 只列选定的保存或回滚点,并不包含每一个 revision。未列入版本列表的 revision 只有在服务端仍可恢复时才能回滚成功;过旧或内容不可用时应直接报告失败,禁止改猜相邻 revision 重试。
### 精确 revision 回滚
只有用户明确要求恢复到某个 revision 时才走此流程:
1. 用 `revision-get` 记录回滚前的当前 revision。
2. 目标 revision 必须来自同一工作簿的真实查询结果,或由用户明确提供;不要根据次数、时间或相邻版本推算。
3. 向用户展示工作簿、目标 revision 以及“当前内容将被覆盖”的风险,然后停止并等待明确确认;确认前禁止调用回滚工具,也禁止预先添加全局 `--yes`。
4. 只有用户对当前展示的参数明确确认后,执行器才可对同一条命令动态追加全局 `--yes` 并执行;任一参数发生变化都必须重新确认。
5. 用 `csv-get`、`range read` 或其他对应读取命令回读所需内容。
6. 需要审计回滚事件时,用回滚前后的 revision 查询 `changeset-get`,确认出现 `STATE_RESET`,且 `reset.targetRevision` 等于目标 revision。
## 上下文传递
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `version list` | `version`(版本号) | 作为 `version revert --version` 的入参 |
| `revision-get` / `changeset-get` | 已确认属于同一工作簿的 `revision` | 用户明确要求精确恢复时,作为 `version revert --version` 的入参 |
| `version save` | 版本快照结果 | 确认已生成快照 |
| `version revert` | 回滚结果 | 确认回滚完成,回读表格内容验证 |
## 注意事项
- ★ 回滚 `version revert` 会**覆盖当前表格内容**,属危险操作,默认二次确认;确认无误再执行,脚本/Agent 场景加 `--yes`
- ★ `--version` 必须来自 `version list` 的真实返回,禁止臆测版本号
- 底层复用 doc 域的历史版本能力(`save_doc_version` / `list_doc_versions` / `revert_doc_version`),`--node` 传在线电子表格文档即可
- ★ 回滚 `version revert` 会**覆盖当前表格内容**,属危险操作;确认前禁止调用工具。只有用户对当前完整参数明确确认后,执行器才可动态追加全局 `--yes`;任一参数变化都必须重新确认
- ★ 默认从 `version list` 选择历史版本;只有用户明确要求精确 revision 时,才使用同一工作簿真实查询结果中已确认的 revision
- ★ 未列入版本列表的 revision 不保证仍可恢复;失败时直接说明,不猜测其他 revision,不自动重试
- ★ `version list` 返回的 `version` 可作为 `changeset-get` 的 start/end 锚点,但相邻历史版本之间可能包含多个 revision。需要查看逐次变更时读 [sheet-revision-changeset](./sheet-revision-changeset.md)
- 回滚后应用独立读命令(`csv-get` / `range read`)回读确认,避免"写返回不等于完成"
Loaded 100 of 107 files, more files were not shown because too many files have changed in this diff. Show more