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
liyuan 49ab53ea0d 评审问题修复 2026-08-21 14:29:05 +08:00
anxiangbo 1308d08862 Merge branch 'DingTalk-Real-AI:main' into feat/20260817_agoal_search 2026-08-21 14:00:30 +08:00
anxiangbo 6d9781fe15 Merge branch 'main' into feat/20260817_agoal_search 2026-08-21 11:04:19 +08:00
anxiangbo c790fe3c3b Merge branch 'main' into feat/20260817_agoal_search 2026-08-21 10:44:53 +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
anxiangbo b10da77e09 Merge branch 'main' into feat/20260817_agoal_search 2026-08-20 15:22:19 +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
58 changed files with 3783 additions and 847 deletions
@@ -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.
+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 }}
+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)
}
}
@@ -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 -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",
+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)
}
@@ -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
@@ -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 数组,再用:
+2 -2
View File
@@ -218,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 -->
## 意图表
+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` |
@@ -218,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 -->
## 意图表
@@ -8,6 +8,17 @@ 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 -->
## 定位白板
每次操作都需要真实的文档 `nodeId` 和白板 `partId`。缺少 `partId` 时先读取文档
@@ -21,7 +32,7 @@ dws doc read --node <DOC_ID> --content-format jsonml --scope tags --tags card --
## 读取
```bash
dws whiteboard query --node <DOC_ID> --part-id <PART_ID> --format json
dws whiteboard +query --node <DOC_ID> --part-id <PART_ID> --format json
```
CLI 会把服务端 `resultJson` 字符串解析为结构化 JSON。白板命令不支持全局
@@ -61,14 +72,16 @@ CLI 会把服务端 `resultJson` 字符串解析为结构化 JSON。白板命令
- `overwrite=false`:追加,`nodes` 至少一个对象。
- `overwrite=true`:整页重建,允许空数组;执行前必须先 query 保存当前内容。
- 所有更新都是远端写入,必须先获得用户确认,再加 `--yes`。
- 所有更新都是远端写入,必须先获得用户对本次写入的确认;存储示例不携带 `--yes`,执行层只能在确认后添加。
- Query 返回不能直接作为 update 输入;真实节点 ID 不能用于局部修改。
```bash
dws whiteboard update --node <DOC_ID> --part-id <PART_ID> \
--source ./whiteboard.json --yes --format json
dws whiteboard +update --node <DOC_ID> --part-id <PART_ID> \
--source @whiteboard.json --format json
```
`+update` 会严格验证终态 receipt、请求节点到真实节点的映射,并对同一 `nodeId` / `partId` 执行独立读回;不需要再以手写原子命令拼装验证链。
常用节点类型包括 `text`、`shape`、`frame`、`group`、`connector`、`vector`。
节点可用请求内临时 `id` 建立 `parentId` 或 connector 引用;服务端负责完整字段、
层级和枚举校验,未知字段会使整次更新失败。
+71 -45
View File
@@ -11,13 +11,11 @@ metadata:
# 钉钉待办 Skill
## 执行契约
## 前置条件 — 执行操作前必读
- 只用 `dws` 操作钉钉待办,命令统一加 `--format json`,并按结构化业务返回判断结果。
- 已知路线直接执行;仅在 leaf 参数或安全语义不确定时查精确 Schema,在 flag 不确定时查精确 Help。不要先枚举整个 Catalog,也不要连续猜命令。
- 后续 ID 必须来自本次真实返回;跨步骤可混用 shortcut 与原子命令,但只传递规范化后的稳定 ID,不能假设两类命令的完整返回结构相同。零匹配、多匹配或类型不明时停止并消歧。
- 写操作遵循最终 Runtime gate。需要确认时先说明对象、动作和影响,用户确认后才追加 `--yes`;不要把 `--yes` 写进存储示例。
- 写后必须核验。非幂等写超时、缺少稳定 ID 或读回失败时先对账,禁止盲目重放。
> **CRITICAL — 执行任何 `dws` 操作前,MUST 先用 Read 工具完整读取 [`dingtalk-shared`](../dingtalk-shared/SKILL.md)。**该轻量文件包含全局执行契约、安全底线及 shared references 的按需加载导航;不要预加载其全部 references。
> 命令参考:[todo.md](references/todo.md);剧本:[02-task.md](references/02-task.md)。
<!-- VISIBLE_SHORTCUTS_START -->
## Shortcuts(无专用脚本/recipe 时优先)
@@ -46,52 +44,80 @@ metadata:
| `dws todo +update` | write | 更新待办并读回验证 |
<!-- VISIBLE_SHORTCUTS_END -->
## 路由优先级
## 意图表
不要按整段请求强制选择同一类命令;先拆成有序步骤,再逐步选择最窄且完整覆盖该步骤的入口:
| 用户说 | 命令 |
|--------|------|
| "建一条待办给张三" | `dws todo task create --title "<标题>" --executors <userId>` |
| "较高 / 高优先级待办" | `dws todo task create ... --priority 30`(10低/20普通/30较高/40紧急) |
| "紧急 / 最高优先级 / 立即处理" | `dws todo task create ... --priority 40` |
| "循环待办(每天)" | `dws todo task create ... --due "<首次截止ISO>" --recurrence "DTSTART:<UTC>\nRRULE:FREQ=DAILY;INTERVAL=1"` |
| "批量建待办" | 按 SOP-4 逐条创建、收集 `taskId` 并批量回读 |
| "今天 / 本周未完成待办" | `python scripts/todo_daily_summary.py [today\|tomorrow\|week]` |
| "逾期待办" | `python scripts/todo_overdue_check.py` |
| "标记完成 / 重开" | `dws todo task done --task-id <taskId> --status true\|false` |
| "修改标题/截止时间/优先级" | `dws todo task update --task-id <taskId> ...` |
| "删除待办" | `dws todo task delete --task-id <taskId>`(需用户确认) |
1. **确定创建入口**:按姓名指派用 `+assign` / `+assign-multi`。给自己创建且后续只有定位、回读和清理时用 `+remind`;已有 `userId` 的同类短流程用 `+create`。后续若有筛选、资源写入或多个对象,则用原子 `task create`。
2. **后续逐步路由**:详情、完成、重开、更新、搜索、评论、提醒及评论/附件/子待办列表,优先用完整覆盖该步的 `+get` / `+complete` / `+reopen` / `+update` / `+search` / `+comment` / `+reminder` / `+list-*`。无完整 shortcut 时使用原子命令。
3. **跨步骤只传稳定 ID**:shortcut 与原子命令可以共存;创建后先提取真实 `taskId`,评论、附件和标签再从各自真实返回取 ID。不要把某个入口的包装字段路径套到另一个入口。
4. **确定性批量/汇总 → 脚本**:批量创建、今天/明天/本周汇总、逾期扫描分别用 bundled scripts。
5. **未知低频能力 → 精确 Reference/Help**:只读当前操作的小节,不要枚举后试错。
## 标准 SOP(必遵流程)
## 高频路线
> 命中以下意图**必须**按对应 SOP 顺序执行;**禁止**跳步、替换命令、编造 taskId。每条命令必须带 `--format json`。创建/完成/删除后**必须**回读验证,不要凭创建返回或口头计划就结束。
| 用户意图 | 首选入口 | 边界 |
|---|---|---|
| 给自己创建,随后只定位、看详情或清理 | `dws todo +remind --task "<标题>" [--at "<截止ISO>"] --format json` | `--at` 是截止时间,不是独立提醒;若后续有列表筛选或资源写入则改用原子创建 |
| 按姓名给一人/多人指派一条待办 | `+assign` / `+assign-multi` | 任一姓名不唯一就停止,不能猜 `userId` |
| 已有 `userId`,只创建、回读和清理一条待办 | `dws todo +create --title "<标题>" --executors <USER_ID> ... --format json` | 结果必须含稳定 `taskId` 且读回一致 |
| 创建后还要筛选或修改资源 | `dws todo task create ... --format json` | 从 `result.taskId` 进入后续步骤;后续读取仍可选 `+get` 等 shortcut |
| 按状态、优先级、角色、日期或页码枚举 | `dws todo task list ... --format json` | `--status false/true`;`hasMore=true` 继续翻页 |
| 当前组织我的待办 / 与我相关的全部待办 | `+get-my-tasks` / `+get-related-tasks` | 后者是创建人、执行人、参与人并集 |
| 按标题关键词定位 / 已知 ID 查详情 | `+search --query ...` / `+get --task-id ...` | 零个或多个候选时停止消歧 |
| 已知 ID 完成、重开、更新 | `+complete` / `+reopen` / `+update` | shortcut 会做状态检查或读回核验 |
| 今天到期 / 逾期 | `+due-today` / `+overdue` | 空集合也是成功结果 |
| 设置或清除独立提醒 | `+reminder` | 上游无提醒查询接口,只能报告写回执,不能声称读回 |
| 创建、改名、列出或删除待办标签 | `tag create` / `tag update` / `tag list` / `tag delete` | “待办标签”属于 Todo,不是通讯录标签、Git tag 或其他产品标签 |
### SOP-1 建待办(create-todo)
## 组合任务闭环
**触发**:建待办/任务提醒/指派任务/TODO。
1. 执行前列出用户要求的全部资源动作及顺序,并为每一步单独选 shortcut 或原子命令;同一链路使用同一个 profile。
2. 原子创建必须先取得执行人:未指定执行人用 `dws contact me --format json`;指定姓名用 `dws aisearch person --query "<姓名>" --dimension name --format json` 并唯一匹配。
3. 原子创建从 `result.taskId` 取 ID;创建 shortcut 从其成功结果的 `taskId` 取 ID。后续评论、附件和标签编号分别来自 `+comment`/`comment list`、`task list-attachment`、`tag create/list` 的真实返回;不要跨入口猜字段层级。
4. 每次写后使用对应 read/list 核验;提醒例外,只保留终端写回执。最后仅清理本次创建且已经记录 ID 的对象。
1. **解析执行者(必须)**:指定姓名 → `dws aisearch person --query "<姓名>" --dimension name --format json` 取 `userId`;未指定 → `dws contact user get-self --format json` 取当前用户 `userId`;多人逐个搜索后英文逗号拼接。
2. **执行(必须)**:`dws todo task create --title "<标题>" --executors <userId>[,<userId2>...] --priority <10/20/30/40> --format json`;有截止时间加 `--due "<ISO>"`;循环待办加 `--due "<首次截止ISO>" --recurrence "DTSTART:<UTC>\nRRULE:FREQ=DAILY;INTERVAL=1"`。
3. **验证(必须)**:从返回取 `taskId`/`todoTaskId`,立即 `dws todo task get --task-id <taskId> --format json` 回读。
## 关键约束
**禁止**:跳过执行者解析直接传姓名、用 `task detail` 取详情(正确是 `task get`)、创建后不回读。
- 公开命令统一使用 `--task-id`;不要在新命令中使用隐藏兼容别名 `--id` / `--ids`。
- 优先级:低=10、普通=20、较高/高/重要=30、紧急/最高/P0=40。
- `--due` 与 `+remind --at` 表示 deadline;独立 reminder 用 `+reminder` 或原子 `task add-reminder`。
- `task list` 用 `--status`,不要写 `--done`;详情命令是 `task get`,不存在 `task detail`。
- “待办标签”始终使用 `dws todo tag ...`,绝不能解释为 Git tag、文档标签或其他产品标签。
- 本地文件作为待办附件时使用 `task add-attachment --file <绝对路径>`;先确认待办存在,不得用上传动作试探权限。
- 会后行动项先走 `dingtalk-minutes` 取真实内容;OA 审批走 `dingtalk-misc`;时间块和会议走 `dingtalk-calendar`。
### SOP-2 查询待办(query-todo)
## 按需参考
**触发**:查待办/今天本周待办/未完成/已完成。
- [单步与短流程](references/lite-recipes.md)
- [组合生命周期与动态 ID 传递](references/02-task.md)
- [局部意图消歧](references/intent-guide.md)
- [完整原子命令参考](references/todo.md)
1. **执行(必须)**:`dws todo task list --status false|true --format json`(`false`=未完成、`true`=已完成、不传=全部);`hasMore=true` 必须翻页。
2. **摘要脚本(必须)**:今天/本周未完成 → `python scripts/todo_daily_summary.py today|tomorrow|week`;逾期 → `python scripts/todo_overdue_check.py`。
3. **详情(必须)**:`dws todo task get --task-id <taskId> --format json`;按主题筛选先 `task list` 再按标题过滤,**禁止**编造主题查询 flag。
**禁止**:写 `--done true`(用 `--status true`)、编造主题筛选参数。
### SOP-3 完成 / 重开 / 改 / 删(mutate-todo)
**触发**:标记完成/重开/改标题截止优先级/删待办。
1. **执行(必须)**:完成/重开 `dws todo task done --task-id <taskId> --status true|false --format json`;修改 `dws todo task update --task-id <taskId> ...`;删除 `dws todo task delete --task-id <taskId>`(**必须**先与用户确认)。
2. **验证(必须)**:`task done`/`update` 后用 `task get` 或对应 `task list --status ...` 回读确认;`delete` 后用 `task get` 确认已不存在或列表已移除。
**禁止**:未确认就删除、用 `update --done`(首选 `task done --status`)、改动后不回读。
### SOP-4 批量建待办(batch-create)
**触发**:批量建待办/一次建多条。
1. **解析(必须)**:执行者姓名先批量解析成真实 `userId`;单批最多 30 条。
2. **执行(必须)**:对每条待办执行 `dws todo task create --title "<标题>" --executors <userId> --priority <10/20/30/40> [--due "<ISO>"] --format json`,逐条收集返回的 `taskId`/`todoTaskId`。可并行执行,但不得丢失“输入条目 → taskId”对应关系。
3. **验证(必须)**:对全部新建 `taskId` 执行 `dws todo task get --task-id <taskId> --format json` 回读;多 ID 按共享并行规则处理,全部成功后才能报告批量创建完成。
**禁止**:只统计创建命令退出码、不保留 taskId、创建后不回读、在执行者位置传姓名。
## 参数硬约束
- 任务详情只用 `dws todo task get --task-id <taskId>`;不要写 `task detail`。
- 完成状态首选 `dws todo task done --task-id <taskId> --status true|false`;若用 `update`,也必须是 `--task-id` + `--done true|false`。
- 查询列表完成状态用 `dws todo task list --status false|true --format json`。不要写 `--done true` 作为可见参数,虽然兼容但不作为推荐写法。
- `--id` / `--ids` 是隐藏兼容别名,文档和生成命令统一写 `--task-id`,减少模型漂移。
- 优先级映射:低=10,普通=20,较高/高/重要=30,紧急/最高/P0/马上处理=40;不要把"较高"写成 40。
- 截止时间必须是 ISO-8601。相对日期按当前日期计算;例如周五说"下周二"就是紧接下一个自然周的周二,不要再加一周。
- 创建、标记完成、重开、删除后必须 `task get` 或对应 `task list --status ...` 验证,不要只凭创建返回或口头计划结束。
- 所有 dws 命令带 `--format json`。
## 跨产品协作
- 执行人是人名 → 先用 `dingtalk-aisearch` 拿 `userId`
- 会后从听记自动建待办 → 切到 `dingtalk-minutes`
- 项目进度汇总写文档 → 切到 `dingtalk-doc`
## 局部意图与短流程
- [局部意图消歧](references/intent-guide.md);[短流程](references/lite-recipes.md)。
@@ -1,61 +1,19 @@
# Todo 组合生命周期
# 任务管理
当一个请求包含多个资源动作并需要传递 `taskId`、`commentId`、`attachmentId` 或 `tagCode` 时使用本文件。组合链路可以逐步混用 shortcut 与原子命令,但只能传递已经规范化的稳定 ID,不能把一种入口的完整返回结构当成另一种入口的结构。
> **SKILL.md** 中 #2 仅内联 **lite**:`create-todo`、`todo-query-ops`。其中 `todo-query-ops` 统一覆盖 list/get/complete/reopen/topic-filter。下列 recipe 已迁出速查表,命中时读本文件对应行。重型 **full** 见下表「行动指南」。命令细节见 [todo.md](./todo.md)。
## 通用骨架
## Recipe 速查(非 SKILL lite)
1. 先列出完整动作序列和需要传递的 ID,不要边执行边发现路线。
2. 未指定执行人时运行 `dws contact me --format json`;指定姓名时运行 `dws aisearch person --query "<姓名>" --dimension name --format json`,唯一匹配后取 `userId`。
3. 选择创建入口:后续只有搜索、详情回读和清理时可用 `+remind` / `+create`;若要列表筛选、资源写入、创建子资源或一次创建多个对象,使用原子创建:
| Recipe | 步骤(命令均须 `--format json`,下略) |
|--------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `create-priority-todo` | 1. 确定执行者(同 [SKILL.md](../SKILL.md) 中 `create-todo` 步骤 1)<br>2. `todo task create --title "<标题>" --executors <userId>[,<userId2>...] --priority <10/20/30/40>`(可选 `--due "<截止ISO>"`;10低/20普通/30较高/40紧急)→ 取 `todoTaskId`<br>3. `todo task get --task-id <todoTaskId>` 回读标题、执行者、优先级和截止时间 |
| `create-recurring-todo` | 1. 确定执行者(同 `create-todo` 步骤 1)<br>2. `todo task create --title "<标题>" --executors <userId> --due "<首次截止ISO>" --priority <10/20/30/40> --recurrence "DTSTART:<UTC时间>\nRRULE:FREQ=DAILY;INTERVAL=1"`(`--due` 必填;仅支持按天循环,见 [todo.md](./todo.md))→ 取 `todoTaskId`<br>3. `todo task get --task-id <todoTaskId>` 回读循环规则和任务字段 |
| `reschedule-todo` | 1. `todo task list --status false` → 取 `todoTaskId`<br>2. `todo task update --task-id <todoTaskId> --due "<新截止时间>"` |
```bash
dws todo task create --title "<标题>" --executors <USER_ID> [--priority 10|20|30|40] [--due "<截止ISO>"] [--recurrence "<规则>"] --format json
```
## Full / 组合(固定路线)
4. 原子创建从成功响应的 `result.taskId` 取 ID;创建 shortcut 从其成功结果的 `taskId` 取 ID。需要详情时优先 `dws todo +get --task-id <TASK_ID> --format json`。
5. 按下表执行后续动作;每一步都复用真实 ID。表中原子命令是保底路径;若对应 `+get`、`+search`、`+complete`、`+reopen`、`+update`、`+comment`、`+reminder` 或 `+list-*` 完整覆盖当前步骤,则优先使用 shortcut 自带的校验。
6. 只清理本次创建且已记录 ID 的对象;删除类操作先走 Runtime 确认门。删除后 `task get` 不存在或列表移除才算清理完成。
## 原子路线表
| 意图 | 写命令 | 核验 / ID 来源 |
|---|---|---|
| 按状态/优先级/角色/日期查询 | `task list --status ... --priority ... --role-types ... --plan-finish-date-start ... --plan-finish-date-end ...` | 遍历 `result.todoCards[]`;`hasMore=true` 时递增 `--page` |
| 更新标题/优先级/截止时间 | `task update --task-id <TASK_ID> ...` | `task get --task-id <TASK_ID>` 逐字段核验 |
| 完成 / 重开 | `task done --task-id <TASK_ID> --status true|false` | `task get` 或对应状态的 `task list` |
| 创建子待办 | `task create-sub --parent-id <PARENT_ID> --title "<标题>" --executors <USER_ID>` | 取响应 `result.taskId`;`task list-sub --task-id <PARENT_ID>` |
| 增删执行人 | `task add-executor --task-id <TASK_ID> --executors <USER_ID>` / `task remove-executor --task-id <TASK_ID> --executors <USER_ID>` | `task get`;只移除本次明确添加的人 |
| 增删参与人 | `task add-participant --task-id <TASK_ID> --participants <USER_ID>` / `task remove-participant --task-id <TASK_ID> --participants <USER_ID>` | `task get`;执行人与参与人不可混用 |
| 添加评论 | `comment add --task-id <TASK_ID> --content "<内容>"` | 从 `comment list` 的 `result.comments[].id` 取真实评论 ID 并核对内容 |
| 删除评论 | `comment delete --task-id <TASK_ID> --comment-id <COMMENT_ID>` | 再次 `comment list` 确认目标不存在 |
| 上传附件 | `task add-attachment --task-id <TASK_ID> --file <绝对路径>` | `task list-attachment` 取真实 `attachmentId` 并核对文件名 |
| 移除附件 | `task remove-attachment --task-id <TASK_ID> --attachment-id <ATTACHMENT_ID>` | 再次 `task list-attachment` |
| 添加截止前提醒 | `task add-reminder --task-id <TASK_ID> --base-time dueTime --due-date-offset -30` | 只有终端写回执;待办必须已有截止时间 |
| 添加独立提醒 | `task add-reminder --task-id <TASK_ID> --base-time customTime --reminder-time-stamp "<提醒ISO>"` | 只有终端写回执 |
| 替换/清空提醒 | `task reset-reminder --task-id <TASK_ID> [--reminder-rules '<JSON数组>']` | 不传规则即清空;不能声称已读回规则 |
| 创建/列出标签 | `tag create --name "<名称>"` / `tag list` | 创建前后比较 `result.userTags[]`,只接受唯一新增的 `code` |
| 关联标签 | `tag add --task-id <TASK_ID> --tag-codes <TAG_CODE_1>[,<TAG_CODE_2>]` | `task get` 或写回执;最多两个标签 |
| 改名标签 | `tag update --user-tags '[{"code":"<TAG_CODE>","name":"<新名称>"}]'` | `tag list` 核对同一 `code` 的名称 |
| 删除标签定义 | `tag delete --tag-codes <TAG_CODE>` | `tag list` 确认标签定义不存在 |
| 删除待办 | `task delete --task-id <TASK_ID>` | `task get` 不存在或对应列表已移除 |
所有表中命令都加前缀 `dws todo` 和后缀 `--format json`。
## 动态 ID 账本
| ID | 只允许来自 | 禁止来源 |
|---|---|---|
| `taskId` | 原子 `task create/create-sub/get/list` 或 Todo shortcut 的成功业务返回 | 标题、URL、展示序号、其他 case |
| `commentId` | 同一 `taskId` 的 `+comment` 成功结果或 `comment list` 的 `result.comments[].id` | 评论文本或猜测 |
| `attachmentId` | 同一 `taskId` 的 `task list-attachment` 返回 `attachments[].attachmentId` | 文件名或本地路径 |
| `tagCode` | `todo tag create/list` 返回 `result.userTags[].code` | 标签名或 Git tag |
| `userId` | `contact me` 或 `aisearch person` 的唯一匹配 | 姓名、手机号片段、其他 profile |
“待办标签”只能调用 `dws todo tag ...`;禁止运行 `git tag`。本地待办附件只能调用 `dws todo task add-attachment`,不要改走 Drive。
## 失败与恢复
- `unknown command/flag`:查该 leaf 的 `--help` 后最多修正一次,不要轮询相似命令。
- 创建、评论等非幂等写超时或返回不明:保留“可能已提交”,先按标题/父 ID 查询对账,不自动重试。
- 提醒接口没有查询能力:成功时只报告服务端接受写入;失败时保留原错误。
- 任一步失败后仍要按账本清理已创建的临时对象;未取得稳定 ID 的对象不得猜 ID 清理。
| Recipe | 行动指南(固定路线) |
|--------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| generate-progress-report | 1. 按[「多源并行采集」](recipes/conventions.md#多源并行采集公共模式)执行<br>2. 交叉比对各源数据<br>3. `doc create --name "<报告名>" --content "<报告内容>"` |
| batch-create-todo | 1. 按[「多源并行采集」](recipes/conventions.md#多源并行采集公共模式)执行 → 从结果提取任务条目<br>2. 每条:`aisearch person --query "<姓名>" --dimension name` → 取真实 `userId`;同名时先消歧<br>3. 逐条执行 `todo task create --title "<标题>" --executors <userId> --priority <10/20/30/40>`,从每次响应收集真实 `todoTaskId`;单批超 30 条须用户确认<br>4. 对全部 `todoTaskId` 并行执行 `todo task get --task-id <todoTaskId>`,逐项核对标题、执行者、优先级和截止时间;不能只以退出码或创建响应作为成功证据 |
| assign-and-notify | 1. `aisearch person --query "<姓名>" --dimension name` → 取 `userId`<br>2. `todo task create --title "<标题>" --executors <userId> --priority <10/20/30/40>` → 取 `todoTaskId`<br>3. `todo task get --task-id <todoTaskId>` 回读任务,确认无误后再通知<br>4. `chat search --query "<群名>"` → 取 `openConversationId` → `chat message send --conversation-id <openConversationId> --content "<通知内容>"` |
@@ -1,15 +1,11 @@
# Todo 局部意图消歧
# todo 局部意图消歧
| 用户说 | 应该用 | 不要用 | 边界 |
|---|---|---|---|
| “帮我记一下明天要做的事” | `todo +remind` | `doc` | 这是个人待办;`--at` 写截止时间 |
| “给自己留一个明天下午的时间块” | `calendar event create` | `todo` | 时间块属于日历事件 |
| “明早 9 点提醒我提交周报” | 先创建待办,再用 `todo +reminder --base-time customTime --at ...` | 把 `+remind --at` 当提醒 | 独立提醒与截止时间是两个字段 |
| “截止前 30 分钟提醒” | `todo +reminder --base-time dueTime --due-date-offset -30` | `calendar` | 待办必须先有截止时间 |
| “每天重复提醒我” | `todo task create --due ... --recurrence ...`,必要时再加 reminder | 只写 reminder | recurrence 管重复待办;reminder 管单条待办提醒 |
| “创建/修改/删除待办标签” | `dws todo tag ...` | `git tag`、文档标签 | 标签名不是 `tagCode`,后续使用真实返回 |
| “提交日报/周报” | `dingtalk-misc` | `todo` | 日志产品不是个人待办 |
| “审批这个申请” | `dingtalk-misc` | `todo` | OA 审批任务与个人待办不同 |
| “把会议行动项建成待办” | 先用 `dingtalk-minutes` 取真实行动项,再进入 Todo | 凭标题猜行动项 | 来源证据归听记,任务对象归 Todo |
本文件从单 Skill `intent-guide.md` 拆分而来,仅保留与本产品相关的跨产品消歧规则。
提醒写入目前没有对应查询接口,成功响应只能证明服务端接受了写请求,不能声称已经读回核验规则。
| 用户说... | 真实意图 | 应该用 | 不要用 | 理由 |
|---|---|---|---|---|
| "帮我记一下明天要做的事" | 创建个人待办 | `todo` | `doc` | 个人待办提醒,非文档内容 |
| "给自己留一个明天下午的时间块/建个个人日程" | 创建个人日程 | `calendar event create` | `todo` | 个人 schedule 仍属于日历事件,不是待办 |
| "明早 9 点提醒我提交周报" | 创建个人待办,但需先声明 reminder 边界 | `todo` | `calendar` | todo 当前只支持 dueTime 截止时间,不支持独立精确 reminder |
| "帮我创建一个待办提醒" | 个人待办 | `todo` | `report` | 个人任务提醒,不是日志汇报 |
| "把最近几次关于XX的会议汇总成报告" | 按主题汇总多次听记 | #5 generate-topic-report | #7 meeting-followup | #7 是单次会议听记跟进;多次会议按主题汇总属于工作汇报 |
@@ -1,57 +1,151 @@
# Todo 单步与短流程
# todo Lite Recipe
用于单步 Todo 意图,也用于组合请求中被 shortcut 完整覆盖的独立步骤。涉及动态子资源 ID 或原子特有操作时,同时读 [02-task.md](02-task.md)。
本文件从单 Skill `lite-recipes.md` 拆分而来,仅保留与本产品相关的轻量流程。
## 创建一条待办
## #2 任务管理
给自己:
### create-todo
1. 确定执行者:指定姓名 → `aisearch person --query "<姓名>" --dimension name` → `userId`;未指定 → `contact user get-self` → `userId`;多人 → 逐个搜索逗号拼接。
2. 创建:`todo task create --title "<标题>" --executors <userId>[,<userId2>...] --priority <优先级>`(可选 `--due "<截止ISO>"`)→ `todoTaskId`
### todo-query-ops
- 查询:`todo task list [--status false|true]`(不传=全部)
- 详情:`todo task get --task-id <id>`
- 完成/重开:`todo task done --task-id <id> --status <true|false>`
- 按主题筛选:list 后按标题关键词过滤
## #7 听记与会后
> 产品命令完整参考见 [minutes.md](../../dingtalk-minutes/references/minutes.md)。full recipe 见 [07-minutes.md](../../dingtalk-minutes/references/07-minutes.md)。
### minutes-query(查询与获取)
> **scope 选择铁律(P2 真实 badcase)**:`list` 后的 scope 决定查询范围,最高频误判是把"我能访问的所有听记"错选成 `mine`:
> - `mine` = **仅我自己创建/发起**的听记(范围最窄)
> - `shared` = **仅他人共享给我**的听记
> - `all` = **我可访问的全部**(= mine ∪ shared,范围最广)
> - **判定口诀**:query 含"访问/权限/可见/能看到/所有/我的"等覆盖范围语义 → 一律走 `all`;**仅当**明确说"我创建的/我发起的/我录的" → 才走 `mine`。**不要因为句子里有"我"字就退化成 `mine`。**
> - 错误:`我能访问的所有听记` → `list mine`(漏掉共享给我的,判定不通过)
> - 正确:`我能访问的所有听记` → `dws minutes list all --format json`
> **选对象铁律(0605 P2 EDD badcase 提炼,命令对了但选错听记 = 整任务失败)**:list/搜索拿到结果后,必须按语义精准锁定目标听记,详见 [minutes.md](../../dingtalk-minutes/references/minutes.md)「选对象铁律 S1~S6」。速记:
> - **S1 跨组织汇总**:以 list 返回的 `taskUuid + title + organizationName` 三元组为准逐条照抄,组织与听记不可张冠李戴。
> - **S2 "最近一次某类会议"**:先 `--query "<主题词>"`(如周会)过滤出该类,再在候选里取时间最新;主题匹配优先级高于时间。
> - **S3 比时长最长**:必须读 `durationMicros` 字段做数值比较,禁止凭印象/标题猜,口头结论与操作的 taskUuid 须自洽。
> - **S4 内容为空**:锁定 taskUuid 后所有 get/update 复用同一 id;某字段为空就如实说,**禁止偷偷切换到另一条听记**。
> - **S5 模糊日期匹配不到**:日期可能是"会议主题日期"而非"创建日期",按标题关键词搜,精确日期没命中就放宽 ±7 天/同主题候选请用户确认,**禁止直接报"找不到"**。搜索回退策略:① 先 `--query "<主题关键词>"` 不带日期搜 → ② 若结果过多则加 `--start/--end` 扩大到 ±7 天 → ③ 列出候选让用户确认。
> - **S6 给标题没给 id**:必须先 `list all --query "<标题关键词>"` 定位 taskUuid 再 update/get,禁止凭记忆直接填 `--id` 跳过定位。
**列表查询**(`list` 后**必须**跟 scope:`mine`/`shared`/`all`,默认补 `all`):
```bash
dws todo +remind --task "<标题>" [--at "<截止ISO>"] --format json
# 我可访问的所有听记(默认)
dws minutes list all --format json
# 按关键词服务端搜索(严禁全量拉取后本地 grep)
dws minutes list all --query "周会" --format json
# 按时间范围筛选(ISO-8601 格式)
dws minutes list mine --start "2026-05-01T00:00:00+08:00" --end "2026-05-25T23:59:59+08:00" --format json
# 关键词 + 时间组合
dws minutes list all --query "需求评审" --start "2026-05-25T00:00:00+08:00" --end "2026-05-25T23:59:59+08:00" --format json
# 限制条数
dws minutes list mine --limit 5 --format json
# 共享给我的听记
dws minutes list shared --query "ROI" --format json
```
按姓名指派:
| 参数 | 说明 |
|------|------|
| `--query "<关键词>"` | 服务端关键词搜索 |
| `--start "<ISO-8601>"` | 开始时间 |
| `--end "<ISO-8601>"` | 结束时间 |
| `--limit <N>` | 每页条数,默认 10(`--max` 为兼容别名) |
| `--cursor "<token>"` | 分页 token,首页留空(`--next-token` 为兼容别名) |
**获取详情**:
- 批量基础信息:`minutes get batch --ids <uuid1,uuid2,...>`
- 单篇摘要:`minutes get summary --id <taskUuid>`
- 转写原文(自动翻页):`minutes get transcription --id <taskUuid>`(返回 `nextToken` 时用 `--next-token <token>` 继续)
- 关键词:`minutes get keywords --id <taskUuid>`
- 待办事项:`minutes get todos --id <taskUuid>`
- 基础信息:`minutes get info --id <taskUuid>`
- 音频地址:`minutes get audio --id <taskUuid>`
> `--id`/`--uuid`/`--task-uuid` 三者等价。推荐 `--id`。
### minutes-edit(编辑与替换)
- **替换转写文字**:`minutes replace-text --id <taskUuid> --search "旧文字" --replace "新文字"`
- 执行前检查特殊字符(引号/书名号/括号等),若包含先提示用户确认去除
- 替换成功后追问是否加热词:`minutes hot-word add --words "新文字"`
- **替换发言人**:先统一搜人取得 dingUid → `minutes speaker replace --id <taskUuid> --from "发言人X" --to "姓名" --target-uid <userId>`
- 查询 dingUid:`aisearch person --query "姓名" --dimension name --format json` → 取 `userId`
- 多个匹配 → 列出候选让用户选;无匹配 → 不带 `--target-uid` 执行
- **修改标题**:`minutes update title --id <taskUuid> --title "新标题"`
- **修改摘要**:`minutes update summary --id <taskUuid> --content "新内容"`
- **热词管理**:`minutes hot-word add --words "词1,词2"` / `minutes hot-word list`
- **思维导图**:`minutes mind-graph create --id <taskUuid>` → `mind-graph status --id <taskUuid>` 轮询至完成
### minutes-tag(标签/分组查询)
- 查询标签列表:`minutes tag list` → 返回用户在听记页面创建的所有标签/分组(含 tagId 和名称)
- 按标签查听记:`minutes tag query --tag-id <tagId> [--limit 20] [--cursor <token>]`
- tagId 来自 `tag list` 返回值,不可编造
- 支持分页,`--cursor` 传入上一次返回的 nextToken
**典型链路**:用户说"帮我看看'周会'标签下的听记" →
1. `dws minutes tag list --format json` → 按名称匹配找到 tagId
2. `dws minutes tag query --tag-id <tagId> --format json`
### minutes-permission(权限管理)
- 添加成员:`minutes permission add --ids <uuid1,uuid2> --member-uids <uid1,uid2> --policy 4`
- 需先通过 `aisearch person --query "<姓名>" --dimension name` 获取目标 userId
- policy:0=不可见 / 1=仅查看 / 2=查看+下载 / 3=查看+下载+编辑 / 4=全部权限
- 移除成员:`minutes permission remove --ids <uuid1,uuid2> --member-uids <uid1,uid2>`
### minutes-upload(音频上传)
```bash
dws todo +assign --to "<姓名>" --task "<标题>" --format json
dws todo +assign-multi --to "<姓名1>,<姓名2>" --task "<标题>" --format json
# 创建上传会话
dws minutes upload create --file-name "meeting.mp3" --file-size 61565431 --format json
# 上传完成后确认
dws minutes upload complete --session-id <sid> --format json
# 取消上传
dws minutes upload cancel --session-id <sid> --format json
```
已经有真实 `userId`:
### 最佳实践案例速查(详见 [minutes.md](../../dingtalk-minutes/references/minutes.md))
```bash
dws todo +create --title "<标题>" --executors <USER_ID> [--priority 10|20|30|40] [--due "<截止ISO>"] --format json
```
| 案例 | 场景 | 正确链路 |
|------|------|----------|
| 案例 1 | 听记 URL + 创建思维导图 | 提取 taskUuid → `mind-graph create` → `mind-graph status` 轮询;**禁止**走 app-development 或前端库 |
| 案例 2 | 替换文字后未引导热词 | 检查特殊字符 → `replace-text` → 追问加热词 `hot-word add` |
| 案例 3 | 查听记拉了不必要的转写 | 用户只要列表 → `list` 即可,**不要**自动拉 `get transcription` |
| 案例 4 | 拉完转写只输出时间线原文 | 拉完后追问按发言人聚类 → 引导匹配 → 调用 `speaker replace` 写回 |
| 案例 5 | 查某人说了什么不引导替换 | 推断发言人 → **用户确认** → 结构化总结 → 引导 `speaker replace` |
| 案例 6 | 通讯录+部门+转写三路印证 | Step 3 画像 + Step 4 `aisearch person` 并发 → 置信度 ≥70% → 确认 → 替换 |
| 案例 7 | grep 花名误判未参会 | **禁止**在转写文本里 grep 人名判参会;**必须**调 `aisearch person` |
| 案例 8 | 听记类 query 不走 dws | **禁止**用 session_search/browser_use/activity:search 替代 dws;模糊请求先 `list mine` |
| 案例 9 | 按标签筛选听记 | `tag list` → 按名称匹配 tagId → `tag query --tag-id <tagId>`;**禁止**编造 tagId |
后续只有搜索、详情回读和清理时可保留上述创建 shortcut;若还要列表筛选、更新状态/字段、提醒、评论、附件、成员、子待办、标签或创建多个对象,创建步骤改用原子 `task create`。成功结果必须含稳定 `taskId` 并完成读回。超时、缺少 ID 或 `verified!=true` 时先用搜索/列表对账,禁止重放非幂等创建。
### 听记取数深度约束(0609 点踩 case 提炼)
## 查询与定位
> 详细说明见 [minutes.md](../../dingtalk-minutes/references/minutes.md)。
```bash
dws todo +get-my-tasks --all --status false --format json
dws todo +get-related-tasks --format json
dws todo +search --query "<标题关键词>" --format json
dws todo +get --task-id <TASK_ID> --format json
```
- **转写原文硬约束**:用户诉求含「聚焦原话/逐字/沟通细节/具体讨论了什么」等词时,**必须先调 `get transcription` 翻页拉全**,禁止仅凭 summary 出稿
- **数据源下钻**:听记维度**必须 `get summary`(或 `get transcription`)读正文**,严禁只取标题列表;scope 用 `all`;空时换窗重试或标注
- **听记链接解析**:聊天消息中遇到听记链接(`flash_minutes_detail`/`SHANJI`)→ 解析 `minutesId` → 调 `minutes get summary/transcription`,禁止把链接降级为关键词
- **忠实性约束**:源数据无某要素(行动项/责任人/数字)时禁止生成;统计字段基于实际取数计数,不得编造
- **多源全覆盖**:用户枚举多数据源时每个来源都必须调对应工具;瞬时错误重试;如实声明缺失来源,禁编无来源数字
- list 用于枚举,search 用于标题关键词,get 用于已知稳定 ID。
- 空集合是成功;零匹配或多匹配时不得自行选第一条。
- 用户明确要求状态、优先级、角色、日期或页码筛选时,使用 `todo task list` 的对应 flag,不要拉全后猜。
### 间接意图识别铁律
## 完成、重开与更新
query 未提"听记"但任务产出依赖会议讨论内容时(报告/总结/日报/复盘/商业分析/市场感知),听记采集是**必跑前置步骤**:
```bash
dws todo +complete --task-id <TASK_ID> --format json
dws todo +reopen --task-id <TASK_ID> --format json
dws todo +update --task-id <TASK_ID> --title "<新标题>" --format json
```
只记得标题时用 `+todo-done --task "<关键词>"`;它只在唯一命中时修改。
## 提醒、汇总与批量
- `+remind --at` 设置截止时间;独立提醒使用 `+reminder --base-time customTime --at "<提醒ISO>"`。
- 截止前提醒使用 `+reminder --base-time dueTime --due-date-offset -30`,待办必须已有截止时间。
- 今天/明天/本周汇总:`python scripts/todo_daily_summary.py today|tomorrow|week`。
- 逾期扫描:`python scripts/todo_overdue_check.py`。
- 批量创建:`python scripts/todo_batch_create.py <todos.json>`;单批最多 30 条,以逐项 ledger 为准。
1. **铁律 A**:任务含"会议/讨论/沟通"信息需求 → `dws minutes list` 必跑
2. **铁律 B**:用户说"文档啥也没有" → 听记优先级更高(唯一结构化数据源)
3. **铁律 C**:多源聚合场景 → 每个被提及的数据源都必须有采集动作,听记侧 0 调用 = 严重失败
+24 -19
View File
@@ -108,6 +108,7 @@ Usage:
dws todo task delete [flags]
Example:
dws todo task delete --task-id <taskId>
dws todo task delete --task-id <taskId> --yes
Flags:
--task-id string 待办任务 ID (必填)
```
@@ -145,6 +146,7 @@ Usage:
dws todo comment delete [flags]
Example:
dws todo comment delete --task-id <taskId> --comment-id <commentId>
dws todo comment delete --task-id <taskId> --comment-id <commentId> --yes
Flags:
--task-id string 待办任务 ID (必填)
--comment-id string 评论 ID (必填)
@@ -235,6 +237,7 @@ Usage:
dws todo task remove-attachment [flags]
Example:
dws todo task remove-attachment --task-id <taskId> --attachment-id <attachmentId>
dws todo task remove-attachment --task-id <taskId> --attachment-id <attachmentId> --yes
Flags:
--attachment-id string 待办附件 ID (必填)
--task-id string 待办任务 ID (必填)
@@ -311,6 +314,7 @@ Usage:
dws todo tag delete [flags]
Example:
dws todo tag delete --tag-codes code1,code2
dws todo tag delete --tag-codes code1,code2 --yes
Flags:
--tag-codes string 要删除的标签编码列表,逗号分隔 (必填)
--yes 跳过交互确认,直接执行删除
@@ -380,7 +384,7 @@ Flags:
## 核心工作流
```bash
# 1. 创建待办 — 提取 result.taskId
# 1. 创建待办 — 提取 todoTaskId
dws todo task create --title "修复线上Bug" --executors userId1,userId2 \
--priority 40 --due "2026-03-10T18:00:00+08:00" --format json
@@ -405,8 +409,8 @@ dws todo task update --task-id <taskId> --title "新标题" --priority 40 --form
# 5. 标记待办完成
dws todo task done --task-id <taskId> --status true --format json
# 6. 删除待办(先按 Runtime gate 取得用户确认)
dws todo task delete --task-id <taskId> --format json
# 6. 删除待办
dws todo task delete --task-id <taskId> --yes --format json
# 7. 给待办新增评论
dws todo comment add --task-id <taskId> --content "已开始处理" --format json
@@ -414,8 +418,8 @@ dws todo comment add --task-id <taskId> --content "已开始处理" --format jso
# 8. 查看待办评论列表
dws todo comment list --task-id <taskId> --page 1 --size 20 --format json
# 9. 删除待办评论(先按 Runtime gate 取得用户确认)
dws todo comment delete --task-id <taskId> --comment-id <commentId> --format json
# 9. 删除待办评论
dws todo comment delete --task-id <taskId> --comment-id <commentId> --yes --format json
# 10. 添加待办执行人
dws todo task add-executor --task-id <taskId> --executors userId1,userId2 --format json
@@ -441,8 +445,8 @@ dws todo task list-sub --task-id <taskId> --format json
dws todo task add-attachment --task-id <taskId> --file /path/to/file.pdf --format json
# 20. 查询待办附件列表
dws todo task list-attachment --task-id <taskId> --format json
# 21. 删除待办附件(先按 Runtime gate 取得用户确认)
dws todo task remove-attachment --task-id <taskId> --attachment-id <attachmentId> --format json
# 21. 删除待办附件
dws todo task remove-attachment --task-id <taskId> --attachment-id <attachmentId> --yes --format json
# 22. 查询待办标签列表
dws todo tag list --format json
@@ -451,29 +455,30 @@ dws todo tag create --name "标签名" --format json
# 24. 给待办打标签
dws todo tag add --task-id <taskId> --tag-codes code1,code2 --format json
# 25. 更新待办标签
dws todo tag update --user-tags '[{"code":"code1","name":"新名称"}]' --format json
# 26. 删除待办标签(先按 Runtime gate 取得用户确认)
dws todo tag delete --tag-codes code1,code2 --format json
dws todo tag update --user-tags '[{"tagCode":"code1","name":"新名称"}]' --format json
# 26. 删除待办标签
dws todo tag delete --tag-codes code1,code2 --yes --format json
```
## 上下文传递表
| 操作 | 从返回中提取 | 用于 |
|------|-------------|---------------------------------------------|
| `task create` / `task create-sub` | `result.taskId` | update/done/get/delete/comment 的 `--task-id` |
| `task list` | `result.todoCards[].taskId` | update/done/get/delete/comment/member 操作的 `--task-id` |
| `task create` | `todoTaskId` | update/done/get/delete 的 --task-id |
| `task list` | `result[].id` | update/done/get/delete 的 --task-id |
| `task create` | `todoTaskId` | update/done/get/delete/comment 的 --task-id |
| `task list` | `result[].id` | update/done/get/delete/comment/add-executor/remove-executor/add-participant/remove-participant 的 --task-id |
| `task get` | `result.todoDetailModel.subTodos[]` | 获取子待办列表,提取子待办的 `taskId` 用于后续操作 |
| `comment list` | `result.comments[].id` | `comment delete` 的 `--comment-id` |
| `task list-attachment` | `attachments[].attachmentId` | `task remove-attachment` 的 `--attachment-id` |
| `comment list` | `result[].commentId` | `comment delete` 的 --comment-id |
| `task list-attachment` | `result[].attachmentId` | `task remove-attachment` 的 --attachment-id |
## 注意事项
- 优先级值: 10=低, 20=普通, 30=较高, 40=紧急
- `--due` 是截止时间 dueTime,不是提醒时间;使用 ISO-8601 格式(如 2026-03-10T18:00:00+08:00)
- `--due` 与 `todo +remind --at` 都是截止时间;不要把它们解释成独立提醒
- 当前不支持单独的 `reminder` / `remind-at` 精确提醒能力;不要把 `--due` 解释成“几点提醒”
- `--recurrence`:仅在与 `--due` 同时设置时有效;当前仅支持按天循环。字符串内需含换行,示例:`DTSTART:20260320T020000Z\nRRULE:FREQ=DAILY;INTERVAL=1`(DTSTART 表示首次截止时间,需与业务约定一致)
- 独立提醒使用 `todo +reminder --base-time customTime --at <ISO>`,或原子命令 `task add-reminder --base-time customTime --reminder-time-stamp <ISO>`
- 截止前提醒使用 `todo +reminder --base-time dueTime --due-date-offset <分钟>`,或对应原子命令;上游没有提醒查询接口,只能报告写回执
- 若用户的真实诉求是“到点提醒我”,需要先说明能力边界;当前 CLI 只能表达 deadline / recurrence,不能表达独立 reminder schedule
- `task list` 的 `--status` 对应 MCP `get_user_todos_in_current_org` 的 `todoStatus` 参数
- `task list` 的 `--priority` 支持逗号分隔多个优先级值(如 `40,30,10`),用于同时筛选多个优先级
- `task list` 的 `--role-types` 支持 `creator`/`executor`/`participant`,可在一次调用中同时传入多个角色用逗号分隔(如 `--role-types creator,executor`),无需分多次查询;不传时默认按 `executor` 查询
@@ -481,7 +486,7 @@ dws todo tag delete --tag-codes code1,code2 --format json
- todo 是个人待办管理产品
- `task update` 可同时修改标题/优先级/截止时间/完成状态
- `task done` 专用于修改执行者的完成状态,与 `task update --done` 作用不同
- `task delete` 为不可逆操作;按 Runtime gate 取得用户确认后执行
- `task delete` 为不可逆操作,建议加 `--yes` 并与用户确认
- `comment delete` 同样为不可逆操作,执行前需用户确认;`--comment-id` 可通过 `comment list` 获取
- `task add-executor` / `task remove-executor` 用于管理待办的执行人,`--executors` 支持逗号分隔的多个 userId
- `task add-participant` / `task remove-participant` 用于管理待办的参与人,`--participants` 支持逗号分隔的多个 userId
@@ -496,7 +501,7 @@ dws todo tag delete --tag-codes code1,code2 --format json
- `tag add` 用于给指定待办打标签,`--task-id` 可通过 `task list` 或 `task create` 获取;`--tag-codes` 可通过 `tag list` 获取
- `tag create` 用于创建新标签,`--name` 为标签名称 (必填)
- `tag update` 用于更新已有标签信息,`--user-tags` 格式同 `tag create`
- `tag delete` 用于删除标签定义,为不可逆操作;按 Runtime gate 取得用户确认后执行
- `tag delete` 用于删除标签定义,为不可逆操作,执行前需用户确认;传 `--yes` 可跳过交互提示,建议加 `--yes` 并与用户确认
- `tag add`(给待办打标签)与 `tag delete`(删除标签定义)作用不同:前者是关联关系,后者是删除标签本身
+6 -1
View File
@@ -71,7 +71,7 @@
[agoal.scorecard]
runnable: true
hidden: false
commands: detail, entity-detail, update
commands: detail, entity-detail, search-entities, update
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
[agoal.scorecard.detail]
@@ -89,6 +89,11 @@
hidden: false
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --content:string|required=false|hidden=false|no-opt=""|scope=local, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dept-id:string|required=false|hidden=false|no-opt=""|scope=local, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --id:string|required=false|hidden=false|no-opt=""|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --request-id:string|required=false|hidden=false|no-opt=""|scope=local, --selected-time:string|required=false|hidden=false|no-opt=""|scope=local, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, --tracking-period-type:string|required=false|hidden=false|no-opt=""|scope=local, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
[agoal.scorecard.search-entities]
runnable: true
hidden: false
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --keyword:string|required=false|hidden=false|no-opt=""|scope=local, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --page:int|required=false|hidden=false|no-opt=""|scope=local, --page-size:int|required=false|hidden=false|no-opt=""|scope=local, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --request-id:string|required=false|hidden=false|no-opt=""|scope=local, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
[agoal.strategy]
runnable: true
hidden: false
+349
View File
@@ -3,17 +3,22 @@ package scripts_test
import (
"archive/tar"
"archive/zip"
"bytes"
"compress/gzip"
"crypto/sha256"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"sort"
"strconv"
"strings"
"syscall"
"testing"
"time"
)
var expectedPackagedSkillTargets = []string{
@@ -93,6 +98,350 @@ func TestPackageManagerVersionVerificationReadsRawBinary(t *testing.T) {
}
}
func TestNPMWrapperForwardsSIGTERMToVendor(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX signal forwarding contract")
}
nodePath, err := exec.LookPath("node")
if err != nil {
t.Skip("node is required to exercise the npm wrapper")
}
root := t.TempDir()
binDir := filepath.Join(root, "bin")
vendorDir := filepath.Join(root, "vendor")
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(vendorDir, 0o755); err != nil {
t.Fatal(err)
}
wrapperSource := filepath.Join("..", "..", "build", "npm", "bin", "dws.js")
wrapperData, err := os.ReadFile(wrapperSource)
if err != nil {
t.Fatal(err)
}
wrapperPath := filepath.Join(binDir, "dws.js")
if err := os.WriteFile(wrapperPath, wrapperData, 0o755); err != nil {
t.Fatal(err)
}
pidPath := filepath.Join(root, "child.pid")
signalPath := filepath.Join(root, "child.signal")
vendorPath := filepath.Join(vendorDir, "dws")
vendorScript := "#!/bin/sh\n" +
"printf '%s' \"$$\" > \"$DWS_TEST_CHILD_PID_FILE\"\n" +
"trap 'printf TERM > \"$DWS_TEST_SIGNAL_FILE\"; exit 0' TERM\n" +
"while :; do sleep 0.1; done\n"
if err := os.WriteFile(vendorPath, []byte(vendorScript), 0o755); err != nil {
t.Fatal(err)
}
cmd := exec.Command(nodePath, wrapperPath)
cmd.Env = append(os.Environ(),
"DWS_TEST_CHILD_PID_FILE="+pidPath,
"DWS_TEST_SIGNAL_FILE="+signalPath,
)
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
childPID := waitForPIDFile(t, pidPath)
t.Cleanup(func() {
_ = exec.Command("kill", "-TERM", strconv.Itoa(childPID)).Run()
})
if err := cmd.Process.Signal(syscall.SIGTERM); err != nil {
t.Fatalf("signal npm wrapper: %v", err)
}
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
select {
case err := <-done:
if err == nil {
t.Fatal("signal-terminated npm wrapper returned success")
}
case <-time.After(5 * time.Second):
t.Fatal("npm wrapper did not exit after SIGTERM")
}
signalData, err := os.ReadFile(signalPath)
if err != nil {
t.Fatalf("vendor did not record SIGTERM: %v", err)
}
if got := strings.TrimSpace(string(signalData)); got != "TERM" {
t.Fatalf("vendor signal = %q, want TERM", got)
}
if err := exec.Command("kill", "-0", strconv.Itoa(childPID)).Run(); err == nil {
t.Fatalf("vendor process %d is still running after npm wrapper exited", childPID)
}
}
func TestNPMWrapperForwardsForegroundGroupSIGINTToVendorGroupOnce(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX foreground process-group signal contract")
}
nodePath, err := exec.LookPath("node")
if err != nil {
t.Skip("node is required to exercise the npm wrapper")
}
root := t.TempDir()
binDir := filepath.Join(root, "bin")
vendorDir := filepath.Join(root, "vendor")
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(vendorDir, 0o755); err != nil {
t.Fatal(err)
}
wrapperData, err := os.ReadFile(filepath.Join("..", "..", "build", "npm", "bin", "dws.js"))
if err != nil {
t.Fatal(err)
}
wrapperPath := filepath.Join(binDir, "dws.js")
if err := os.WriteFile(wrapperPath, wrapperData, 0o755); err != nil {
t.Fatal(err)
}
pidPath := filepath.Join(root, "child.pid")
signalPath := filepath.Join(root, "child.signal")
descendantPIDPath := filepath.Join(root, "descendant.pid")
descendantSignalPath := filepath.Join(root, "descendant.signal")
vendorPath := filepath.Join(vendorDir, "dws")
vendorScript := `#!/usr/bin/env node
"use strict";
const fs = require("fs");
const childProcess = require("child_process");
const descendantSource = String.raw` + "`" + `
"use strict";
const fs = require("fs");
let exiting = false;
process.on("SIGINT", () => {
fs.appendFileSync(process.env.DWS_TEST_DESCENDANT_SIGNAL_FILE, "INT\n");
if (!exiting) {
exiting = true;
setTimeout(() => process.exit(0), 250);
}
});
fs.writeFileSync(process.env.DWS_TEST_DESCENDANT_PID_FILE, String(process.pid));
setInterval(() => {}, 1000);
` + "`" + `;
childProcess.spawn(process.execPath, ["-e", descendantSource], {
stdio: "inherit",
env: process.env,
});
let exiting = false;
process.on("SIGINT", () => {
fs.appendFileSync(process.env.DWS_TEST_SIGNAL_FILE, "INT\n");
if (!exiting) {
exiting = true;
setTimeout(() => process.exit(0), 250);
}
});
fs.writeFileSync(process.env.DWS_TEST_CHILD_PID_FILE, String(process.pid));
setInterval(() => {}, 1000);
`
if err := os.WriteFile(vendorPath, []byte(vendorScript), 0o755); err != nil {
t.Fatal(err)
}
cmd := exec.Command(nodePath, wrapperPath)
cmd.Env = append(os.Environ(),
"DWS_TEST_CHILD_PID_FILE="+pidPath,
"DWS_TEST_SIGNAL_FILE="+signalPath,
"DWS_TEST_DESCENDANT_PID_FILE="+descendantPIDPath,
"DWS_TEST_DESCENDANT_SIGNAL_FILE="+descendantSignalPath,
)
configureIsolatedProcessGroup(cmd)
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
childPID := waitForPIDFile(t, pidPath)
descendantPID := waitForPIDFile(t, descendantPIDPath)
t.Cleanup(func() {
_ = exec.Command("kill", "-TERM", strconv.Itoa(childPID)).Run()
_ = exec.Command("kill", "-TERM", strconv.Itoa(descendantPID)).Run()
})
wrapperGroup, err := processGroupID(cmd.Process.Pid)
if err != nil {
t.Fatalf("wrapper process group: %v", err)
}
childGroup, err := processGroupID(childPID)
if err != nil {
t.Fatalf("vendor process group: %v", err)
}
if childGroup == wrapperGroup {
t.Fatalf("vendor process group = wrapper process group %d; foreground signal would be duplicated", childGroup)
}
descendantGroup, err := processGroupID(descendantPID)
if err != nil {
t.Fatalf("descendant process group: %v", err)
}
if descendantGroup != childGroup {
t.Fatalf("descendant process group = %d, want vendor process group %d", descendantGroup, childGroup)
}
if err := signalProcessGroup(wrapperGroup, syscall.SIGINT); err != nil {
t.Fatalf("signal foreground process group: %v", err)
}
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
select {
case err := <-done:
if err == nil {
t.Fatal("signal-terminated npm wrapper returned success")
}
case <-time.After(5 * time.Second):
t.Fatal("npm wrapper did not exit after foreground SIGINT")
}
signalData, err := os.ReadFile(signalPath)
if err != nil {
t.Fatalf("vendor did not record SIGINT: %v", err)
}
if got := strings.Fields(string(signalData)); len(got) != 1 || got[0] != "INT" {
t.Fatalf("vendor signals = %q, want exactly one INT", got)
}
descendantSignalData, err := os.ReadFile(descendantSignalPath)
if err != nil {
t.Fatalf("vendor descendant did not record SIGINT: %v", err)
}
if got := strings.Fields(string(descendantSignalData)); len(got) != 1 || got[0] != "INT" {
t.Fatalf("vendor descendant signals = %q, want exactly one INT", got)
}
waitForProcessExit(t, childPID, "vendor")
waitForProcessExit(t, descendantPID, "vendor descendant")
}
func TestNPMWrapperPreservesInteractiveTTY(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX controlling-terminal contract")
}
nodePath, err := exec.LookPath("node")
if err != nil {
t.Skip("node is required to exercise the npm wrapper")
}
root := t.TempDir()
binDir := filepath.Join(root, "bin")
vendorDir := filepath.Join(root, "vendor")
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(vendorDir, 0o755); err != nil {
t.Fatal(err)
}
wrapperData, err := os.ReadFile(filepath.Join("..", "..", "build", "npm", "bin", "dws.js"))
if err != nil {
t.Fatal(err)
}
wrapperPath := filepath.Join(binDir, "dws.js")
if err := os.WriteFile(wrapperPath, wrapperData, 0o755); err != nil {
t.Fatal(err)
}
resultPath := filepath.Join(root, "interactive.result")
readyPath := filepath.Join(root, "interactive.ready")
vendorPath := filepath.Join(vendorDir, "dws")
vendorScript := "#!/bin/sh\n" +
"printf 'DWS prompt: ' > /dev/tty\n" +
"printf ready > \"$DWS_TEST_INTERACTIVE_READY\"\n" +
"IFS= read -r answer < /dev/tty\n" +
"printf '%s' \"$answer\" > \"$DWS_TEST_INTERACTIVE_RESULT\"\n"
if err := os.WriteFile(vendorPath, []byte(vendorScript), 0o755); err != nil {
t.Fatal(err)
}
cmd := exec.Command(nodePath, wrapperPath)
cmd.Env = append(os.Environ(),
"DWS_TEST_INTERACTIVE_RESULT="+resultPath,
"DWS_TEST_INTERACTIVE_READY="+readyPath,
)
terminal, err := startWithPTY(cmd)
if err != nil {
t.Fatalf("start npm wrapper with PTY: %v", err)
}
defer terminal.Close()
t.Cleanup(func() { _ = cmd.Process.Kill() })
var output bytes.Buffer
outputDone := make(chan struct{})
go func() {
_, _ = io.Copy(&output, terminal)
close(outputDone)
}()
waitForFile(t, readyPath)
if _, err := fmt.Fprintln(terminal, "confirmed"); err != nil {
t.Fatalf("write interactive input: %v", err)
}
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
var waitErr error
select {
case waitErr = <-done:
case <-time.After(5 * time.Second):
t.Fatal("interactive npm wrapper did not exit after terminal input")
}
_ = terminal.Close()
<-outputDone
if waitErr != nil {
t.Fatalf("interactive npm wrapper: %v\noutput:\n%s", waitErr, output.String())
}
result, err := os.ReadFile(resultPath)
if err != nil {
t.Fatalf("interactive vendor did not record terminal input: %v\noutput:\n%s", err, output.String())
}
if got := strings.TrimSpace(string(result)); got != "confirmed" {
t.Fatalf("interactive input = %q, want confirmed\noutput:\n%s", got, output.String())
}
}
func waitForPIDFile(t *testing.T, path string) int {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
data, err := os.ReadFile(path)
if err == nil {
pid, parseErr := strconv.Atoi(strings.TrimSpace(string(data)))
if parseErr == nil && pid > 0 {
return pid
}
}
time.Sleep(25 * time.Millisecond)
}
t.Fatalf("timed out waiting for vendor pid file %s", path)
return 0
}
func waitForFile(t *testing.T, path string) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if _, err := os.Stat(path); err == nil {
return
}
time.Sleep(25 * time.Millisecond)
}
t.Fatalf("timed out waiting for file %s", path)
}
func waitForProcessExit(t *testing.T, pid int, label string) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if err := exec.Command("kill", "-0", strconv.Itoa(pid)).Run(); err != nil {
return
}
time.Sleep(25 * time.Millisecond)
}
t.Fatalf("%s process %d is still running after npm wrapper exited", label, pid)
}
func TestPackageManagerVerifierCoversSpecificAndFallbackSkillRoots(t *testing.T) {
t.Parallel()
+27
View File
@@ -0,0 +1,27 @@
//go:build !windows
package scripts_test
import (
"os"
"os/exec"
"syscall"
"github.com/creack/pty"
)
func configureIsolatedProcessGroup(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
}
func processGroupID(pid int) (int, error) {
return syscall.Getpgid(pid)
}
func signalProcessGroup(groupID int, signal syscall.Signal) error {
return syscall.Kill(-groupID, signal)
}
func startWithPTY(cmd *exec.Cmd) (*os.File, error) {
return pty.Start(cmd)
}
@@ -0,0 +1,24 @@
//go:build windows
package scripts_test
import (
"errors"
"os"
"os/exec"
"syscall"
)
func configureIsolatedProcessGroup(*exec.Cmd) {}
func processGroupID(int) (int, error) {
return 0, errors.New("POSIX process groups are unavailable on Windows")
}
func signalProcessGroup(int, syscall.Signal) error {
return errors.New("POSIX process groups are unavailable on Windows")
}
func startWithPTY(*exec.Cmd) (*os.File, error) {
return nil, errors.New("POSIX pseudo terminals are unavailable on Windows")
}