Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
758b0f875e | ||
|
|
19be571574 | ||
|
|
b5a287ae71 | ||
|
|
da6f867dfa | ||
|
|
82dc2b5e5e | ||
|
|
9265fd4cb8 | ||
|
|
e324ef9d4a | ||
|
|
fb1847d62e | ||
|
|
331681e82b | ||
|
|
1633888290 | ||
|
|
206f33ae1c | ||
|
|
9259477372 | ||
|
|
081220f15e | ||
|
|
f871689960 | ||
|
|
b15a21de93 | ||
|
|
546c2d2eb2 | ||
|
|
5bd0ea7c53 | ||
|
|
4833b39071 | ||
|
|
8a4e49dbf2 | ||
|
|
7c924c54ca | ||
|
|
59d0b6dd75 | ||
|
|
74f7bbc980 | ||
|
|
2b7d5a2c5f | ||
|
|
c7510cd1a1 | ||
|
|
1c3477c087 | ||
|
|
93d450a9bf | ||
|
|
cf39768095 | ||
|
|
c096258b0f | ||
|
|
5ba8ac6775 | ||
|
|
66fee5ef5f | ||
|
|
d9b9c5c7da | ||
|
|
684411e54e | ||
|
|
de5ba029d4 | ||
|
|
15f139e32d | ||
|
|
768c1ce494 | ||
|
|
966fd60e2f | ||
|
|
7825c3c7e0 | ||
|
|
10d44615d1 | ||
|
|
1e88612e43 | ||
|
|
5934ccac7f | ||
|
|
d9365f3fff | ||
|
|
15d93698bd | ||
|
|
7e69a3d6fe | ||
|
|
94b4958038 | ||
|
|
97a99ba04f | ||
|
|
5e1e5cbb84 | ||
|
|
e04e886c50 | ||
|
|
1acde9b766 | ||
|
|
852efe56aa | ||
|
|
6c287bcb4d | ||
|
|
df24d53886 | ||
|
|
49cecabb12 | ||
|
|
09993ad82c | ||
|
|
0efaf6c82f | ||
|
|
26002637f4 | ||
|
|
d552c59d11 |
@@ -0,0 +1,24 @@
|
||||
---
|
||||
category: Changed
|
||||
---
|
||||
|
||||
- **Permission error guidance and error rendering** (#1085) —
|
||||
permission-denied responses now exit with the `AUTH_PERMISSION_DENIED` code
|
||||
instead of a generic business-error rendering; document/wiki-specific errors
|
||||
(the drive-specific codes `forbidden.accessDenied` / `forbidden.no.auth`,
|
||||
or the role-threshold wording like
|
||||
“需要您具备 MANAGER 及以上角色”) carry apply-permission guidance
|
||||
(`dws drive permission apply-info` / `dws drive permission apply`), while
|
||||
permission failures carrying only generic code names (`FORBIDDEN`,
|
||||
`NO_PERMISSION` — also returned by attendance and event-subscription tools)
|
||||
or other products' wording keep their product-specific or
|
||||
product-neutral suggestion instead of a misleading document-permission hint;
|
||||
member-validation failures such as
|
||||
“用户不存在/不属于当前组织” are classified as tool errors with a
|
||||
`--members`-with-`corpId` suggestion instead of a misleading
|
||||
resource-not-found error; business error output now surfaces the backend
|
||||
message with `code`/`logId` appended for traceability; and the
|
||||
`update_permission` / `remove_permission` / `update_member` /
|
||||
`remove_member` tools — whose servers return a literal `null` on successful
|
||||
no-payload writes — now render `{}` so downstream JSON consumers do not fail
|
||||
parsing `null`; other tools keep raw `null` output unchanged.
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Permission and member list pagination** (#1085) — `drive/doc permission
|
||||
list` and `wiki member list` now accept `--next-token` to follow the
|
||||
server-side cursor (output carries `totalCount`/`hasMore`/`nextToken`) and
|
||||
map `--limit` to `pageSize` capped at 50 instead of the rejected `maxResults
|
||||
200` path; `permission add/update/remove` and `wiki member add/update/remove`
|
||||
additionally accept a `--members` JSON array covering USER/DEPT/CONVERSATION/TAG
|
||||
grantee types. The optional `--notify` defaults to `false` and is omitted from
|
||||
the server request unless passed explicitly, so member grants no longer notify
|
||||
recipients by default. These commands also declare cursor pagination
|
||||
(`next-token`) in the Agent schema contract, mirroring the internal CLI parity
|
||||
change. Because a single batch remove can revoke access for up to 30
|
||||
USER/DEPT/CONVERSATION/TAG members — where departments, chats, and role
|
||||
groups can indirectly affect many more users — `drive/doc permission
|
||||
remove` and `wiki member remove` now declare
|
||||
`confirmation=user_required` and gate the actual tool call behind user
|
||||
confirmation (`--yes`, an interactive yes, or `--dry-run` preview); their
|
||||
confirmation-gate failure now also passes through verbatim instead of being
|
||||
reclassified as a permission-denied or unclassified error.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **AITable datasource shortcuts** — adds 7 shortcuts for datasource sync management (`+datasource-create`, `+datasource-update`, `+datasource-sync`, `+datasource-sync-status`, `+datasource-get-config`, `+datasource-list-sources`, `+datasource-get-fields`) and updates the `dingtalk-aitable` skill with routing rules and a new `aitable-datasource.md` reference guide.
|
||||
@@ -544,7 +544,8 @@ jobs:
|
||||
- app-schema
|
||||
- app-a-b
|
||||
- app-c-a-l
|
||||
- app-c-m-r
|
||||
- app-c-m-o
|
||||
- app-c-p-r
|
||||
- app-c-s-z
|
||||
- app-c-other
|
||||
- app-d-r
|
||||
@@ -692,7 +693,8 @@ jobs:
|
||||
- app-schema
|
||||
- app-a-b
|
||||
- app-c-a-l
|
||||
- app-c-m-r
|
||||
- app-c-m-o
|
||||
- app-c-p-r
|
||||
- app-c-s-z
|
||||
- app-c-other
|
||||
- app-d-r
|
||||
|
||||
@@ -10,7 +10,7 @@ SCHEMA_META_INDEX_OUTPUT ?= artifacts/schema_meta_index.gob
|
||||
POLICY_ENV = DWS_POLICY_TMPDIR="$(DWS_POLICY_TMPDIR)" GOTMPDIR="$(POLICY_GOTMPDIR)"
|
||||
GO_SOURCE_LIST = git ls-files -z --cached --others --exclude-standard -- '*.go'
|
||||
|
||||
.PHONY: all help build rebuild test test-plan test-auth-legacy-compat lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity skill-context-budget multi-im-skill-chain-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema fetch-mcp-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks
|
||||
.PHONY: all help build rebuild test test-plan test-auth-legacy-compat shortcut-public-e2e-proof lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity skill-context-budget multi-im-skill-chain-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema fetch-mcp-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks
|
||||
|
||||
all: setup-hooks fmt lint build test rebuild
|
||||
|
||||
@@ -20,6 +20,7 @@ help:
|
||||
@printf " make test - Run the Go test suite\n"
|
||||
@printf " make test-plan - Verify CI test and full-suite coverage package plans cover their scopes exactly once\n"
|
||||
@printf " make test-auth-legacy-compat - Run stable legacy authentication compatibility regressions\n"
|
||||
@printf " make shortcut-public-e2e-proof - Prove every reviewed Devdoc/HRbrain/PAT public Shortcut through exact and owning raw execution\n"
|
||||
@printf " make lint - Run formatting checks, go vet, and staticcheck\n"
|
||||
@printf " make format-check - Check all repository Go source files with gofmt\n"
|
||||
@printf " make fmt - Format all repository Go source files\n"
|
||||
@@ -62,6 +63,9 @@ test-auth-legacy-compat:
|
||||
@mkdir -p "$(POLICY_GOTMPDIR)"
|
||||
@GO="$(GO)" $(POLICY_ENV) ./scripts/policy/check-auth-legacy-compat.sh
|
||||
|
||||
shortcut-public-e2e-proof: build
|
||||
@GO="$(GO)" DWS_PACKAGE_VERSION="$(DWS_PACKAGE_VERSION)" ./scripts/policy/check-shortcut-public-e2e-proof.sh
|
||||
|
||||
lint:
|
||||
@./scripts/dev/lint.sh
|
||||
|
||||
@@ -84,7 +88,7 @@ fmt:
|
||||
$(GO_SOURCE_LIST) > "$$go_files"; \
|
||||
xargs -0 sh -c 'if [ "$$#" -gt 0 ]; then exec gofmt -w -- "$$@"; fi' sh < "$$go_files"
|
||||
|
||||
policy: test-auth-legacy-compat
|
||||
policy: test-auth-legacy-compat shortcut-public-e2e-proof
|
||||
@mkdir -p "$(POLICY_GOTMPDIR)"
|
||||
@$(POLICY_ENV) ./scripts/policy/check-open-source-assets.sh
|
||||
@$(POLICY_ENV) ./scripts/policy/check-skill-context-budget.sh
|
||||
|
||||
@@ -0,0 +1,454 @@
|
||||
# AI 表格数据源指令使用指南
|
||||
|
||||
## 概述
|
||||
|
||||
dws 新增了 7 个 AI 表格数据源同步管理指令,用于将外部数据源(一期支持审批数据)接入 AI 表格,实现数据的自动同步。
|
||||
|
||||
所有指令均通过 `dws aitable +datasource-*` 前缀调用,操作对象是 AI 表格中的"数据源表"——一种由数据源同步创建的特殊数据表。
|
||||
|
||||
## 指令速览
|
||||
|
||||
| 指令 | 用途 | 读写 | 风险 |
|
||||
|------|------|------|------|
|
||||
| `+datasource-list-sources` | 列出数据源类型可用的来源信息(OA 返回 result/processCode、sourceType、sourceUrl) | 读 | low |
|
||||
| `+datasource-get-fields` | 获取数据源来源的可同步字段结构 | 读 | low |
|
||||
| `+datasource-create` | 创建数据源表并触发首次同步 | 写 | medium |
|
||||
| `+datasource-update` | 更新已有数据源表的同步配置 | 写 | medium |
|
||||
| `+datasource-sync` | 手动触发一次同步 | 写 | medium |
|
||||
| `+datasource-sync-status` | 查询同步任务状态 | 读 | low |
|
||||
| `+datasource-get-config` | 查看数据源表配置 | 读 | low |
|
||||
|
||||
## 前置条件
|
||||
|
||||
1. **登录认证**:执行 `dws auth login` 确保已登录
|
||||
2. **获取 Base ID**:通过 `dws aitable +base-list` 或 `dws aitable +base-search --query "关键词"` 获取目标 AI 表格的 Base ID
|
||||
|
||||
---
|
||||
|
||||
## 1. 列出数据源可用来源
|
||||
|
||||
```
|
||||
dws aitable +datasource-list-sources [flags]
|
||||
```
|
||||
|
||||
列出指定数据源类型可用的来源信息。OA 审批类型返回当前 Base 可用的审批数据源条目(`sources` 数组,当前通常为单条),用于构造 `+datasource-create` / `+datasource-update` / `+datasource-get-fields` 的 `--source-config`。OA 场景下每条 source 的 `result` 字段是 JSON 字符串,需解析后得到 `approvals` 数组,再从中提取目标模板的 `processCode`、`name`、`iconUrl`、`url`。
|
||||
|
||||
### 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `--base-id` | string | 是 | 目标 Base ID |
|
||||
| `--datasource-type` | string | 是 | 数据源类型,目前支持审批(OA) |
|
||||
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
# 列出审批数据源来源,获取 result(JSON,解析后得到 approvals[].processCode)
|
||||
dws aitable +datasource-list-sources \
|
||||
--base-id BASE123 \
|
||||
--datasource-type OA
|
||||
```
|
||||
|
||||
### 返回值
|
||||
|
||||
返回 `sources` 数组,每个条目包含:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `result` | OA 审批场景为 JSON 字符串,解析后得到 `approvals` 数组;每个 approval 含 `processCode`、`name`、`iconUrl`、`url` |
|
||||
| `sourceType` | 数据源类型编号(OA 对应内部枚举值 2) |
|
||||
| `sourceUrl` | 数据源访问链接,可选 |
|
||||
|
||||
`result` 本身不是 `processCode`,需要解析出 `approvals` 数组,再取目标模板的 `processCode`、`name`、`iconUrl`、`url` 原样填入 `--source-config`。
|
||||
|
||||
---
|
||||
|
||||
## 2. 获取数据源可同步字段
|
||||
|
||||
```
|
||||
dws aitable +datasource-get-fields [flags]
|
||||
```
|
||||
|
||||
获取指定数据源来源(如某个审批模板)的可同步字段列表,包括字段 ID、字段名称、字段类型和是否主键等信息。用于创建数据源前选择需要同步的字段。
|
||||
|
||||
### 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `--base-id` | string | 是 | 目标 Base ID |
|
||||
| `--datasource-type` | string | 是 | 数据源类型,目前支持审批(OA) |
|
||||
| `--source-config` | string | 是 | 源配置 JSON 字符串,结构同 `+datasource-create` 的 `--source-config` |
|
||||
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
# 获取某审批模板的可同步字段
|
||||
dws aitable +datasource-get-fields \
|
||||
--base-id BASE123 \
|
||||
--datasource-type OA \
|
||||
--source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'
|
||||
```
|
||||
|
||||
### 返回值
|
||||
|
||||
返回可同步字段列表,每个字段包含字段 ID、名称、类型和是否主键。字段 ID 可用于 `+datasource-create` / `+datasource-update` 的 `--field-ids` 参数。
|
||||
|
||||
---
|
||||
|
||||
## 3. 创建数据源表
|
||||
|
||||
```
|
||||
dws aitable +datasource-create [flags]
|
||||
```
|
||||
|
||||
为指定 AI 表格创建数据源同步配置,自动创建一张数据源表并触发首次全量同步。
|
||||
|
||||
### 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `--base-id` | string | 是 | 目标 Base ID(通过 `+base-list` / `+base-search` 获取) |
|
||||
| `--datasource-type` | string | 是 | 数据源类型,目前支持审批(OA) |
|
||||
| `--source-config` | string | 是 | 源配置 JSON 字符串(格式见下方) |
|
||||
| `--auto` | bool | 否 | 是否开启自动同步,默认 false;无论是否传入,CLI 都会把该字段下发给下游 |
|
||||
| `--auto-sync-setting` | string | 否 | 自动同步频率配置 JSON 字符串,仅在 `--auto=true` 时生效,格式见下方 |
|
||||
| `--field-ids` | stringSlice | 否 | 需要同步的字段 ID 列表,不传时同步全部字段 |
|
||||
|
||||
### source-config 格式(审批类)
|
||||
|
||||
审批数据源的 `--source-config` 是一个 JSON 对象字符串,包含以下字段:
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `processCode` | string | 是 | 审批模板编码,对应 `+datasource-list-sources` 返回的 `result` |
|
||||
| `name` | string | 是 | 数据源展示名称,须从 `+datasource-list-sources` 结果原样透传 |
|
||||
| `iconUrl` | string | 是 | OA 审批图标 URL,须从 `+datasource-list-sources` 结果原样透传 |
|
||||
| `url` | string | 是 | OA 审批跳转链接,须从 `+datasource-list-sources` 结果原样透传 |
|
||||
| `dataType` | string | 是 | 数据时间范围类型:`time_range` / `start_time` / `recent_time` |
|
||||
| `recentDays` | string | 当 dataType=recent_time 时必填 | 近 N 天:`7d` / `30d` / `1y` |
|
||||
| `startDate` | string | 当 dataType=time_range 或 start_time 时必填 | 起始日期,格式 `yyyy-MM-dd` |
|
||||
| `endDate` | string | 当 dataType=time_range 时必填 | 结束日期,格式 `yyyy-MM-dd` |
|
||||
| `keepRemovedFields` | bool | 否 | 是否保留已删除字段,默认 false |
|
||||
|
||||
> 注:`splitParentTableField`、`enableDataSyncOaDetailList` 等字段为下游内部字段,无需传入,下游自动处理。
|
||||
|
||||
按 `dataType` 选择对应的时间参数组合:
|
||||
|
||||
| dataType | 需要的时间字段 | 说明 |
|
||||
|----------|----------------|------|
|
||||
| `recent_time` | `recentDays` | 同步近 N 天数据(7d/30d/1y) |
|
||||
| `start_time` | `startDate` | 同步从某日期至今的数据 |
|
||||
| `time_range` | `startDate` + `endDate` | 同步指定日期范围内的数据 |
|
||||
|
||||
### auto-sync-setting 格式
|
||||
|
||||
`--auto-sync-setting` 仅在 `--auto=true` 时生效,用于指定自动同步频率。不传时使用下游默认策略。
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `syncType` | string | 是 | `hourly`(按小时间隔)/ `scheduled`(定时触发) |
|
||||
| `hourlyInterval` | int | hourly 时必填 | 正整数,小时间隔 |
|
||||
| `scheduleType` | string | scheduled 时必填 | `daily` / `weekly` / `monthly` |
|
||||
| `timeValue` | string | scheduled 时必填 | 触发时间,格式 `HH:mm` |
|
||||
| `selectedMonthDays` | int[] | monthly 时必填 | 每月几号触发,1-31 |
|
||||
| `selectedWeekdays` | int[] | weekly 时必填 | 每周哪几天触发,1=周一…7=周日 |
|
||||
| `skipNonWorkingDay` | bool | 否 | 是否跳过非工作日,默认 false |
|
||||
|
||||
示例:`{"syncType":"scheduled","scheduleType":"daily","timeValue":"09:00"}`
|
||||
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
# 基本创建——同步近 30 天审批数据
|
||||
dws aitable +datasource-create \
|
||||
--base-id BASE123 \
|
||||
--datasource-type OA \
|
||||
--source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'
|
||||
|
||||
# 指定日期范围创建并开启自动同步
|
||||
dws aitable +datasource-create \
|
||||
--base-id BASE123 \
|
||||
--datasource-type OA \
|
||||
--source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"time_range","startDate":"2025-01-01","endDate":"2025-12-31","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}' \
|
||||
--auto
|
||||
|
||||
# 指定同步字段(仅同步部分字段,field-ids 可通过 +datasource-get-fields 获取)
|
||||
dws aitable +datasource-create \
|
||||
--base-id BASE123 \
|
||||
--datasource-type OA \
|
||||
--source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}' \
|
||||
--field-ids fldAAA,fldBBB,fldCCC
|
||||
```
|
||||
|
||||
### 返回值
|
||||
|
||||
创建成功后返回新建数据源表 ID 和同步任务 ID,后续操作需要用到这两个 ID。
|
||||
|
||||
---
|
||||
|
||||
## 4. 更新数据源配置
|
||||
|
||||
```
|
||||
dws aitable +datasource-update [flags]
|
||||
```
|
||||
|
||||
更新已有数据源表的同步配置,支持更新源配置、自动同步开关和同步字段选择。更新后会自动触发一次同步。
|
||||
|
||||
### 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `--base-id` | string | 是 | 目标 Base ID |
|
||||
| `--table-id` | string | 是 | 已存在的数据源表 ID(由 `+datasource-create` 返回) |
|
||||
| `--source-config` | string | 否 | 新的源配置 JSON 字符串,不传时保持原有配置。结构同 `+datasource-create` |
|
||||
| `--auto` | bool | 否 | 是否开启自动同步;仅显式设置时下发给下游,省略时保持原有自动同步开关不变 |
|
||||
| `--auto-sync-setting` | string | 否 | 自动同步频率配置 JSON 字符串,仅在显式设置 `--auto=true` 时生效;省略时保持原频率配置 |
|
||||
| `--field-ids` | stringSlice | 否 | 需要同步的字段 ID 列表,不传时保持现有字段配置 |
|
||||
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
# 更换审批模板并调整时间范围
|
||||
dws aitable +datasource-update \
|
||||
--base-id BASE123 \
|
||||
--table-id TBL456 \
|
||||
--source-config '{"processCode":"PROC-YYYY","name":"出差申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'
|
||||
|
||||
# 开启自动同步
|
||||
dws aitable +datasource-update \
|
||||
--base-id BASE123 \
|
||||
--table-id TBL456 \
|
||||
--auto
|
||||
|
||||
# 更新同步字段范围
|
||||
dws aitable +datasource-update \
|
||||
--base-id BASE123 \
|
||||
--table-id TBL456 \
|
||||
--field-ids fldAAA,fldDDD
|
||||
```
|
||||
|
||||
> 注意:`--table-id` 指向的是数据源表(由 `+datasource-create` 创建),不是普通数据表。
|
||||
|
||||
---
|
||||
|
||||
## 5. 触发手动同步
|
||||
|
||||
```
|
||||
dws aitable +datasource-sync [flags]
|
||||
```
|
||||
|
||||
对已有数据源表触发一次手动同步。单次最多 5 张表,每张表独立提交,部分失败不影响其他表。
|
||||
|
||||
### 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `--base-id` | string | 是 | 目标 Base ID |
|
||||
| `--table-ids` | stringSlice | 是 | 待触发同步的数据源表 ID 列表(1-5 个) |
|
||||
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
# 同步单张表
|
||||
dws aitable +datasource-sync \
|
||||
--base-id BASE123 \
|
||||
--table-ids TBL1
|
||||
|
||||
# 批量同步多张表(逗号分隔,最多 5 个)
|
||||
dws aitable +datasource-sync \
|
||||
--base-id BASE123 \
|
||||
--table-ids TBL1,TBL2,TBL3
|
||||
```
|
||||
|
||||
### 返回值
|
||||
|
||||
返回每个表的同步任务 ID,可通过 `+datasource-sync-status` 查询最终结果。
|
||||
|
||||
---
|
||||
|
||||
## 6. 查询同步状态
|
||||
|
||||
```
|
||||
dws aitable +datasource-sync-status [flags]
|
||||
```
|
||||
|
||||
按任务 ID 查询数据源表的同步任务状态。与 `+datasource-sync` / `+datasource-create` / `+datasource-update` 配对使用——这些指令触发同步后返回任务 ID,本指令通过任务 ID 查询最终结果。
|
||||
|
||||
### 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `--base-id` | string | 是 | 目标 Base ID |
|
||||
| `--table-id` | string | 是 | 数据源表 ID |
|
||||
| `--task-ids` | stringSlice | 是 | 待查询的同步任务 ID 列表(1-5 个) |
|
||||
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
# 按任务 ID 查询(批量,最多 5 个)
|
||||
dws aitable +datasource-sync-status \
|
||||
--base-id BASE123 \
|
||||
--table-id TBL456 \
|
||||
--task-ids TASK1,TASK2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 获取数据源配置
|
||||
|
||||
```
|
||||
dws aitable +datasource-get-config [flags]
|
||||
```
|
||||
|
||||
获取指定数据源表的同步配置信息,包括源配置、同步模式、自动同步开关和同步状态。
|
||||
|
||||
### 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `--base-id` | string | 是 | 目标 Base ID |
|
||||
| `--table-id` | string | 是 | 数据源表 ID |
|
||||
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
dws aitable +datasource-get-config \
|
||||
--base-id BASE123 \
|
||||
--table-id TBL456
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 典型工作流
|
||||
|
||||
### 场景一:从零接入审批数据
|
||||
|
||||
```bash
|
||||
# 0. 获取 Base ID
|
||||
dws aitable +base-search --query "我的项目表"
|
||||
|
||||
# 1. 列出可用审批数据源来源,解析 result JSON 获取 approvals[].processCode
|
||||
dws aitable +datasource-list-sources \
|
||||
--base-id BASE123 \
|
||||
--datasource-type OA
|
||||
# → 返回 sources[0].result 为 JSON 字符串,解析后取 approvals[0].processCode=PROC-XXXX
|
||||
|
||||
# 2. 查看可同步字段(可选,用于指定 field-ids)
|
||||
dws aitable +datasource-get-fields \
|
||||
--base-id BASE123 \
|
||||
--datasource-type OA \
|
||||
--source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'
|
||||
|
||||
# 3. 创建数据源表(创建后自动触发首次同步)
|
||||
dws aitable +datasource-create \
|
||||
--base-id BASE123 \
|
||||
--datasource-type OA \
|
||||
--source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'
|
||||
# → 返回 tableId=TBL456, taskId=TASK001
|
||||
|
||||
# 4. 查询首次同步是否完成
|
||||
dws aitable +datasource-sync-status \
|
||||
--base-id BASE123 \
|
||||
--table-id TBL456 \
|
||||
--task-ids TASK001
|
||||
|
||||
# 5. 确认配置
|
||||
dws aitable +datasource-get-config \
|
||||
--base-id BASE123 \
|
||||
--table-id TBL456
|
||||
```
|
||||
|
||||
### 场景二:更换审批模板后重新同步
|
||||
|
||||
```bash
|
||||
# 1. 更新源配置(更新后自动触发一次同步)
|
||||
dws aitable +datasource-update \
|
||||
--base-id BASE123 \
|
||||
--table-id TBL456 \
|
||||
--source-config '{"processCode":"PROC-NEW","name":"新审批模板","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'
|
||||
|
||||
# 2. 查询同步状态(更新后会返回新的 taskId)
|
||||
dws aitable +datasource-sync-status \
|
||||
--base-id BASE123 \
|
||||
--table-id TBL456 \
|
||||
--task-ids TASK002
|
||||
```
|
||||
|
||||
### 场景三:手动触发日常同步
|
||||
|
||||
```bash
|
||||
# 仅触发同步,不修改配置
|
||||
dws aitable +datasource-sync \
|
||||
--base-id BASE123 \
|
||||
--table-ids TBL456
|
||||
|
||||
# 查询结果(sync 会返回 taskId)
|
||||
dws aitable +datasource-sync-status \
|
||||
--base-id BASE123 \
|
||||
--table-id TBL456 \
|
||||
--task-ids TASK001
|
||||
```
|
||||
|
||||
### 场景四:开启自动同步后确认
|
||||
|
||||
```bash
|
||||
# 1. 更新配置,开启自动同步
|
||||
dws aitable +datasource-update \
|
||||
--base-id BASE123 \
|
||||
--table-id TBL456 \
|
||||
--auto
|
||||
|
||||
# 2. 确认配置已更新
|
||||
dws aitable +datasource-get-config \
|
||||
--base-id BASE123 \
|
||||
--table-id TBL456
|
||||
# → 返回中应显示 auto=true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 通用选项
|
||||
|
||||
以下全局选项可在所有指令中使用:
|
||||
|
||||
| 选项 | 说明 |
|
||||
|------|------|
|
||||
| `-f, --format` | 输出格式:json(默认)/ table / raw / pretty / ndjson / csv |
|
||||
| `--jq` | jq 表达式过滤输出(如 `.tableId` 或 `.status`) |
|
||||
| `--fields` | 筛选输出字段(逗号分隔) |
|
||||
| `--dry-run` | 预览操作内容,不实际执行 |
|
||||
| `--profile` | 指定组织或账号 |
|
||||
| `--timeout` | HTTP 请求超时时间(秒,默认 30) |
|
||||
| `--debug` | 显示调试日志 |
|
||||
| `-v, --verbose` | 显示详细日志 |
|
||||
|
||||
### 输出过滤示例
|
||||
|
||||
```bash
|
||||
# 只取 tableId
|
||||
dws aitable +datasource-create ... --jq '.tableId'
|
||||
|
||||
# 只取同步状态
|
||||
dws aitable +datasource-sync-status ... --jq '.status'
|
||||
|
||||
# table 格式查看
|
||||
dws aitable +datasource-get-config ... -f table
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **推荐流程**:先 `+datasource-list-sources` 解析 `result` JSON 获取 `approvals[].processCode`,再 `+datasource-get-fields` 查看可同步字段,最后 `+datasource-create` 创建数据源表。
|
||||
|
||||
2. **数据源表 vs 普通数据表**:`+datasource-create` 创建的是"数据源表",它由数据源同步驱动数据写入。`+datasource-update` 和 `+datasource-sync` 仅适用于数据源表,不可对普通数据表使用。
|
||||
|
||||
3. **datasource-type 透传**:CLI 层不对 `--datasource-type` 做枚举校验,目前一期仅支持 `OA`(审批)。后续支持其他类型时由服务端控制,CLI 无需修改。
|
||||
|
||||
4. **source-config 格式**:`--source-config` 必须是合法 JSON 字符串。审批数据源需要原样透传 `processCode`(从 `+datasource-list-sources` 返回的 `result` JSON 中解析 `approvals[]` 提取)、`name`、`iconUrl`、`url`,设置 `dataType`(时间范围类型),并按 `dataType` 提供对应的时间参数(`recentDays` / `startDate` / `endDate`)。
|
||||
|
||||
5. **同步限制**:`+datasource-sync` 单次最多 5 张表;`+datasource-sync-status` 单次最多查询 5 个任务 ID。
|
||||
|
||||
6. **创建即同步**:`+datasource-create` 和 `+datasource-update` 在操作完成后会自动触发一次同步,无需额外调用 `+datasource-sync`。
|
||||
|
||||
7. **自动同步**:`--auto` 开启后,数据源表会按 `--auto-sync-setting` 指定的频率自动定期同步;未指定频率时使用服务端默认策略。关闭 `--auto` 后仅能通过 `+datasource-sync` 手动触发。
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"generated_at": "2026-08-21T10:07:48.251313",
|
||||
"count": 418,
|
||||
"generated_at": "2026-08-24T12:22:05.108140",
|
||||
"count": 426,
|
||||
"results": [
|
||||
{
|
||||
"suite": "semantic",
|
||||
@@ -282,6 +282,76 @@
|
||||
"semantic_delta": "更新仪表盘配置的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+datasource-create",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "为指定 Base 创建数据源表并触发首次全量同步,返回新建表 ID 和同步任务 ID。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+datasource-get-config",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "读取已有数据源表的同步配置详情(源配置、字段选择、自动同步状态)的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+datasource-get-fields",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "获取指定数据源来源的可同步字段列表(字段 ID/名称/类型/是否主键),用于决定 field-ids。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+datasource-list-sources",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "列出指定 Base 下可用的数据源条目(OA 审批模板等),提取 processCode/name/iconUrl/url 用于 sourceConfig。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+datasource-sync",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "对已有数据源表触发手动同步(单次最多 5 张),仅触发即返回同步任务 ID。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+datasource-sync-status",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "批量查询数据源同步任务状态(RUNNING/FINISHED/FAILED),与 sync/create/update 触发后配对使用。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+datasource-update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "更新已有数据源表的同步配置并触发一次同步;不改配置可只切换 auto 开关或 field-ids。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
@@ -3635,6 +3705,16 @@
|
||||
"semantic_delta": "按关键字搜索可发起审批定义,严格要求显式 result 数组和稳定 processCode;已完成已知非空与保证零命中证明。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "pat",
|
||||
"command": "+browser-policy",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "在隔离本地策略文件上提供显式确认、无写入请求预览、同目标磁盘读回和不暴露 agent identity 的统一结果;exact 写入与清理已通过。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "report",
|
||||
@@ -4126,4 +4206,4 @@
|
||||
"availability": "available"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -16,12 +16,12 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
publicShortcutCount = 417
|
||||
publicShortcutCount = 425
|
||||
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
|
||||
// including reviewed hidden compatibility and unavailable contracts.
|
||||
schemaPublishedShortcutCount = 461
|
||||
schemaPublishedShortcutCount = 482
|
||||
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
|
||||
publiclyDeliveredShortcutCount = 417
|
||||
publiclyDeliveredShortcutCount = 425
|
||||
)
|
||||
|
||||
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
|
||||
@@ -271,6 +271,60 @@ func TestAllShortcutsWikiSchemaExamplesIncludeRequiredParameters(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllShortcutsAITableDatasourceExamplesSourceConfigHasRequiredMembers(t *testing.T) {
|
||||
tools := deliverySchemaAllToolsForHelpFlagTest(t, NewRootCommand())
|
||||
requiredSourceConfigMembers := []string{"processCode", "name", "iconUrl", "url"}
|
||||
checked := 0
|
||||
for _, declared := range shortcut.All() {
|
||||
if declared.Service != "aitable" || declared.UserDefined || !shortcut.InPublicCatalog(declared.Service, declared.Command) {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(declared.Command, "+datasource-") {
|
||||
continue
|
||||
}
|
||||
if declared.Command != "+datasource-create" && declared.Command != "+datasource-update" && declared.Command != "+datasource-get-fields" {
|
||||
continue
|
||||
}
|
||||
checked++
|
||||
canonical := shortcutSchemaCanonical(declared)
|
||||
tool := tools[canonical]
|
||||
if tool == nil {
|
||||
t.Fatalf("delivery schema --all is missing %s", canonical)
|
||||
}
|
||||
examples := schemaContractStringSlice(tool["examples"])
|
||||
if len(examples) == 0 {
|
||||
t.Fatalf("%s has no delivered examples", canonical)
|
||||
}
|
||||
for _, example := range examples {
|
||||
if !strings.Contains(example, "--source-config") {
|
||||
continue
|
||||
}
|
||||
argv, err := cli.ParseAgentExampleArgv(example)
|
||||
if err != nil {
|
||||
t.Fatalf("%s example %q is not valid argv: %v", canonical, example, err)
|
||||
}
|
||||
sourceConfig := schemaExampleFlagValue(argv, "source-config")
|
||||
if sourceConfig == "" {
|
||||
t.Errorf("%s example %q contains --source-config but has no value", canonical, example)
|
||||
continue
|
||||
}
|
||||
var cfg map[string]any
|
||||
if err := json.Unmarshal([]byte(sourceConfig), &cfg); err != nil {
|
||||
t.Errorf("%s example %q has invalid source-config JSON: %v", canonical, example, err)
|
||||
continue
|
||||
}
|
||||
for _, member := range requiredSourceConfigMembers {
|
||||
if _, ok := cfg[member]; !ok {
|
||||
t.Errorf("%s example %q source-config is missing required member %q", canonical, example, member)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if checked != 3 {
|
||||
t.Fatalf("checked aitable datasource source-config examples = %d, want 3", checked)
|
||||
}
|
||||
}
|
||||
|
||||
func schemaExampleHasLongFlag(argv []string, names ...string) bool {
|
||||
for _, argument := range argv {
|
||||
for _, name := range names {
|
||||
@@ -282,6 +336,25 @@ func schemaExampleHasLongFlag(argv []string, names ...string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func schemaExampleFlagValue(argv []string, name string) string {
|
||||
prefix := "--" + name + "="
|
||||
for _, argument := range argv {
|
||||
if argument == "--"+name {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(argument, prefix) {
|
||||
return strings.TrimPrefix(argument, prefix)
|
||||
}
|
||||
}
|
||||
// Value may be in the next argv entry: `--flag value` form.
|
||||
for i := 0; i < len(argv)-1; i++ {
|
||||
if argv[i] == "--"+name {
|
||||
return argv[i+1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func assertSchemaSummarySafety(
|
||||
t testing.TB,
|
||||
summaries map[string]map[string]any,
|
||||
|
||||
@@ -5723,7 +5723,7 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
"top": "limit",
|
||||
"wiki-workspace-id": "workspace",
|
||||
},
|
||||
Blocked: []string{"count", "cursor", "dentry-id", "dingdrive-space-id", "drive-space-id", "folder", "folder-id", "page", "permission", "role", "space", "space-id", "storage-space-id"},
|
||||
Blocked: []string{"count", "dentry-id", "dingdrive-space-id", "drive-space-id", "folder", "folder-id", "page", "permission", "role", "space", "space-id", "storage-space-id"},
|
||||
},
|
||||
{
|
||||
CLIPath: "drive permission remove",
|
||||
@@ -7423,7 +7423,7 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
"top": "limit",
|
||||
"wiki-workspace-id": "workspace",
|
||||
},
|
||||
Blocked: []string{"count", "cursor", "dentry-id", "dingdrive-space-id", "drive-space-id", "folder", "folder-id", "name", "node", "node-id", "open-dingtalk-id", "page", "page-all", "role", "space", "space-id", "storage-space-id", "user-id", "user-ids", "users"},
|
||||
Blocked: []string{"count", "dentry-id", "dingdrive-space-id", "drive-space-id", "folder", "folder-id", "name", "node", "node-id", "open-dingtalk-id", "page", "page-all", "role", "space", "space-id", "storage-space-id", "user-id", "user-ids", "users"},
|
||||
Ambiguous: []string{"id"},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -423,7 +423,7 @@
|
||||
"wiki +member-remove": {"scoped_aliases":{"uid":"user","user-id":"user"},"ambiguous":["id"],"block":["name","open-dingtalk-id","open-dingtalk-ids","staff-id"],"scope_strict":true,"note":"Workspace and a 1-30 item userId list are separate ID roles. --user-id/--uid value-preservingly normalize to the real repeatable --user alias; openDingTalkId and staffId spellings remain blocked."},
|
||||
"wiki member remove": {"ambiguous":["id"],"block":["name","open-dingtalk-id","open-dingtalk-ids","staff-id"],"scope_strict":true,"note":"Only canonical --users and the manually wired hidden --user are runtime-consumed. Auto-registered real hidden --user-id/--uid/--user-ids/--user-list/--user-id-list bypass central rewriting but are not read by collectUserIDs; use --users until command-owned wiring is fixed."},
|
||||
"wiki +member-list": {"scoped_aliases":{"filter-roles":"filter-role","role-filter":"filter-role","roles":"filter-role"},"ambiguous":["id"],"block":["cursor","name","open-dingtalk-id","page-all","role","user-id","user-ids","users"],"scope_strict":true,"note":"This command lists members and can filter by a role list. It does not accept user filters or cursor auto-pagination, and workspace/user IDs must not be conflated."},
|
||||
"wiki member list": {"scoped_aliases":{"filter-roles":"filter-role","role-filter":"filter-role","roles":"filter-role"},"ambiguous":["id"],"block":["cursor","name","open-dingtalk-id","page-all","role","user-id","user-ids","users"],"scope_strict":true,"note":"Native hidden workspace/pagination aliases remain native. The endpoint has no user filter or cursor and returns at most 50 members."}
|
||||
"wiki member list": {"scoped_aliases":{"filter-roles":"filter-role","role-filter":"filter-role","roles":"filter-role"},"ambiguous":["id"],"block":["name","open-dingtalk-id","page-all","role","user-id","user-ids","users"],"scope_strict":true,"note":"Native hidden workspace aliases remain native. Pagination is now cursor-based via the real --next-token (auto-aliased --cursor/--page-token) with pageSize max 50; user filters are still unsupported."}
|
||||
},
|
||||
|
||||
"validation_fixture": {
|
||||
|
||||
@@ -408,6 +408,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
|
||||
"doc.insert_document_block --level": "aggregate convenience input used to build element",
|
||||
"doc.insert_document_block --content": "aggregate convenience input used to build element",
|
||||
"doc.list_document_blocks --block-id": "runtime extension sends blockId, which is absent from the pinned list_document_blocks metadata",
|
||||
"doc.list_permission --limit": "The server rejects the legacy maxResults path for list_permission; the CLI validates --limit (1-50) and sends it as pageSize at runtime, so --limit is a CLI pagination input without a one-to-one RPC property.",
|
||||
"doc.reply_comment --mentioned-open-conversation-id": "Runtime extension sends mentionedOpenConversationIds, which is absent from the immutable pinned reply_comment metadata at its declared source revision.",
|
||||
"doc.style_background_clear --node": "Reviewed unpinned adapter: doc.style_background_clear has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"doc.style_background_set --color": "Reviewed unpinned adapter: doc.style_background_set has no singular pinned interface_ref; --color is a CLI wrapper input and does not publish a direct interface property.",
|
||||
@@ -476,6 +477,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
|
||||
"drive.list_files --space-id": "drive-branch-only route input on a composite drive/doc command; no singular interface property is advertised",
|
||||
"drive.list_files --thumbnail": "drive-branch-only option on a composite drive/doc command; no singular interface property is advertised",
|
||||
"drive.list_files --workspace": "selects the doc.list_nodes branch of the composite drive/doc command",
|
||||
"drive.list_permission --limit": "The server rejects the legacy maxResults path for list_permission; the CLI validates --limit (1-50) and sends it as pageSize at runtime, so --limit is a CLI pagination input without a one-to-one RPC property.",
|
||||
"drive.mark_star --node": "Reviewed unpinned adapter: drive.mark_star has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.publish_get --node": "Reviewed unpinned adapter: drive.publish_get has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.publish_set --node": "Reviewed unpinned adapter: drive.publish_set has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
@@ -664,6 +666,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
|
||||
"todo.list_todo_attachment --task-id": "Reviewed unpinned adapter: --task-id is nested under todoAttachmentListRequest at runtime, while the immutable pinned MCP snapshot has no interface_ref for todo.list_todo_attachment.",
|
||||
"wiki.create_wikiSpace --icon": "runtime extension sends icon, which is absent from the pinned create_wikiSpace metadata",
|
||||
"wiki.delete_document --workspace": "local validation/authorization context; not sent to delete_document",
|
||||
"wiki.list_member --limit": "The server rejects the legacy maxResults path for list_member; the CLI validates --limit (1-50) and sends it as pageSize at runtime, so --limit is a CLI pagination input without a one-to-one RPC property.",
|
||||
"wiki.list_wikiSpaces --cursor": "composite route maps to pageToken for wiki.list_wikiSpaces or nextToken for drive.list_spaces",
|
||||
"wiki.list_wikiSpaces --limit": "composite route maps to pageSize for wiki.list_wikiSpaces or maxResults for drive.list_spaces",
|
||||
"wiki.list_wikiSpaces --type": "route selector maps to wikiSpaceType or spaceType on different composite branches",
|
||||
|
||||
@@ -151,6 +151,24 @@ func WithOperation(operation string) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// IsConfirmationRequired reports whether err (or any wrapped cause) is a
|
||||
// typed framework confirmation-gate failure carrying reason
|
||||
// confirmation_required. Downstream classifiers must pass such errors through
|
||||
// verbatim: the "re-run with --yes" semantics can only be carried by the
|
||||
// machine-readable reason, while message-text classification actively
|
||||
// misroutes them (a command path containing "permission" would be reported as
|
||||
// an auth failure, and any other wording degrades to an unclassified error).
|
||||
func IsConfirmationRequired(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
var typed *Error
|
||||
if stderrors.As(err, &typed) {
|
||||
return strings.TrimSpace(typed.Reason) == "confirmation_required"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// WithServerKey records the server identifier associated with the failure.
|
||||
func WithServerKey(serverKey string) Option {
|
||||
return func(err *Error) {
|
||||
|
||||
@@ -15,6 +15,7 @@ package errors
|
||||
|
||||
import (
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -464,3 +465,34 @@ func TestCrossPlatformCoveragePrintHumanHidesRPCCode_Normal(t *testing.T) {
|
||||
t.Fatalf("normal mode should not show RPC Code, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageIsConfirmationRequired(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if IsConfirmationRequired(nil) {
|
||||
t.Fatal("nil error must not report confirmation_required")
|
||||
}
|
||||
if IsConfirmationRequired(NewValidation("missing required flag")) {
|
||||
t.Fatal("plain validation error must not report confirmation_required")
|
||||
}
|
||||
plain := stderrors.New("需要用户确认")
|
||||
if IsConfirmationRequired(plain) {
|
||||
t.Fatal("message text alone must not report confirmation_required")
|
||||
}
|
||||
confirmation := NewValidation(
|
||||
"blocked",
|
||||
WithReason("confirmation_required"),
|
||||
)
|
||||
if !IsConfirmationRequired(confirmation) {
|
||||
t.Fatal("typed confirmation error must report confirmation_required")
|
||||
}
|
||||
// 包装链(fmt.Errorf %w)必须能穿透到 typed 原因。
|
||||
wrapped := fmt.Errorf("call tool: %w", confirmation)
|
||||
if !IsConfirmationRequired(wrapped) {
|
||||
t.Fatal("wrapped confirmation error must report confirmation_required")
|
||||
}
|
||||
otherReason := NewValidation("rate limited", WithReason("rate_limit"))
|
||||
if IsConfirmationRequired(otherReason) {
|
||||
t.Fatal("other reasons must not report confirmation_required")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8422,6 +8422,416 @@ parentSectionId 为空串表示该节点在 Base 根目录下。
|
||||
sectionMoveNodeCmd,
|
||||
)
|
||||
|
||||
// ── datasource: 数据源同步管理 ──────────────────────────────
|
||||
|
||||
datasourceCmd := &cobra.Command{Use: "datasource", Short: "数据源同步管理", RunE: groupRunE}
|
||||
|
||||
datasourceGetConfigCmd := &cobra.Command{
|
||||
Use: "get-config",
|
||||
Short: "获取数据源表同步配置",
|
||||
Example: ` dws aitable datasource get-config --base-id BASE_ID --table-id TABLE_ID`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "table-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callAitableTool("get_datasource_config", map[string]any{
|
||||
"baseId": baseID,
|
||||
"tableId": mustGetFlag(cmd, "table-id"),
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(datasourceGetConfigCmd, LeafSpec{
|
||||
Safety: aitableSafetyRead(),
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aitable",
|
||||
Name: "datasource_get_config",
|
||||
CanonicalPath: "aitable.datasource_get_config",
|
||||
CLIPath: "aitable datasource get-config",
|
||||
PrimaryCLIPath: "aitable datasource get-config",
|
||||
},
|
||||
Description: "获取数据源表的同步配置信息。",
|
||||
Interface: aitableMCPInterface("get_datasource_config"),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "获取数据源表的同步配置信息。",
|
||||
UseWhen: []string{"查看已有数据源表的配置详情时"},
|
||||
AvoidWhen: []string{"更新配置用 datasource update;查询同步状态用 datasource sync-status"},
|
||||
Examples: []string{"dws aitable datasource get-config --base-id <BASE_ID> --table-id <TABLE_ID>"},
|
||||
},
|
||||
},
|
||||
})
|
||||
datasourceGetConfigCmd.Flags().String("base-id", "", "Base ID (必填)")
|
||||
datasourceGetConfigCmd.Flags().String("table-id", "", "数据源表 ID (必填)")
|
||||
|
||||
datasourceListSourcesCmd := &cobra.Command{
|
||||
Use: "list-sources",
|
||||
Short: "列出可用数据源来源",
|
||||
Example: ` dws aitable datasource list-sources --base-id BASE_ID --datasource-type OA`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "datasource-type"); err != nil {
|
||||
return err
|
||||
}
|
||||
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callAitableTool("list_datasource_sources", map[string]any{
|
||||
"baseId": baseID,
|
||||
"datasourceType": mustGetFlag(cmd, "datasource-type"),
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(datasourceListSourcesCmd, LeafSpec{
|
||||
Safety: aitableSafetyRead(),
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aitable",
|
||||
Name: "datasource_list_sources",
|
||||
CanonicalPath: "aitable.datasource_list_sources",
|
||||
CLIPath: "aitable datasource list-sources",
|
||||
PrimaryCLIPath: "aitable datasource list-sources",
|
||||
},
|
||||
Description: "列出指定 Base 下可用的数据源条目。",
|
||||
Interface: aitableMCPInterface("list_datasource_sources"),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "列出指定 Base 下可用的数据源条目(OA 审批模板等)。",
|
||||
UseWhen: []string{"创建或更新数据源前需要查看可用来源时"},
|
||||
AvoidWhen: []string{"获取字段结构用 datasource get-fields"},
|
||||
Examples: []string{"dws aitable datasource list-sources --base-id <BASE_ID> --datasource-type OA"},
|
||||
},
|
||||
},
|
||||
})
|
||||
datasourceListSourcesCmd.Flags().String("base-id", "", "Base ID (必填)")
|
||||
datasourceListSourcesCmd.Flags().String("datasource-type", "", "数据源类型,目前支持 OA (必填)")
|
||||
|
||||
validateJSONObject := func(flag, raw string) error {
|
||||
var v any
|
||||
if err := json.Unmarshal([]byte(raw), &v); err != nil {
|
||||
return fmt.Errorf("--%s must be a valid JSON object: %w", flag, err)
|
||||
}
|
||||
if _, ok := v.(map[string]any); !ok {
|
||||
return fmt.Errorf("--%s must be a JSON object, got %T", flag, v)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
validateAutoSyncSetting := func(raw string) error {
|
||||
return validateJSONObject("auto-sync-setting", raw)
|
||||
}
|
||||
|
||||
datasourceGetFieldsCmd := &cobra.Command{
|
||||
Use: "get-fields",
|
||||
Short: "获取数据源可同步字段列表",
|
||||
Example: ` dws aitable datasource get-fields --base-id BASE_ID --datasource-type OA --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "datasource-type", "source-config"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateJSONObject("source-config", mustGetFlag(cmd, "source-config")); err != nil {
|
||||
return err
|
||||
}
|
||||
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callAitableTool("get_datasource_fields", map[string]any{
|
||||
"baseId": baseID,
|
||||
"datasourceType": mustGetFlag(cmd, "datasource-type"),
|
||||
"sourceConfig": mustGetFlag(cmd, "source-config"),
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(datasourceGetFieldsCmd, LeafSpec{
|
||||
Safety: aitableSafetyRead(),
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aitable",
|
||||
Name: "datasource_get_fields",
|
||||
CanonicalPath: "aitable.datasource_get_fields",
|
||||
CLIPath: "aitable datasource get-fields",
|
||||
PrimaryCLIPath: "aitable datasource get-fields",
|
||||
},
|
||||
Description: "获取指定数据源来源的可同步字段列表。",
|
||||
Interface: aitableMCPInterface("get_datasource_fields"),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "获取指定数据源来源的可同步字段列表(字段 ID/名称/类型/是否主键)。",
|
||||
UseWhen: []string{"创建或更新数据源前需要查看可同步字段以决定 field-ids 时"},
|
||||
AvoidWhen: []string{"列出可用来源用 datasource list-sources"},
|
||||
Examples: []string{`dws aitable datasource get-fields --base-id <BASE_ID> --datasource-type OA --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`},
|
||||
},
|
||||
},
|
||||
})
|
||||
datasourceGetFieldsCmd.Flags().String("base-id", "", "Base ID (必填)")
|
||||
datasourceGetFieldsCmd.Flags().String("datasource-type", "", "数据源类型,目前支持 OA (必填)")
|
||||
datasourceGetFieldsCmd.Flags().String("source-config", "", "源配置 JSON 字符串,需含 processCode、name、iconUrl、url、dataType 及对应时间字段 (必填)")
|
||||
|
||||
datasourceCreateCmd := &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "创建数据源表并触发首次同步",
|
||||
Example: ` dws aitable datasource create --base-id BASE_ID --datasource-type OA --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "datasource-type", "source-config"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateJSONObject("source-config", mustGetFlag(cmd, "source-config")); err != nil {
|
||||
return err
|
||||
}
|
||||
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
auto, _ := cmd.Flags().GetBool("auto")
|
||||
toolArgs := map[string]any{
|
||||
"baseId": baseID,
|
||||
"datasourceType": mustGetFlag(cmd, "datasource-type"),
|
||||
"sourceConfig": mustGetFlag(cmd, "source-config"),
|
||||
"auto": auto,
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("field-ids"); v != "" {
|
||||
toolArgs["fieldIds"] = parseCSVValues(v)
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("auto-sync-setting"); v != "" {
|
||||
if err := validateAutoSyncSetting(v); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["autoSyncSetting"] = v
|
||||
}
|
||||
return callAitableTool("create_datasource", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(datasourceCreateCmd, LeafSpec{
|
||||
Safety: aitableSafetyWrite(),
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aitable",
|
||||
Name: "datasource_create",
|
||||
CanonicalPath: "aitable.datasource_create",
|
||||
CLIPath: "aitable datasource create",
|
||||
PrimaryCLIPath: "aitable datasource create",
|
||||
},
|
||||
Description: "为指定 Base 创建数据源表并触发首次全量同步。",
|
||||
Interface: aitableMCPInterface("create_datasource"),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "为指定 Base 创建数据源表并触发首次全量同步,返回新建表 ID 和同步任务 ID。",
|
||||
UseWhen: []string{"需要将外部数据源接入 AI 表格、创建新的数据源表时"},
|
||||
AvoidWhen: []string{"已有数据源表改配置用 datasource update;仅触发同步用 datasource sync"},
|
||||
Examples: []string{`dws aitable datasource create --base-id <BASE_ID> --datasource-type OA --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "base-id", Property: "baseId", Required: boolPtr(true)},
|
||||
{Name: "datasource-type", Property: "datasourceType", Required: boolPtr(true)},
|
||||
{Name: "source-config", Property: "sourceConfig", Required: boolPtr(true)},
|
||||
{Name: "auto", Property: "auto"},
|
||||
{Name: "field-ids", Property: "fieldIds", InterfaceType: "array"},
|
||||
{Name: "auto-sync-setting", Property: "autoSyncSetting"},
|
||||
},
|
||||
},
|
||||
})
|
||||
datasourceCreateCmd.Flags().String("base-id", "", "Base ID (必填)")
|
||||
datasourceCreateCmd.Flags().String("datasource-type", "", "数据源类型,目前支持 OA (必填)")
|
||||
datasourceCreateCmd.Flags().String("source-config", "", "源配置 JSON 字符串,须从 list-sources 原样透传 processCode/name/iconUrl/url,并设置 dataType 及对应时间字段 (必填)")
|
||||
datasourceCreateCmd.Flags().Bool("auto", false, "是否开启自动同步,默认 false;创建新数据源表时始终下发给下游")
|
||||
datasourceCreateCmd.Flags().String("field-ids", "", "需要同步的字段 ID 列表,逗号分隔;不传时同步全部字段")
|
||||
datasourceCreateCmd.Flags().String("auto-sync-setting", "", "自动同步频率配置 JSON 字符串,仅在 --auto=true 时生效。字段:syncType(必填,hourly/scheduled)、hourlyInterval(syncType=hourly 时必填)、scheduleType(syncType=scheduled 时必填,daily/weekly/monthly)、timeValue(HH:mm)、selectedMonthDays(scheduleType=monthly 时)、selectedWeekdays(scheduleType=weekly 时)、skipNonWorkingDay")
|
||||
|
||||
datasourceUpdateCmd := &cobra.Command{
|
||||
Use: "update",
|
||||
Short: "更新数据源表同步配置并触发同步",
|
||||
Example: ` dws aitable datasource update --base-id BASE_ID --table-id TABLE_ID --auto`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "table-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"baseId": baseID,
|
||||
"tableId": mustGetFlag(cmd, "table-id"),
|
||||
}
|
||||
if cmd.Flags().Changed("source-config") {
|
||||
if err := validateJSONObject("source-config", mustGetFlag(cmd, "source-config")); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["sourceConfig"] = mustGetFlag(cmd, "source-config")
|
||||
}
|
||||
if cmd.Flags().Changed("auto") {
|
||||
auto, _ := cmd.Flags().GetBool("auto")
|
||||
toolArgs["auto"] = auto
|
||||
}
|
||||
if cmd.Flags().Changed("field-ids") {
|
||||
v := mustGetFlag(cmd, "field-ids")
|
||||
if v == "" {
|
||||
return fmt.Errorf("--field-ids 显式提供时不能为空,如需保持默认请勿传入")
|
||||
}
|
||||
toolArgs["fieldIds"] = parseCSVValues(v)
|
||||
}
|
||||
if cmd.Flags().Changed("auto-sync-setting") {
|
||||
v := mustGetFlag(cmd, "auto-sync-setting")
|
||||
if v == "" {
|
||||
return fmt.Errorf("--auto-sync-setting 显式提供时不能为空,如需保持默认请勿传入")
|
||||
}
|
||||
if err := validateAutoSyncSetting(v); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["autoSyncSetting"] = v
|
||||
}
|
||||
if !cmd.Flags().Changed("source-config") && !cmd.Flags().Changed("auto") && !cmd.Flags().Changed("field-ids") && !cmd.Flags().Changed("auto-sync-setting") {
|
||||
return fmt.Errorf("至少需要一个配置变更:--source-config、--auto、--field-ids 或 --auto-sync-setting;仅触发同步请使用 datasource sync")
|
||||
}
|
||||
return callAitableTool("update_datasource_config", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(datasourceUpdateCmd, LeafSpec{
|
||||
Safety: aitableSafetyWrite(),
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aitable",
|
||||
Name: "datasource_update",
|
||||
CanonicalPath: "aitable.datasource_update",
|
||||
CLIPath: "aitable datasource update",
|
||||
PrimaryCLIPath: "aitable datasource update",
|
||||
},
|
||||
Description: "更新已有数据源表的同步配置并触发一次同步。",
|
||||
Interface: aitableMCPInterface("update_datasource_config"),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "更新已有数据源表的同步配置并触发一次同步。",
|
||||
UseWhen: []string{"需要修改已有数据源表的配置(更换模板、调整字段、开关自动同步)时"},
|
||||
AvoidWhen: []string{"创建新数据源表用 datasource create;仅触发同步用 datasource sync"},
|
||||
Examples: []string{
|
||||
"dws aitable datasource update --base-id <BASE_ID> --table-id <TABLE_ID> --auto",
|
||||
`dws aitable datasource update --base-id <BASE_ID> --table-id <TABLE_ID> --source-config '{"processCode":"PROC-YYYY","name":"出差申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`,
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "base-id", Property: "baseId", Required: boolPtr(true)},
|
||||
{Name: "table-id", Property: "tableId", Required: boolPtr(true)},
|
||||
{Name: "source-config", Property: "sourceConfig"},
|
||||
{Name: "auto", Property: "auto"},
|
||||
{Name: "field-ids", Property: "fieldIds", InterfaceType: "array"},
|
||||
{Name: "auto-sync-setting", Property: "autoSyncSetting"},
|
||||
},
|
||||
},
|
||||
})
|
||||
datasourceUpdateCmd.Flags().String("base-id", "", "Base ID (必填)")
|
||||
datasourceUpdateCmd.Flags().String("table-id", "", "数据源表 ID (必填)")
|
||||
datasourceUpdateCmd.Flags().String("source-config", "", "可选。新的源配置 JSON 字符串,不传时保持原配置;传入时整体覆盖,须含 processCode、name、iconUrl、url、dataType 及对应时间字段")
|
||||
datasourceUpdateCmd.Flags().Bool("auto", false, "可选。是否开启自动同步;仅显式设置时下发给下游,省略时保持原设置")
|
||||
datasourceUpdateCmd.Flags().String("field-ids", "", "需要同步的字段 ID 列表,逗号分隔;不传时保持现有配置(创建时默认为全部字段)")
|
||||
datasourceUpdateCmd.Flags().String("auto-sync-setting", "", "可选。自动同步频率配置 JSON 字符串,仅在显式设置 --auto=true 时生效;省略时保持原有自动同步频率配置。字段:syncType(必填,hourly/scheduled)、hourlyInterval(syncType=hourly 时必填)、scheduleType(syncType=scheduled 时必填,daily/weekly/monthly)、timeValue(HH:mm)、selectedMonthDays(scheduleType=monthly 时)、selectedWeekdays(scheduleType=weekly 时)、skipNonWorkingDay")
|
||||
|
||||
datasourceSyncCmd := &cobra.Command{
|
||||
Use: "sync",
|
||||
Short: "触发数据源表手动同步",
|
||||
Example: ` dws aitable datasource sync --base-id BASE_ID --table-ids TBL1,TBL2`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "table-ids"); err != nil {
|
||||
return err
|
||||
}
|
||||
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tableIDs := parseCSVValues(mustGetFlag(cmd, "table-ids"))
|
||||
if len(tableIDs) < 1 || len(tableIDs) > 5 {
|
||||
return fmt.Errorf("--table-ids requires 1-5 table IDs, got %d", len(tableIDs))
|
||||
}
|
||||
return callAitableTool("run_datasource_sync", map[string]any{
|
||||
"baseId": baseID,
|
||||
"tableIds": tableIDs,
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(datasourceSyncCmd, LeafSpec{
|
||||
Safety: aitableSafetyWrite(),
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aitable",
|
||||
Name: "datasource_sync",
|
||||
CanonicalPath: "aitable.datasource_sync",
|
||||
CLIPath: "aitable datasource sync",
|
||||
PrimaryCLIPath: "aitable datasource sync",
|
||||
},
|
||||
Description: "对已有数据源表触发手动同步(单次最多 5 张),仅触发即返回。",
|
||||
Interface: aitableMCPInterface("run_datasource_sync"),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "对已有数据源表触发手动同步(单次最多 5 张),仅触发即返回同步任务 ID。",
|
||||
UseWhen: []string{"需要手动触发已有数据源表的数据同步时"},
|
||||
AvoidWhen: []string{"创建新数据源表用 datasource create;更新配置用 datasource update"},
|
||||
Examples: []string{"dws aitable datasource sync --base-id <BASE_ID> --table-ids TBL1,TBL2"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "base-id", Property: "baseId", Required: boolPtr(true)},
|
||||
{Name: "table-ids", Property: "tableIds", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
datasourceSyncCmd.Flags().String("base-id", "", "Base ID (必填)")
|
||||
datasourceSyncCmd.Flags().String("table-ids", "", "待触发同步的数据源表 ID 列表,逗号分隔,1-5 个 (必填)")
|
||||
|
||||
datasourceSyncStatusCmd := &cobra.Command{
|
||||
Use: "sync-status",
|
||||
Short: "按任务 ID 查询数据源同步任务状态",
|
||||
Example: ` dws aitable datasource sync-status --base-id BASE_ID --table-id TABLE_ID --task-ids TASK1,TASK2`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "table-id", "task-ids"); err != nil {
|
||||
return err
|
||||
}
|
||||
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ids := parseCSVValues(mustGetFlag(cmd, "task-ids"))
|
||||
if len(ids) < 1 || len(ids) > 5 {
|
||||
return fmt.Errorf("--task-ids requires 1-5 task IDs, got %d", len(ids))
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"baseId": baseID,
|
||||
"tableId": mustGetFlag(cmd, "table-id"),
|
||||
"taskIds": ids,
|
||||
}
|
||||
return callAitableTool("get_datasource_sync_status", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(datasourceSyncStatusCmd, LeafSpec{
|
||||
Safety: aitableSafetyRead(),
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aitable",
|
||||
Name: "datasource_sync_status",
|
||||
CanonicalPath: "aitable.datasource_sync_status",
|
||||
CLIPath: "aitable datasource sync-status",
|
||||
PrimaryCLIPath: "aitable datasource sync-status",
|
||||
},
|
||||
Description: "按任务 ID 查询数据源同步任务状态(RUNNING/FINISHED/FAILED)。",
|
||||
Interface: aitableMCPInterface("get_datasource_sync_status"),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "按任务 ID 批量查询数据源同步任务状态(RUNNING/FINISHED/FAILED),与 sync/create/update 触发后配对使用。",
|
||||
UseWhen: []string{"触发同步后需要按任务 ID 查询任务是否完成时"},
|
||||
AvoidWhen: []string{"触发同步用 datasource sync"},
|
||||
Examples: []string{"dws aitable datasource sync-status --base-id <BASE_ID> --table-id <TABLE_ID> --task-ids TASK1"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "base-id", Property: "baseId", Required: boolPtr(true)},
|
||||
{Name: "table-id", Property: "tableId", Required: boolPtr(true)},
|
||||
{Name: "task-ids", Property: "taskIds", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
datasourceSyncStatusCmd.Flags().String("base-id", "", "Base ID (必填)")
|
||||
datasourceSyncStatusCmd.Flags().String("table-id", "", "数据源表 ID (必填)")
|
||||
datasourceSyncStatusCmd.Flags().String("task-ids", "", "待查询的同步任务 ID 列表,逗号分隔,1-5 个 (必填)")
|
||||
|
||||
datasourceCmd.AddCommand(
|
||||
datasourceGetConfigCmd, datasourceListSourcesCmd, datasourceGetFieldsCmd,
|
||||
datasourceCreateCmd, datasourceUpdateCmd,
|
||||
datasourceSyncCmd, datasourceSyncStatusCmd,
|
||||
)
|
||||
|
||||
// 组装 aitable 命令树
|
||||
root.AddCommand(
|
||||
baseCmd, tableCmd, fieldCmd,
|
||||
@@ -8432,6 +8842,7 @@ parentSectionId 为空串表示该节点在 Base 根目录下。
|
||||
attachmentCmd, templateCmd,
|
||||
advpermCmd,
|
||||
sectionCmd,
|
||||
datasourceCmd,
|
||||
)
|
||||
|
||||
// 批量注册 --base 作为 --base-id 的隐藏别名
|
||||
|
||||
@@ -0,0 +1,462 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
type aitableDatasourceCaller struct {
|
||||
calls []aitableTestCall
|
||||
}
|
||||
|
||||
func (c *aitableDatasourceCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
c.calls = append(c.calls, aitableTestCall{server: server, tool: tool, args: args})
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{
|
||||
Type: "text",
|
||||
Text: `{"status":"success","data":{"tableId":"tbl_test","taskId":"task_test"}}`,
|
||||
}}}, nil
|
||||
}
|
||||
|
||||
func (*aitableDatasourceCaller) Format() string { return "json" }
|
||||
func (*aitableDatasourceCaller) DryRun() bool { return false }
|
||||
func (*aitableDatasourceCaller) Fields() string { return "" }
|
||||
func (*aitableDatasourceCaller) JQ() string { return "" }
|
||||
|
||||
func runAitableDatasourceCommand(t *testing.T, args ...string) (*aitableDatasourceCaller, error) {
|
||||
t.Helper()
|
||||
testseam.Protect(t, &os.Args)
|
||||
|
||||
caller := &aitableDatasourceCaller{}
|
||||
InitDepsForTest(t, caller)
|
||||
deps.Out.w = io.Discard
|
||||
deps.Out.errW = io.Discard
|
||||
os.Args = append([]string{"dws", "aitable", "datasource"}, args...)
|
||||
|
||||
root := newAitableCommand()
|
||||
root.SetArgs(append([]string{"datasource"}, args...))
|
||||
return caller, root.Execute()
|
||||
}
|
||||
|
||||
func TestAitableDatasourceSyncRejectsMissingTableIDs(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "sync", "--base-id", "BASE123")
|
||||
if err == nil || !strings.Contains(err.Error(), "table-ids") {
|
||||
t.Fatalf("error = %v, want table-ids required", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceSyncRejectsTooManyTableIDs(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "sync", "--base-id", "BASE123",
|
||||
"--table-ids", "T1,T2,T3,T4,T5,T6")
|
||||
if err == nil || !strings.Contains(err.Error(), "1-5") {
|
||||
t.Fatalf("error = %v, want 1-5 limit", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceSyncRejectsEmptyTableIDs(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "sync", "--base-id", "BASE123",
|
||||
"--table-ids", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "table-ids") {
|
||||
t.Fatalf("error = %v, want table-ids error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceSyncAcceptsBoundaryFive(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "sync", "--base-id", "BASE123",
|
||||
"--table-ids", "T1,T2,T3,T4,T5")
|
||||
if err != nil {
|
||||
t.Fatalf("5 table-ids should succeed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].tool != "run_datasource_sync" {
|
||||
t.Fatalf("unexpected calls: %#v", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceSyncAcceptsSingleTableID(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "sync", "--base-id", "BASE123",
|
||||
"--table-ids", "T1")
|
||||
if err != nil {
|
||||
t.Fatalf("single table-id should succeed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("expected 1 call, got %d", len(caller.calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceSyncStatusRejectsTooManyTaskIDs(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "sync-status",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456",
|
||||
"--task-ids", "TK1,TK2,TK3,TK4,TK5,TK6")
|
||||
if err == nil || !strings.Contains(err.Error(), "requires 1-5") {
|
||||
t.Fatalf("error = %v, want 1-5 limit", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceSyncStatusAcceptsFiveTaskIDs(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "sync-status",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456",
|
||||
"--task-ids", "TK1,TK2,TK3,TK4,TK5")
|
||||
if err != nil {
|
||||
t.Fatalf("5 task-ids should succeed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].tool != "get_datasource_sync_status" {
|
||||
t.Fatalf("unexpected calls: %#v", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceSyncStatusRequiresTaskIDs(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "sync-status",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456")
|
||||
if err == nil || !strings.Contains(err.Error(), "task-ids") {
|
||||
t.Fatalf("error = %v, want task-ids required", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceSyncStatusRejectsMissingTableID(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "sync-status", "--base-id", "BASE123")
|
||||
if err == nil || !strings.Contains(err.Error(), "table-id") {
|
||||
t.Fatalf("error = %v, want table-id required", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceGetConfigRejectsMissingTableID(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "get-config", "--base-id", "BASE123")
|
||||
if err == nil || !strings.Contains(err.Error(), "table-id") {
|
||||
t.Fatalf("error = %v, want table-id required", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceGetConfigSuccess(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "get-config",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456")
|
||||
if err != nil {
|
||||
t.Fatalf("get-config should succeed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].tool != "get_datasource_config" {
|
||||
t.Fatalf("unexpected calls: %#v", caller.calls)
|
||||
}
|
||||
if caller.calls[0].args["tableId"] != "TBL456" {
|
||||
t.Fatalf("tableId = %v, want TBL456", caller.calls[0].args["tableId"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceListSourcesSuccess(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "list-sources",
|
||||
"--base-id", "BASE123", "--datasource-type", "OA")
|
||||
if err != nil {
|
||||
t.Fatalf("list-sources should succeed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].tool != "list_datasource_sources" {
|
||||
t.Fatalf("unexpected calls: %#v", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceListSourcesRejectsMissingType(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "list-sources", "--base-id", "BASE123")
|
||||
if err == nil || !strings.Contains(err.Error(), "datasource-type") {
|
||||
t.Fatalf("error = %v, want datasource-type required", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceGetFieldsRejectsMissingSourceConfig(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "get-fields",
|
||||
"--base-id", "BASE123", "--datasource-type", "OA")
|
||||
if err == nil || !strings.Contains(err.Error(), "source-config") {
|
||||
t.Fatalf("error = %v, want source-config required", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceGetFieldsRejectsInvalidSourceConfig(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "get-fields",
|
||||
"--base-id", "BASE123", "--datasource-type", "OA",
|
||||
"--source-config", `not-json`)
|
||||
if err == nil || !strings.Contains(err.Error(), "source-config") {
|
||||
t.Fatalf("error = %v, want source-config validation error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceGetFieldsRejectsNonObjectSourceConfig(t *testing.T) {
|
||||
cases := []string{`[]`, `"text"`, `1`, `true`, `null`}
|
||||
for _, raw := range cases {
|
||||
_, err := runAitableDatasourceCommand(t, "get-fields",
|
||||
"--base-id", "BASE123", "--datasource-type", "OA",
|
||||
"--source-config", raw)
|
||||
if err == nil || !strings.Contains(err.Error(), "source-config") {
|
||||
t.Fatalf("source-config %q: error = %v, want source-config validation error", raw, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceGetFieldsSuccess(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "get-fields",
|
||||
"--base-id", "BASE123", "--datasource-type", "OA",
|
||||
"--source-config", `{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}`)
|
||||
if err != nil {
|
||||
t.Fatalf("get-fields should succeed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].tool != "get_datasource_fields" {
|
||||
t.Fatalf("unexpected calls: %#v", caller.calls)
|
||||
}
|
||||
if caller.calls[0].args["sourceConfig"] != `{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}` {
|
||||
t.Fatalf("sourceConfig not passed as raw string: %v", caller.calls[0].args["sourceConfig"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceCreateRejectsMissingSourceConfig(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "create",
|
||||
"--base-id", "BASE123", "--datasource-type", "OA")
|
||||
if err == nil || !strings.Contains(err.Error(), "source-config") {
|
||||
t.Fatalf("error = %v, want source-config required", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceCreateRejectsInvalidSourceConfig(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "create",
|
||||
"--base-id", "BASE123", "--datasource-type", "OA",
|
||||
"--source-config", `not-json`)
|
||||
if err == nil || !strings.Contains(err.Error(), "source-config") {
|
||||
t.Fatalf("error = %v, want source-config validation error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceCreateRejectsNonObjectSourceConfig(t *testing.T) {
|
||||
cases := []string{`[]`, `"text"`, `1`, `true`, `null`}
|
||||
for _, raw := range cases {
|
||||
_, err := runAitableDatasourceCommand(t, "create",
|
||||
"--base-id", "BASE123", "--datasource-type", "OA",
|
||||
"--source-config", raw)
|
||||
if err == nil || !strings.Contains(err.Error(), "source-config") {
|
||||
t.Fatalf("source-config %q: error = %v, want source-config validation error", raw, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceCreateSuccess(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "create",
|
||||
"--base-id", "BASE123", "--datasource-type", "OA",
|
||||
"--source-config", `{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}`)
|
||||
if err != nil {
|
||||
t.Fatalf("create should succeed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].tool != "create_datasource" {
|
||||
t.Fatalf("unexpected calls: %#v", caller.calls)
|
||||
}
|
||||
if v, ok := caller.calls[0].args["auto"]; !ok || v != false {
|
||||
t.Fatalf("auto = %v, want false when not provided", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceCreateWithAuto(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "create",
|
||||
"--base-id", "BASE123", "--datasource-type", "OA",
|
||||
"--source-config", `{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}`,
|
||||
"--auto")
|
||||
if err != nil {
|
||||
t.Fatalf("create with --auto should succeed: %v", err)
|
||||
}
|
||||
if v, ok := caller.calls[0].args["auto"]; !ok || v != true {
|
||||
t.Fatalf("auto = %v, want true", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceCreateWithFieldIDsAndAutoSyncSetting(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "create",
|
||||
"--base-id", "BASE123", "--datasource-type", "OA",
|
||||
"--source-config", `{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}`,
|
||||
"--auto",
|
||||
"--field-ids", "fldAAA,fldBBB",
|
||||
"--auto-sync-setting", `{"syncType":"scheduled","scheduleType":"daily","timeValue":"09:00"}`)
|
||||
if err != nil {
|
||||
t.Fatalf("create with field-ids and auto-sync-setting should succeed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].tool != "create_datasource" {
|
||||
t.Fatalf("unexpected calls: %#v", caller.calls)
|
||||
}
|
||||
fieldIDs, ok := caller.calls[0].args["fieldIds"].([]string)
|
||||
if !ok || len(fieldIDs) != 2 || fieldIDs[0] != "fldAAA" || fieldIDs[1] != "fldBBB" {
|
||||
t.Fatalf("fieldIds = %v, want [fldAAA fldBBB]", caller.calls[0].args["fieldIds"])
|
||||
}
|
||||
if caller.calls[0].args["autoSyncSetting"] != `{"syncType":"scheduled","scheduleType":"daily","timeValue":"09:00"}` {
|
||||
t.Fatalf("autoSyncSetting not passed as raw string: %v", caller.calls[0].args["autoSyncSetting"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceCreateRejectsInvalidAutoSyncSetting(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "create",
|
||||
"--base-id", "BASE123", "--datasource-type", "OA",
|
||||
"--source-config", `{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}`,
|
||||
"--auto-sync-setting", `not-json`)
|
||||
if err == nil || !strings.Contains(err.Error(), "auto-sync-setting") {
|
||||
t.Fatalf("error = %v, want auto-sync-setting validation error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceUpdateRejectsMissingTableID(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "update", "--base-id", "BASE123")
|
||||
if err == nil || !strings.Contains(err.Error(), "table-id") {
|
||||
t.Fatalf("error = %v, want table-id required", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceUpdateRejectsInvalidSourceConfig(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "update",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456",
|
||||
"--source-config", `not-json`)
|
||||
if err == nil || !strings.Contains(err.Error(), "source-config") {
|
||||
t.Fatalf("error = %v, want source-config validation error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceUpdateRejectsNonObjectSourceConfig(t *testing.T) {
|
||||
cases := []string{`[]`, `"text"`, `1`, `true`, `null`}
|
||||
for _, raw := range cases {
|
||||
_, err := runAitableDatasourceCommand(t, "update",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456",
|
||||
"--source-config", raw)
|
||||
if err == nil || !strings.Contains(err.Error(), "source-config") {
|
||||
t.Fatalf("source-config %q: error = %v, want source-config validation error", raw, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceUpdateRejectsNoChanges(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "update",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456")
|
||||
if err == nil || !strings.Contains(err.Error(), "至少需要一个配置变更") {
|
||||
t.Fatalf("error = %v, want at least one config change required", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("MCP should not be called when no changes provided")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceUpdateWithAutoOnly(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "update",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456", "--auto")
|
||||
if err != nil {
|
||||
t.Fatalf("update with --auto only should succeed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].tool != "update_datasource_config" {
|
||||
t.Fatalf("unexpected calls: %#v", caller.calls)
|
||||
}
|
||||
if v, ok := caller.calls[0].args["auto"]; !ok || v != true {
|
||||
t.Fatalf("auto = %v, want true", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceUpdateWithSourceConfig(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "update",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456",
|
||||
"--source-config", `{"processCode":"PROC-YYYY","name":"出差申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}`)
|
||||
if err != nil {
|
||||
t.Fatalf("update with source-config should succeed: %v", err)
|
||||
}
|
||||
if caller.calls[0].args["sourceConfig"] != `{"processCode":"PROC-YYYY","name":"出差申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}` {
|
||||
t.Fatalf("sourceConfig not passed as raw string: %v", caller.calls[0].args["sourceConfig"])
|
||||
}
|
||||
if _, ok := caller.calls[0].args["auto"]; ok {
|
||||
t.Fatalf("auto should not be sent when --auto is omitted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceUpdateWithAutoFalse(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "update",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456", "--auto=false")
|
||||
if err != nil {
|
||||
t.Fatalf("update with --auto=false should succeed: %v", err)
|
||||
}
|
||||
if v, ok := caller.calls[0].args["auto"]; !ok || v != false {
|
||||
t.Fatalf("auto = %v, want false", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceUpdateWithFieldIDsAndAutoSyncSetting(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "update",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456",
|
||||
"--field-ids", "fldAAA,fldBBB",
|
||||
"--auto-sync-setting", `{"syncType":"scheduled","scheduleType":"daily","timeValue":"09:00"}`)
|
||||
if err != nil {
|
||||
t.Fatalf("update with field-ids and auto-sync-setting should succeed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].tool != "update_datasource_config" {
|
||||
t.Fatalf("unexpected calls: %#v", caller.calls)
|
||||
}
|
||||
fieldIDs, ok := caller.calls[0].args["fieldIds"].([]string)
|
||||
if !ok || len(fieldIDs) != 2 || fieldIDs[0] != "fldAAA" || fieldIDs[1] != "fldBBB" {
|
||||
t.Fatalf("fieldIds = %v, want [fldAAA fldBBB]", caller.calls[0].args["fieldIds"])
|
||||
}
|
||||
if caller.calls[0].args["autoSyncSetting"] != `{"syncType":"scheduled","scheduleType":"daily","timeValue":"09:00"}` {
|
||||
t.Fatalf("autoSyncSetting not passed as raw string: %v", caller.calls[0].args["autoSyncSetting"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceUpdateRejectsInvalidAutoSyncSetting(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "update",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456",
|
||||
"--auto-sync-setting", `not-json`)
|
||||
if err == nil || !strings.Contains(err.Error(), "auto-sync-setting") {
|
||||
t.Fatalf("error = %v, want auto-sync-setting validation error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceUpdateRejectsNonObjectAutoSyncSetting(t *testing.T) {
|
||||
cases := []string{`[]`, `"text"`, `1`, `true`, `null`}
|
||||
for _, raw := range cases {
|
||||
_, err := runAitableDatasourceCommand(t, "update",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456",
|
||||
"--auto-sync-setting", raw)
|
||||
if err == nil || !strings.Contains(err.Error(), "auto-sync-setting") {
|
||||
t.Fatalf("auto-sync-setting %q: error = %v, want auto-sync-setting validation error", raw, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceUpdateRejectsEmptyExplicitFieldIDs(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "update",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456",
|
||||
"--field-ids", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "field-ids") {
|
||||
t.Fatalf("error = %v, want field-ids empty error", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("MCP should not be called when empty field-ids is rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceUpdateRejectsEmptyExplicitAutoSyncSetting(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "update",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456",
|
||||
"--auto-sync-setting", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "auto-sync-setting") {
|
||||
t.Fatalf("error = %v, want auto-sync-setting empty error", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("MCP should not be called when empty auto-sync-setting is rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceGetFieldsRejectsMissingBaseID(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "get-fields",
|
||||
"--datasource-type", "OA",
|
||||
"--source-config", `{"processCode":"P","name":"N","dataType":"recent_time","iconUrl":"u","url":"v"}`)
|
||||
if err == nil || !strings.Contains(err.Error(), "base-id") {
|
||||
t.Fatalf("error = %v, want base-id required", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceCreateRejectsMissingBaseID(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "create",
|
||||
"--datasource-type", "OA",
|
||||
"--source-config", `{"processCode":"P","name":"N","dataType":"recent_time","iconUrl":"u","url":"v"}`)
|
||||
if err == nil || !strings.Contains(err.Error(), "base-id") {
|
||||
t.Fatalf("error = %v, want base-id required", err)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"runtime"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -39,7 +41,16 @@ func TestMain(m *testing.M) {
|
||||
if os.Getenv(helpersShellStubEnv) == "1" {
|
||||
os.Exit(runHelpersShellStub())
|
||||
}
|
||||
// Cobra's Windows pre-exec hook walks the process table on every Execute*
|
||||
// call to detect Explorer launches. Helpers tests execute command trees
|
||||
// thousands of times, and none of those in-process invocations can be an
|
||||
// Explorer launch, so keep that production-only check out of the test
|
||||
// process. This also prevents every new exhaustive harness from having to
|
||||
// remember a test-local override.
|
||||
originalMousetrapHelpText := cobra.MousetrapHelpText
|
||||
cobra.MousetrapHelpText = ""
|
||||
code := m.Run()
|
||||
cobra.MousetrapHelpText = originalMousetrapHelpText
|
||||
if helpersShellStubBaseDir != "" {
|
||||
_ = os.RemoveAll(helpersShellStubBaseDir)
|
||||
}
|
||||
|
||||
@@ -504,3 +504,147 @@ func TestSheetConfirmationGuardCoversEveryProtectedLeaf(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// executeGuardedPermissionRemoveCommand mirrors executeGuardedMutationCommand
|
||||
// but additionally rewrites os.Args so resolveProductID routes doc/wiki
|
||||
// auto-routed tools to their real MCP server (doc → doc, wiki → wiki).
|
||||
func executeGuardedPermissionRemoveCommand(t *testing.T, caller *guardedMutationCaller, build func() *cobra.Command, args ...string) error {
|
||||
t.Helper()
|
||||
testseam.Protect(t, &os.Args)
|
||||
root := build()
|
||||
os.Args = append([]string{"dws", root.Name()}, args...)
|
||||
previousDeps := deps
|
||||
t.Cleanup(func() { deps = previousDeps })
|
||||
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
if root.PersistentFlags().Lookup("yes") == nil {
|
||||
root.PersistentFlags().Bool("yes", false, "confirm high-risk operation")
|
||||
}
|
||||
if root.PersistentFlags().Lookup("dry-run") == nil {
|
||||
root.PersistentFlags().Bool("dry-run", false, "preview without executing")
|
||||
}
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
if root.InOrStdin() == os.Stdin {
|
||||
root.SetIn(strings.NewReader(""))
|
||||
}
|
||||
root.SetArgs(args)
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
// TestPermissionMemberRemoveRequiresConfirmationBeforeToolCall pins the
|
||||
// destructive confirmation gate on the three batch remove entry points
|
||||
// (drive/doc permission remove, wiki member remove). Removing up to 30
|
||||
// USER/DEPT/CONVERSATION/TAG members in one call can revoke access for whole
|
||||
// departments, chats, or role groups, so every invocation — new --members
|
||||
// format and legacy --users alike — must confirm first: without --yes the
|
||||
// command fails with the typed confirmation_required error and performs zero
|
||||
// MCP calls; with --yes it dispatches exactly one call with the complete,
|
||||
// precise tool arguments; --dry-run previews without any call.
|
||||
func TestPermissionMemberRemoveRequiresConfirmationBeforeToolCall(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
build func() *cobra.Command
|
||||
args []string
|
||||
product string
|
||||
tool string
|
||||
wantArgs map[string]any
|
||||
}{
|
||||
{
|
||||
name: "drive permission remove --members",
|
||||
build: newDriveCommand,
|
||||
args: []string{"permission", "remove", "--node", "n1", "--members", `[{"type":"CONVERSATION","id":"cid1"},{"type":"TAG","id":"t1","corpId":"c1"}]`},
|
||||
product: "doc",
|
||||
tool: "remove_permission",
|
||||
wantArgs: map[string]any{
|
||||
"nodeId": "n1",
|
||||
"members": []map[string]any{
|
||||
{"type": "CONVERSATION", "id": "cid1"},
|
||||
{"type": "TAG", "id": "t1", "corpId": "c1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "drive permission remove --users",
|
||||
build: newDriveCommand,
|
||||
args: []string{"permission", "remove", "--node", "n1", "--users", "uid1,uid2"},
|
||||
product: "doc",
|
||||
tool: "remove_permission",
|
||||
wantArgs: map[string]any{
|
||||
"nodeId": "n1",
|
||||
"userIds": []string{"uid1", "uid2"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "doc permission remove --members",
|
||||
build: newDocCommand,
|
||||
args: []string{"permission", "remove", "--node", "n1", "--members", `[{"type":"USER","id":"u1","corpId":"c1"}]`},
|
||||
product: "doc",
|
||||
tool: "remove_permission",
|
||||
wantArgs: map[string]any{
|
||||
"nodeId": "n1",
|
||||
"members": []map[string]any{{"type": "USER", "id": "u1", "corpId": "c1"}},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "wiki member remove --members",
|
||||
build: newWikiCommand,
|
||||
args: []string{"member", "remove", "--workspace", "ws1", "--members", `[{"type":"DEPT","id":"d1","corpId":"c1"}]`},
|
||||
product: "wiki",
|
||||
tool: "remove_member",
|
||||
wantArgs: map[string]any{
|
||||
"workspaceId": "ws1",
|
||||
"members": []map[string]any{{"type": "DEPT", "id": "d1", "corpId": "c1"}},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "wiki member remove --users",
|
||||
build: newWikiCommand,
|
||||
args: []string{"member", "remove", "--workspace", "ws1", "--users", "uid1"},
|
||||
product: "wiki",
|
||||
tool: "remove_member",
|
||||
wantArgs: map[string]any{
|
||||
"workspaceId": "ws1",
|
||||
"userIds": []string{"uid1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// 1) 未确认:typed confirmation_required 错误 + 零 MCP 调用。
|
||||
caller := &guardedMutationCaller{}
|
||||
err := executeGuardedPermissionRemoveCommand(t, caller, tc.build, tc.args...)
|
||||
requireTypedConfirmationError(t, err)
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("tool calls before confirmation = %#v, want none", caller.calls)
|
||||
}
|
||||
|
||||
// 2) 明确确认(--yes):恰好一次调用,参数完整且精确。
|
||||
confirmed := &guardedMutationCaller{}
|
||||
if err := executeGuardedPermissionRemoveCommand(t, confirmed, tc.build, append(append([]string(nil), tc.args...), "--yes")...); err != nil {
|
||||
t.Fatalf("confirmed remove returned error: %v", err)
|
||||
}
|
||||
if len(confirmed.calls) != 1 {
|
||||
t.Fatalf("tool calls = %d, want exactly 1: %+v", len(confirmed.calls), confirmed.calls)
|
||||
}
|
||||
call := confirmed.calls[0]
|
||||
if call.productID != tc.product || call.toolName != tc.tool {
|
||||
t.Fatalf("tool call = %s/%s, want %s/%s", call.productID, call.toolName, tc.product, tc.tool)
|
||||
}
|
||||
if !reflect.DeepEqual(call.args, tc.wantArgs) {
|
||||
t.Fatalf("tool args = %#v, want %#v", call.args, tc.wantArgs)
|
||||
}
|
||||
|
||||
// 3) --dry-run 预览:不触发任何 MCP 调用。
|
||||
dryRun := &guardedMutationCaller{dryRun: true}
|
||||
if err := executeGuardedPermissionRemoveCommand(t, dryRun, tc.build, append(append([]string(nil), tc.args...), "--dry-run")...); err != nil {
|
||||
t.Fatalf("dry-run remove returned error: %v", err)
|
||||
}
|
||||
if len(dryRun.calls) != 0 {
|
||||
t.Fatalf("dry-run tool calls = %#v, want none", dryRun.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+225
-49
@@ -3556,11 +3556,20 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
|
||||
permissionAddCmd := &cobra.Command{
|
||||
Use: "add",
|
||||
Short: "添加文档协作者",
|
||||
Args: cobra.NoArgs,
|
||||
Long: `为指定文档(或文件夹/文件)添加一个或多个协作成员,并授予指定角色。
|
||||
|
||||
通过 --user 传入逗号分隔的 userId 列表,多个用户将被授予同一角色。
|
||||
两种传参方式(互斥):
|
||||
旧格式:--users 传入逗号分隔的 userId 列表 + --role 指定统一角色(仅 USER 类型)
|
||||
新格式:--members 传入 JSON 数组,支持四种成员类型,每个 member 携带独立 roleId
|
||||
|
||||
支持的角色 (--role)(必须大写):
|
||||
成员类型说明:
|
||||
USER 用户,id 为用户 userId,需携带 corpId(标识用户所属组织)
|
||||
DEPT 部门,id 为部门 ID,需携带 corpId(标识部门所属组织)
|
||||
CONVERSATION 群聊,id 为群聊 conversationId(cid 开头),无需 corpId
|
||||
TAG 角色标签(也称角色组),id 为角色标签 ID,需携带 corpId。当用户要求"添加角色组"或"添加角色标签"时使用此类型
|
||||
|
||||
支持的角色(大小写不敏感):
|
||||
MANAGER 管理员,可读写、管理成员
|
||||
EDITOR 编辑者,可查看、编辑、上传内容
|
||||
DOWNLOADER 查看下载者,可查看并下载内容
|
||||
@@ -3568,29 +3577,46 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
|
||||
|
||||
注意:
|
||||
- OWNER 角色不可通过此接口添加。
|
||||
- 操作者须满足该节点配置的权限管理最低角色要求(默认 MANAGER,可配置为 EDITOR 等),权限不足返回 forbidden.accessDenied。
|
||||
- 单次请求最多 30 个成员,超出请分批调用。
|
||||
- --notify 仅在 --members 新格式时生效,仅对 USER 和 CONVERSATION 类型成员发送通知(DEPT 和 TAG 不通知),默认 false;省略时 CLI 不向服务端发送该字段,服务端按不通知处理,需要通知请显式传 --notify。
|
||||
|
||||
用户 uid 可通过「钉钉通讯录」相关命令检索,如:
|
||||
dws contact user search --keyword "姓名"`,
|
||||
Example: ` dws doc permission add --node DOC_ID --users uid1 --role READER
|
||||
dws doc permission add --node DOC_ID --users uid1,uid2,uid3 --role EDITOR
|
||||
dws doc permission add --node "https://alidocs.dingtalk.com/i/nodes/xxx" --users uid1 --role MANAGER --workspace WS_ID`,
|
||||
dws doc permission add --node "https://alidocs.dingtalk.com/i/nodes/xxx" --users uid1 --role MANAGER --workspace WS_ID
|
||||
dws doc permission add --node DOC_ID --members '[{"type":"USER","id":"uid1","roleId":"READER","corpId":"xxx"},{"type":"DEPT","id":"deptId1","roleId":"EDITOR","corpId":"xxx"}]' --notify
|
||||
dws doc permission add --node DOC_ID --members '[{"type":"CONVERSATION","id":"cidXXX","roleId":"READER"},{"type":"TAG","id":"tagId1","roleId":"EDITOR","corpId":"xxx"}]'`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRequiredFlags(cmd, "role"); err != nil {
|
||||
if err := validateMembersExclusivity(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
userIds, err := collectUserIDs(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
toolArgs := map[string]any{"nodeId": nodeID}
|
||||
members, mErr := collectMembers(cmd, false)
|
||||
if mErr != nil {
|
||||
return mErr
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"roleId": normalizePermissionRole(mustGetFlag(cmd, "role")),
|
||||
"userIds": userIds,
|
||||
if len(members) > 0 {
|
||||
toolArgs["members"] = members
|
||||
if cmd.Flags().Changed("notify") {
|
||||
notify, _ := cmd.Flags().GetBool("notify")
|
||||
toolArgs["notify"] = notify
|
||||
}
|
||||
} else {
|
||||
if err := validateRequiredFlags(cmd, "role"); err != nil {
|
||||
return err
|
||||
}
|
||||
userIds, err := collectUserIDs(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["roleId"] = normalizePermissionRole(mustGetFlag(cmd, "role"))
|
||||
toolArgs["userIds"] = userIds
|
||||
}
|
||||
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
|
||||
toolArgs["workspaceId"] = v
|
||||
@@ -3627,7 +3653,9 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
|
||||
Examples: []string{"dws doc permission add --node <DOC_ID> --users uid1 --role READER --format json"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "members", Property: "members"},
|
||||
{Name: "node", Property: "nodeId"},
|
||||
{Name: "notify", Property: "notify"},
|
||||
{Name: "role", Property: "roleId"},
|
||||
{Name: "users", Property: "userIds"},
|
||||
{Name: "workspace", Property: "workspaceId"},
|
||||
@@ -3636,18 +3664,31 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
|
||||
})
|
||||
|
||||
permissionAddCmd.Flags().String("node", "", "目标节点的标识(文档/文件夹/文件),支持传入 URL 或 ID (必填)")
|
||||
permissionAddCmd.Flags().String("users", "", "被授权的用户 userId 列表,逗号分隔 (必填,单次最多 30 个)")
|
||||
permissionAddCmd.Flags().String("users", "", "被授权的用户 userId 列表,逗号分隔 (旧格式,单次最多 30 个)")
|
||||
permissionAddCmd.Flags().String("user", "", "")
|
||||
_ = permissionAddCmd.Flags().MarkHidden("user")
|
||||
permissionAddCmd.Flags().String("role", "", "权限角色: MANAGER / EDITOR / DOWNLOADER / READER (必填,大小写不敏感)")
|
||||
permissionAddCmd.Flags().String("role", "", "权限角色: MANAGER / EDITOR / DOWNLOADER / READER (旧格式必填,大小写不敏感)")
|
||||
permissionAddCmd.Flags().String("workspace", "", "目标知识库 ID 或 URL(选填,仅用于辅助构造返回的 docUrl)")
|
||||
permissionAddCmd.Flags().String("members", "", "成员列表 JSON 数组(新格式),支持 USER/DEPT/CONVERSATION/TAG 类型(TAG=角色组),与 --users 互斥")
|
||||
permissionAddCmd.Flags().Bool("notify", false, "是否通知被添加的成员(仅 --members 新格式时生效,需显式传入才通知)")
|
||||
|
||||
permissionUpdateCmd := &cobra.Command{
|
||||
Use: "update",
|
||||
Short: "更新文档协作者权限",
|
||||
Long: `更新指定节点已有协作者的权限角色(仅支持 USER 类型成员)。
|
||||
Args: cobra.NoArgs,
|
||||
Long: `更新指定节点已有协作者的权限角色。
|
||||
|
||||
支持的角色 (--role)(必须大写):
|
||||
两种传参方式(互斥):
|
||||
旧格式:--users 传入逗号分隔的 userId 列表 + --role 指定统一角色(仅 USER 类型)
|
||||
新格式:--members 传入 JSON 数组,支持四种成员类型,每个 member 携带独立 roleId
|
||||
|
||||
成员类型说明:
|
||||
USER 用户,id 为用户 userId,需携带 corpId
|
||||
DEPT 部门,id 为部门 ID,需携带 corpId
|
||||
CONVERSATION 群聊,id 为群聊 conversationId(cid 开头),无需 corpId
|
||||
TAG 角色标签(也称角色组),id 为角色标签 ID,需携带 corpId
|
||||
|
||||
支持的角色 (--role)(大小写不敏感):
|
||||
MANAGER 管理员
|
||||
EDITOR 编辑者
|
||||
DOWNLOADER 查看下载者
|
||||
@@ -3657,26 +3698,43 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
|
||||
- OWNER 角色不可通过此接口变更。
|
||||
- 同一成员在同一节点只能拥有一个角色,变更后旧角色自动替换。
|
||||
- 若成员的角色来自父节点的权限继承(PASS_ON),且继承角色高于目标角色,接口会拒绝操作。
|
||||
- 操作者须满足该节点配置的权限管理最低角色要求(默认 MANAGER,可配置为 EDITOR 等),权限不足返回 forbidden.accessDenied。
|
||||
- --notify 仅在 --members 新格式时生效,仅对 USER 和 CONVERSATION 类型成员发送通知,默认 false。
|
||||
|
||||
仅可更新已存在协作关系的用户,新增协作者请使用 dws doc permission add。`,
|
||||
Example: ` dws doc permission update --node DOC_ID --users uid1 --role EDITOR
|
||||
dws doc permission update --node DOC_ID --users uid1,uid2 --role READER`,
|
||||
dws doc permission update --node DOC_ID --users uid1,uid2 --role READER
|
||||
dws doc permission update --node DOC_ID --members '[{"type":"USER","id":"uid1","roleId":"EDITOR","corpId":"xxx"}]' --notify=false
|
||||
dws doc permission update --node DOC_ID --members '[{"type":"TAG","id":"tagId1","roleId":"READER","corpId":"xxx"}]'`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRequiredFlags(cmd, "role"); err != nil {
|
||||
if err := validateMembersExclusivity(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
userIds, err := collectUserIDs(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
toolArgs := map[string]any{"nodeId": nodeID}
|
||||
members, mErr := collectMembers(cmd, false)
|
||||
if mErr != nil {
|
||||
return mErr
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"roleId": normalizePermissionRole(mustGetFlag(cmd, "role")),
|
||||
"userIds": userIds,
|
||||
if len(members) > 0 {
|
||||
toolArgs["members"] = members
|
||||
if cmd.Flags().Changed("notify") {
|
||||
notify, _ := cmd.Flags().GetBool("notify")
|
||||
toolArgs["notify"] = notify
|
||||
}
|
||||
} else {
|
||||
if err := validateRequiredFlags(cmd, "role"); err != nil {
|
||||
return err
|
||||
}
|
||||
userIds, err := collectUserIDs(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["roleId"] = normalizePermissionRole(mustGetFlag(cmd, "role"))
|
||||
toolArgs["userIds"] = userIds
|
||||
}
|
||||
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
|
||||
toolArgs["workspaceId"] = v
|
||||
@@ -3710,7 +3768,9 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
|
||||
Examples: []string{"dws doc permission update --node <DOC_ID> --users uid1 --role EDITOR --format json"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "members", Property: "members"},
|
||||
{Name: "node", Property: "nodeId"},
|
||||
{Name: "notify", Property: "notify"},
|
||||
{Name: "role", Property: "roleId"},
|
||||
{Name: "users", Property: "userIds"},
|
||||
{Name: "workspace", Property: "workspaceId"},
|
||||
@@ -3719,11 +3779,13 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
|
||||
})
|
||||
|
||||
permissionUpdateCmd.Flags().String("node", "", "目标节点的标识(文档/文件夹/文件),支持传入 URL 或 ID (必填)")
|
||||
permissionUpdateCmd.Flags().String("users", "", "被更新的用户 userId 列表,逗号分隔 (必填,单次最多 30 个)")
|
||||
permissionUpdateCmd.Flags().String("users", "", "被更新的用户 userId 列表,逗号分隔 (旧格式,单次最多 30 个)")
|
||||
permissionUpdateCmd.Flags().String("user", "", "")
|
||||
_ = permissionUpdateCmd.Flags().MarkHidden("user")
|
||||
permissionUpdateCmd.Flags().String("role", "", "新权限角色: MANAGER / EDITOR / DOWNLOADER / READER (必填,大小写不敏感)")
|
||||
permissionUpdateCmd.Flags().String("role", "", "新权限角色: MANAGER / EDITOR / DOWNLOADER / READER (旧格式必填,大小写不敏感)")
|
||||
permissionUpdateCmd.Flags().String("workspace", "", "目标知识库 ID 或 URL(选填,仅用于辅助构造返回的 docUrl)")
|
||||
permissionUpdateCmd.Flags().String("members", "", "成员列表 JSON 数组(新格式),支持 USER/DEPT/CONVERSATION/TAG 类型(TAG=角色组),与 --users 互斥")
|
||||
permissionUpdateCmd.Flags().Bool("notify", false, "是否通知被变更的成员(仅 --members 新格式时生效)")
|
||||
|
||||
permissionListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
@@ -3731,11 +3793,14 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
|
||||
Short: "查询文档协作者列表",
|
||||
Long: `查询指定节点的协作者列表,返回每位成员的 userId、姓名、角色等信息。
|
||||
|
||||
注意:底层不支持游标分页,--limit 仅控制单次返回的最大条数(最大 200)。
|
||||
若结果被截断(出参 truncated=true),可通过 --filter-role 收窄查询范围。`,
|
||||
底层一次性返回全量成员后在内存中按 pageSize 分页,支持通过 nextToken 翻页。
|
||||
出参包含 totalCount(全量成员总数)、hasMore(是否还有下一页)和 nextToken(下一页游标)。
|
||||
当 hasMore 为 true 时,传入下一次请求的 --next-token 即可获取下一页。
|
||||
操作者需满足该节点配置的权限管理最低角色要求,权限不足返回 forbidden.accessDenied。`,
|
||||
Example: ` dws doc permission list --node DOC_ID
|
||||
dws doc permission list --node DOC_ID --limit 100
|
||||
dws doc permission list --node DOC_ID --filter-role MANAGER,EDITOR`,
|
||||
dws doc permission list --node DOC_ID --limit 50
|
||||
dws doc permission list --node DOC_ID --filter-role MANAGER,EDITOR
|
||||
dws doc permission list --node DOC_ID --next-token <上次返回的 nextToken>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
@@ -3744,14 +3809,13 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
|
||||
toolArgs := map[string]any{
|
||||
"nodeId": nodeID,
|
||||
}
|
||||
limit := 0
|
||||
if cmd.Flags().Changed("limit") {
|
||||
limit, _ = cmd.Flags().GetInt("limit")
|
||||
} else if cmd.Flags().Changed("max-results") {
|
||||
limit, _ = cmd.Flags().GetInt("max-results")
|
||||
if size, ok, err := permissionPageSizeFromFlags(cmd); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
toolArgs["pageSize"] = size
|
||||
}
|
||||
if limit > 0 {
|
||||
toolArgs["maxResults"] = limit
|
||||
if v := flagOrFallback(cmd, "next-token", "cursor", "page-token"); v != "" {
|
||||
toolArgs["nextToken"] = v
|
||||
}
|
||||
if v := mustGetFlag(cmd, "filter-role"); v != "" {
|
||||
toolArgs["filterRoleIds"] = parseRoleList(v)
|
||||
@@ -3789,50 +3853,75 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "filter-role", Property: "filterRoleIds"},
|
||||
{Name: "limit", Property: "maxResults"},
|
||||
// limit 不声明 Property:运行时经 cap 校验(1-50)转换为 pageSize,
|
||||
// 属 CLI 分页输入而非 1:1 RPC property(reviewed mapping exclusion)。
|
||||
{Name: "limit"},
|
||||
{Name: "next-token", Property: "nextToken"},
|
||||
{Name: "node", Property: "nodeId"},
|
||||
{Name: "workspace", Property: "workspaceId"},
|
||||
},
|
||||
Pagination: &contract.PaginationSpec{Kind: contract.PaginationKindCursor, CursorParameter: "next-token"},
|
||||
},
|
||||
})
|
||||
|
||||
permissionListCmd.Flags().String("node", "", "目标节点的标识(文档/文件夹/文件),支持传入 URL 或 ID (必填)")
|
||||
permissionListCmd.Flags().Int("limit", 30, "返回成员数上限,默认 30,最大 200")
|
||||
permissionListCmd.Flags().Int("limit", 30, "返回成员数上限,默认 30,最大 50")
|
||||
permissionListCmd.Flags().Int("max-results", 0, "")
|
||||
_ = permissionListCmd.Flags().MarkHidden("max-results")
|
||||
permissionListCmd.Flags().String("filter-role", "", "按角色过滤(逗号分隔):OWNER / MANAGER / EDITOR / DOWNLOADER / READER")
|
||||
permissionListCmd.Flags().String("next-token", "", "分页游标,首次不传,后续传入上一次返回的 nextToken")
|
||||
permissionListCmd.Flags().String("workspace", "", "目标知识库 ID 或 URL(选填,仅用于辅助构造返回的 docUrl)")
|
||||
|
||||
permissionRemoveCmd := &cobra.Command{
|
||||
Use: "remove",
|
||||
Aliases: []string{"rm"},
|
||||
Short: "移除文档协作者权限",
|
||||
Long: `从指定节点移除一个或多个协作成员的权限(仅支持 USER 类型)。
|
||||
Long: `从指定节点移除一个或多个协作成员的权限。
|
||||
|
||||
两种传参方式(互斥):
|
||||
旧格式:--users 传入逗号分隔的 userId 列表(仅 USER 类型)
|
||||
新格式:--members 传入 JSON 数组,支持四种成员类型,只需 type 和 id(USER/DEPT/TAG 还需 corpId)
|
||||
|
||||
成员类型说明:
|
||||
USER 用户,id 为用户 userId,需携带 corpId
|
||||
DEPT 部门,id 为部门 ID,需携带 corpId
|
||||
CONVERSATION 群聊,id 为群聊 conversationId(cid 开头),无需 corpId
|
||||
TAG 角色标签(也称角色组),id 为角色标签 ID,需携带 corpId
|
||||
|
||||
移除后相关用户将无法通过该节点的直接授权访问内容(若有父节点继承权限则仍可通过继承权限访问)。
|
||||
|
||||
注意:
|
||||
- OWNER 角色不可通过此接口移除。
|
||||
- 操作者需在该节点具备 EDITOR 及以上角色(OWNER / MANAGER / EDITOR)。
|
||||
- 操作者须满足该节点配置的权限管理最低角色要求(默认 MANAGER,可配置为 EDITOR 等),权限不足返回 forbidden.accessDenied。
|
||||
- 单次请求最多 30 个成员,超出请分批调用。
|
||||
|
||||
用户 uid 可通过「钉钉通讯录」相关命令检索,如:
|
||||
dws contact user search --keyword "姓名"`,
|
||||
Example: ` dws doc permission remove --node DOC_ID --users uid1
|
||||
dws doc permission remove --node DOC_ID --users uid1,uid2,uid3
|
||||
dws doc permission remove --node "https://alidocs.dingtalk.com/i/nodes/xxx" --users uid1`,
|
||||
dws doc permission remove --node "https://alidocs.dingtalk.com/i/nodes/xxx" --users uid1
|
||||
dws doc permission remove --node DOC_ID --members '[{"type":"USER","id":"uid1","corpId":"xxx"},{"type":"DEPT","id":"deptId1","corpId":"xxx"}]'`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
userIds, err := collectUserIDs(cmd)
|
||||
if err != nil {
|
||||
if err := validateMembersExclusivity(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"userIds": userIds,
|
||||
toolArgs := map[string]any{"nodeId": nodeID}
|
||||
members, mErr := collectMembers(cmd, true)
|
||||
if mErr != nil {
|
||||
return mErr
|
||||
}
|
||||
if len(members) > 0 {
|
||||
toolArgs["members"] = members
|
||||
} else {
|
||||
userIds, err := collectUserIDs(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["userIds"] = userIds
|
||||
}
|
||||
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
|
||||
toolArgs["workspaceId"] = v
|
||||
@@ -3842,8 +3931,11 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
|
||||
}
|
||||
DeclareLeafMetadata(permissionRemoveCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
// 批量移除(最多 30 个 USER/DEPT/CONVERSATION/TAG)会一次性撤销多个
|
||||
// 成员的访问,部门/群聊/角色组还可能间接影响大量用户,与删除同级的
|
||||
// destructive 入口,必须经过用户确认(--yes 或交互 yes)。
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "not_required", Idempotency: "unknown",
|
||||
Confirmation: "user_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
@@ -3866,6 +3958,7 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
|
||||
Examples: []string{"dws doc permission remove --node <DOC_ID> --users uid1 --format json"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "members", Property: "members"},
|
||||
{Name: "node", Property: "nodeId"},
|
||||
{Name: "users", Property: "userIds"},
|
||||
{Name: "workspace", Property: "workspaceId"},
|
||||
@@ -3873,9 +3966,10 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
|
||||
},
|
||||
})
|
||||
permissionRemoveCmd.Flags().String("node", "", "目标节点的标识(文档/文件夹/文件),支持传入 URL 或 ID (必填)")
|
||||
permissionRemoveCmd.Flags().String("users", "", "被移除权限的用户 userId 列表,逗号分隔 (必填,单次最多 30 个)")
|
||||
permissionRemoveCmd.Flags().String("users", "", "被移除权限的用户 userId 列表,逗号分隔 (旧格式,单次最多 30 个)")
|
||||
permissionRemoveCmd.Flags().String("user", "", "")
|
||||
_ = permissionRemoveCmd.Flags().MarkHidden("user")
|
||||
permissionRemoveCmd.Flags().String("members", "", "成员列表 JSON 数组(新格式),只需 type 和 id(USER/DEPT/TAG 还需 corpId),与 --users 互斥")
|
||||
permissionRemoveCmd.Flags().String("workspace", "", "目标知识库 ID 或 URL(选填,仅用于辅助构造返回的 docUrl)")
|
||||
|
||||
// permission 子命令的 --node 隐藏别名
|
||||
@@ -5199,3 +5293,85 @@ func collectUserIDs(cmd *cobra.Command) ([]string, error) {
|
||||
}
|
||||
return userIds, nil
|
||||
}
|
||||
|
||||
// collectMembers parses the --members JSON array flag (new format), returning
|
||||
// a members list ready to embed in MCP tool args. Supports USER/DEPT/CONVERSATION/TAG
|
||||
// member types, each carrying an independent roleId.
|
||||
//
|
||||
// When onlyTypeID is true (remove operations), roleId is not required —
|
||||
// only type and id are needed.
|
||||
func collectMembers(cmd *cobra.Command, onlyTypeID bool) ([]map[string]any, error) {
|
||||
raw := mustGetFlag(cmd, "members")
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var members []map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &members); err != nil {
|
||||
return nil, fmt.Errorf("--members JSON 解析失败: %w", err)
|
||||
}
|
||||
if len(members) == 0 {
|
||||
return nil, fmt.Errorf("--members 不能为空数组")
|
||||
}
|
||||
if len(members) > 30 {
|
||||
return nil, fmt.Errorf("--members 单次最多 30 个成员,超出请分批调用")
|
||||
}
|
||||
for i, m := range members {
|
||||
mt, ok := m["type"].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("--members[%d] 缺少必填字段 type", i)
|
||||
}
|
||||
if _, ok := m["id"].(string); !ok {
|
||||
return nil, fmt.Errorf("--members[%d] 缺少必填字段 id", i)
|
||||
}
|
||||
// USER/DEPT/TAG 类型需携带 corpId 用于确定成员所属组织,CONVERSATION 类型选填
|
||||
if (mt == "USER" || mt == "DEPT" || mt == "TAG") && m["corpId"] == nil {
|
||||
return nil, fmt.Errorf("--members[%d] 类型 %s 需携带 corpId 以确定所属组织", i, mt)
|
||||
}
|
||||
if !onlyTypeID {
|
||||
if _, ok := m["roleId"].(string); !ok {
|
||||
return nil, fmt.Errorf("--members[%d] 缺少必填字段 roleId", i)
|
||||
}
|
||||
if r, ok := m["roleId"].(string); ok {
|
||||
m["roleId"] = normalizePermissionRole(r)
|
||||
}
|
||||
}
|
||||
}
|
||||
return members, nil
|
||||
}
|
||||
|
||||
// validateMembersExclusivity ensures --members (new format) and --users (legacy
|
||||
// format) are not used simultaneously. Exactly one must be provided.
|
||||
func validateMembersExclusivity(cmd *cobra.Command) error {
|
||||
hasMembers := mustGetFlag(cmd, "members") != ""
|
||||
hasUsers := flagOrFallback(cmd, "users", "user") != ""
|
||||
if hasMembers && hasUsers {
|
||||
return fmt.Errorf("--members 与 --users 互斥,不可同时传递")
|
||||
}
|
||||
if !hasMembers && !hasUsers {
|
||||
return fmt.Errorf("必须指定 --members(新格式)或 --users(旧格式)之一")
|
||||
}
|
||||
if hasMembers && mustGetFlag(cmd, "role") != "" {
|
||||
return fmt.Errorf("--members 新格式下不需要 --role,每个 member 携带独立 roleId")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// permissionPageSizeFromFlags resolves and validates the page size for the
|
||||
// permission / member list commands. Both --limit and the hidden
|
||||
// --max-results alias map to the server pageSize, whose accepted range is
|
||||
// 1..50 (the backend rejects pageSize > 50 with
|
||||
// invalidRequest.inputArgs.invalid, and non-positive values are invalid).
|
||||
// It returns (size, true, nil) when a page size was explicitly provided.
|
||||
func permissionPageSizeFromFlags(cmd *cobra.Command) (int, bool, error) {
|
||||
for _, name := range []string{"limit", "max-results"} {
|
||||
if !cmd.Flags().Changed(name) {
|
||||
continue
|
||||
}
|
||||
size, _ := cmd.Flags().GetInt(name)
|
||||
if size < 1 || size > 50 {
|
||||
return 0, false, fmt.Errorf("--%s 取值范围为 1..50(服务端 pageSize 上限 50),当前值 %d", name, size)
|
||||
}
|
||||
return size, true, nil
|
||||
}
|
||||
return 0, false, nil
|
||||
}
|
||||
|
||||
+141
-44
@@ -2042,27 +2042,55 @@ func newDriveCommand() *cobra.Command {
|
||||
drivePermAddCmd := &cobra.Command{
|
||||
Use: "add",
|
||||
Short: "添加协作者",
|
||||
Args: cobra.NoArgs,
|
||||
Long: `为文档空间节点添加协作成员并授予指定角色。
|
||||
|
||||
支持的角色 (--role): MANAGER / EDITOR / DOWNLOADER / READER`,
|
||||
两种传参方式(互斥):
|
||||
旧格式:--users 传入逗号分隔的 userId 列表 + --role 指定统一角色(仅 USER 类型)
|
||||
新格式:--members 传入 JSON 数组,支持四种成员类型,每个 member 携带独立 roleId
|
||||
|
||||
成员类型说明:
|
||||
USER 用户,id 为用户 userId,需携带 corpId(标识用户所属组织)
|
||||
DEPT 部门,id 为部门 ID,需携带 corpId(标识部门所属组织)
|
||||
CONVERSATION 群聊,id 为群聊 conversationId(cid 开头),无需 corpId
|
||||
TAG 角色标签(也称角色组),id 为角色标签 ID,需携带 corpId。当用户要求"添加角色组"或"添加角色标签"时使用此类型
|
||||
|
||||
支持的角色: MANAGER / EDITOR / DOWNLOADER / READER
|
||||
--notify 仅在 --members 新格式时生效,仅对 USER 和 CONVERSATION 类型成员发送通知(DEPT 和 TAG 不通知),默认 false。
|
||||
省略 --notify 时 CLI 不向服务端发送该字段,服务端按不通知处理;需要通知请显式传 --notify。`,
|
||||
Example: ` dws drive permission add --node DOC_ID --users uid1 --role READER
|
||||
dws drive permission add --node DOC_ID --users uid1,uid2 --role EDITOR`,
|
||||
dws drive permission add --node DOC_ID --users uid1,uid2 --role EDITOR
|
||||
dws drive permission add --node DOC_ID --members '[{"type":"USER","id":"uid1","roleId":"READER","corpId":"xxx"}]' --notify
|
||||
dws drive permission add --node DOC_ID --members '[{"type":"CONVERSATION","id":"cidXXX","roleId":"READER"},{"type":"TAG","id":"tagId1","roleId":"EDITOR","corpId":"xxx"}]'`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRequiredFlags(cmd, "role"); err != nil {
|
||||
if err := validateMembersExclusivity(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
userIds, err := collectUserIDs(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
toolArgs := map[string]any{"nodeId": nodeID}
|
||||
members, mErr := collectMembers(cmd, false)
|
||||
if mErr != nil {
|
||||
return mErr
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"roleId": normalizePermissionRole(mustGetFlag(cmd, "role")),
|
||||
"userIds": userIds,
|
||||
if len(members) > 0 {
|
||||
toolArgs["members"] = members
|
||||
if cmd.Flags().Changed("notify") {
|
||||
notify, _ := cmd.Flags().GetBool("notify")
|
||||
toolArgs["notify"] = notify
|
||||
}
|
||||
} else {
|
||||
if err := validateRequiredFlags(cmd, "role"); err != nil {
|
||||
return err
|
||||
}
|
||||
userIds, err := collectUserIDs(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["roleId"] = normalizePermissionRole(mustGetFlag(cmd, "role"))
|
||||
toolArgs["userIds"] = userIds
|
||||
}
|
||||
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
|
||||
toolArgs["workspaceId"] = v
|
||||
@@ -2103,7 +2131,9 @@ func newDriveCommand() *cobra.Command {
|
||||
Examples: []string{"dws drive permission add --node <ID> --users uid1,uid2 --role READER --format json"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "members", Property: "members"},
|
||||
{Name: "node", Property: "nodeId"},
|
||||
{Name: "notify", Property: "notify"},
|
||||
{Name: "role", Property: "roleId"},
|
||||
{Name: "users", Property: "userIds"},
|
||||
{Name: "workspace", Property: "workspaceId"},
|
||||
@@ -2111,35 +2141,64 @@ func newDriveCommand() *cobra.Command {
|
||||
},
|
||||
})
|
||||
drivePermAddCmd.Flags().String("node", "", "目标节点 ID 或 URL (必填)")
|
||||
drivePermAddCmd.Flags().String("users", "", "用户 userId 列表,逗号分隔 (必填)")
|
||||
drivePermAddCmd.Flags().String("users", "", "用户 userId 列表,逗号分隔 (旧格式)")
|
||||
drivePermAddCmd.Flags().String("user", "", "")
|
||||
_ = drivePermAddCmd.Flags().MarkHidden("user")
|
||||
drivePermAddCmd.Flags().String("role", "", "角色: MANAGER / EDITOR / DOWNLOADER / READER (必填)")
|
||||
drivePermAddCmd.Flags().String("role", "", "角色: MANAGER / EDITOR / DOWNLOADER / READER (旧格式必填)")
|
||||
drivePermAddCmd.Flags().String("workspace", "", "知识库 ID (选填)")
|
||||
drivePermAddCmd.Flags().String("members", "", "成员列表 JSON 数组(新格式),支持 USER/DEPT/CONVERSATION/TAG 类型(TAG=角色组),与 --users 互斥")
|
||||
drivePermAddCmd.Flags().Bool("notify", false, "是否通知被添加的成员(仅 --members 新格式时生效,需显式传入才通知)")
|
||||
|
||||
drivePermUpdateCmd := &cobra.Command{
|
||||
Use: "update",
|
||||
Short: "更新协作者权限",
|
||||
Args: cobra.NoArgs,
|
||||
Long: `更新文档空间节点已有协作者的权限角色。
|
||||
|
||||
支持的角色 (--role): MANAGER / EDITOR / DOWNLOADER / READER`,
|
||||
Example: ` dws drive permission update --node DOC_ID --users uid1 --role EDITOR`,
|
||||
两种传参方式(互斥):
|
||||
旧格式:--users 传入逗号分隔的 userId 列表 + --role 指定统一角色(仅 USER 类型)
|
||||
新格式:--members 传入 JSON 数组,支持四种成员类型,每个 member 携带独立 roleId
|
||||
|
||||
成员类型说明:
|
||||
USER 用户,id 为用户 userId,需携带 corpId
|
||||
DEPT 部门,id 为部门 ID,需携带 corpId
|
||||
CONVERSATION 群聊,id 为群聊 conversationId(cid 开头),无需 corpId
|
||||
TAG 角色标签(也称角色组),id 为角色标签 ID,需携带 corpId
|
||||
|
||||
支持的角色: MANAGER / EDITOR / DOWNLOADER / READER
|
||||
--notify 仅在 --members 新格式时生效,仅对 USER 和 CONVERSATION 类型成员发送通知,默认 false。`,
|
||||
Example: ` dws drive permission update --node DOC_ID --users uid1 --role EDITOR
|
||||
dws drive permission update --node DOC_ID --members '[{"type":"USER","id":"uid1","roleId":"EDITOR","corpId":"xxx"}]' --notify=false
|
||||
dws drive permission update --node DOC_ID --members '[{"type":"TAG","id":"tagId1","roleId":"READER","corpId":"xxx"}]'`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRequiredFlags(cmd, "role"); err != nil {
|
||||
if err := validateMembersExclusivity(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
userIds, err := collectUserIDs(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
toolArgs := map[string]any{"nodeId": nodeID}
|
||||
members, mErr := collectMembers(cmd, false)
|
||||
if mErr != nil {
|
||||
return mErr
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"roleId": normalizePermissionRole(mustGetFlag(cmd, "role")),
|
||||
"userIds": userIds,
|
||||
if len(members) > 0 {
|
||||
toolArgs["members"] = members
|
||||
if cmd.Flags().Changed("notify") {
|
||||
notify, _ := cmd.Flags().GetBool("notify")
|
||||
toolArgs["notify"] = notify
|
||||
}
|
||||
} else {
|
||||
if err := validateRequiredFlags(cmd, "role"); err != nil {
|
||||
return err
|
||||
}
|
||||
userIds, err := collectUserIDs(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["roleId"] = normalizePermissionRole(mustGetFlag(cmd, "role"))
|
||||
toolArgs["userIds"] = userIds
|
||||
}
|
||||
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
|
||||
toolArgs["workspaceId"] = v
|
||||
@@ -2176,7 +2235,9 @@ func newDriveCommand() *cobra.Command {
|
||||
Examples: []string{"dws drive permission update --node <ID> --users uid1 --role EDITOR --format json"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "members", Property: "members"},
|
||||
{Name: "node", Property: "nodeId"},
|
||||
{Name: "notify", Property: "notify"},
|
||||
{Name: "role", Property: "roleId"},
|
||||
{Name: "users", Property: "userIds"},
|
||||
{Name: "workspace", Property: "workspaceId"},
|
||||
@@ -2184,33 +2245,39 @@ func newDriveCommand() *cobra.Command {
|
||||
},
|
||||
})
|
||||
drivePermUpdateCmd.Flags().String("node", "", "目标节点 ID 或 URL (必填)")
|
||||
drivePermUpdateCmd.Flags().String("users", "", "用户 userId 列表,逗号分隔 (必填)")
|
||||
drivePermUpdateCmd.Flags().String("users", "", "用户 userId 列表,逗号分隔 (旧格式)")
|
||||
drivePermUpdateCmd.Flags().String("user", "", "")
|
||||
_ = drivePermUpdateCmd.Flags().MarkHidden("user")
|
||||
drivePermUpdateCmd.Flags().String("role", "", "新角色: MANAGER / EDITOR / DOWNLOADER / READER (必填)")
|
||||
drivePermUpdateCmd.Flags().String("role", "", "新角色: MANAGER / EDITOR / DOWNLOADER / READER (旧格式必填)")
|
||||
drivePermUpdateCmd.Flags().String("workspace", "", "知识库 ID (选填)")
|
||||
drivePermUpdateCmd.Flags().String("members", "", "成员列表 JSON 数组(新格式),支持 USER/DEPT/CONVERSATION/TAG 类型(TAG=角色组),与 --users 互斥")
|
||||
drivePermUpdateCmd.Flags().Bool("notify", false, "是否通知被变更的成员(仅 --members 新格式时生效)")
|
||||
|
||||
drivePermListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Aliases: []string{"ls"},
|
||||
Short: "查询协作者列表",
|
||||
Long: `查询文档空间节点的协作者列表。`,
|
||||
Long: `查询文档空间节点的协作者列表,支持分页和角色过滤。
|
||||
|
||||
底层一次性返回全量成员后在内存中按 pageSize 分页,支持通过 nextToken 翻页。
|
||||
出参包含 totalCount、hasMore 和 nextToken。
|
||||
当 hasMore 为 true 时,传入下一次请求的 --next-token 即可获取下一页。`,
|
||||
Example: ` dws drive permission list --node DOC_ID
|
||||
dws drive permission list --node DOC_ID --limit 100 --filter-role MANAGER,EDITOR`,
|
||||
dws drive permission list --node DOC_ID --limit 50 --filter-role MANAGER,EDITOR
|
||||
dws drive permission list --node DOC_ID --next-token <上次返回的 nextToken>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{"nodeId": nodeID}
|
||||
limit := 0
|
||||
if cmd.Flags().Changed("limit") {
|
||||
limit, _ = cmd.Flags().GetInt("limit")
|
||||
} else if cmd.Flags().Changed("max-results") {
|
||||
limit, _ = cmd.Flags().GetInt("max-results")
|
||||
if size, ok, err := permissionPageSizeFromFlags(cmd); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
toolArgs["pageSize"] = size
|
||||
}
|
||||
if limit > 0 {
|
||||
toolArgs["maxResults"] = limit
|
||||
if v := flagOrFallback(cmd, "next-token", "cursor", "page-token"); v != "" {
|
||||
toolArgs["nextToken"] = v
|
||||
}
|
||||
if v := mustGetFlag(cmd, "filter-role"); v != "" {
|
||||
toolArgs["filterRoleIds"] = parseRoleList(v)
|
||||
@@ -2251,38 +2318,63 @@ func newDriveCommand() *cobra.Command {
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "filter-role", Property: "filterRoleIds"},
|
||||
{Name: "limit", Property: "maxResults"},
|
||||
// limit 不声明 Property:运行时经 cap 校验(1-50)转换为 pageSize,
|
||||
// 属 CLI 分页输入而非 1:1 RPC property(reviewed mapping exclusion)。
|
||||
{Name: "limit"},
|
||||
{Name: "next-token", Property: "nextToken"},
|
||||
{Name: "node", Property: "nodeId"},
|
||||
{Name: "workspace", Property: "workspaceId"},
|
||||
},
|
||||
Pagination: &contract.PaginationSpec{Kind: contract.PaginationKindCursor, CursorParameter: "next-token"},
|
||||
},
|
||||
})
|
||||
drivePermListCmd.Flags().String("node", "", "目标节点 ID 或 URL (必填)")
|
||||
drivePermListCmd.Flags().Int("limit", 30, "返回成员数上限,默认 30,最大 200")
|
||||
drivePermListCmd.Flags().Int("limit", 30, "返回成员数上限,默认 30,最大 50")
|
||||
drivePermListCmd.Flags().Int("max-results", 0, "")
|
||||
_ = drivePermListCmd.Flags().MarkHidden("max-results")
|
||||
drivePermListCmd.Flags().String("filter-role", "", "按角色过滤: OWNER / MANAGER / EDITOR / DOWNLOADER / READER")
|
||||
drivePermListCmd.Flags().String("next-token", "", "分页游标,首次不传,后续传入上一次返回的 nextToken")
|
||||
drivePermListCmd.Flags().String("workspace", "", "知识库 ID (选填)")
|
||||
|
||||
drivePermRemoveCmd := &cobra.Command{
|
||||
Use: "remove",
|
||||
Aliases: []string{"rm"},
|
||||
Short: "移除协作者权限",
|
||||
Long: `从文档空间节点移除协作成员的权限。`,
|
||||
Long: `从文档空间节点移除协作成员的权限。
|
||||
|
||||
两种传参方式(互斥):
|
||||
旧格式:--users 传入逗号分隔的 userId 列表(仅 USER 类型)
|
||||
新格式:--members 传入 JSON 数组,支持四种成员类型,只需 type 和 id(USER/DEPT/TAG 还需 corpId)
|
||||
|
||||
成员类型说明:
|
||||
USER 用户,id 为用户 userId,需携带 corpId
|
||||
DEPT 部门,id 为部门 ID,需携带 corpId
|
||||
CONVERSATION 群聊,id 为群聊 conversationId(cid 开头),无需 corpId
|
||||
TAG 角色标签(也称角色组),id 为角色标签 ID,需携带 corpId`,
|
||||
Example: ` dws drive permission remove --node DOC_ID --users uid1
|
||||
dws drive permission remove --node DOC_ID --users uid1,uid2`,
|
||||
dws drive permission remove --node DOC_ID --users uid1,uid2
|
||||
dws drive permission remove --node DOC_ID --members '[{"type":"USER","id":"uid1","corpId":"xxx"}]'`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
userIds, err := collectUserIDs(cmd)
|
||||
if err != nil {
|
||||
if err := validateMembersExclusivity(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"userIds": userIds,
|
||||
toolArgs := map[string]any{"nodeId": nodeID}
|
||||
members, mErr := collectMembers(cmd, true)
|
||||
if mErr != nil {
|
||||
return mErr
|
||||
}
|
||||
if len(members) > 0 {
|
||||
toolArgs["members"] = members
|
||||
} else {
|
||||
userIds, err := collectUserIDs(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["userIds"] = userIds
|
||||
}
|
||||
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
|
||||
toolArgs["workspaceId"] = v
|
||||
@@ -2292,8 +2384,11 @@ func newDriveCommand() *cobra.Command {
|
||||
}
|
||||
DeclareLeafMetadata(drivePermRemoveCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
// 批量移除(最多 30 个 USER/DEPT/CONVERSATION/TAG)会一次性撤销多个
|
||||
// 成员的访问,部门/群聊/角色组还可能间接影响大量用户,与删除同级的
|
||||
// destructive 入口,必须经过用户确认(--yes 或交互 yes)。
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "not_required", Idempotency: "unknown",
|
||||
Confirmation: "user_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
@@ -2319,6 +2414,7 @@ func newDriveCommand() *cobra.Command {
|
||||
Examples: []string{"dws drive permission remove --node <ID> --users uid1 --format json"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "members", Property: "members"},
|
||||
{Name: "node", Property: "nodeId"},
|
||||
{Name: "users", Property: "userIds"},
|
||||
{Name: "workspace", Property: "workspaceId"},
|
||||
@@ -2326,9 +2422,10 @@ func newDriveCommand() *cobra.Command {
|
||||
},
|
||||
})
|
||||
drivePermRemoveCmd.Flags().String("node", "", "目标节点 ID 或 URL (必填)")
|
||||
drivePermRemoveCmd.Flags().String("users", "", "用户 userId 列表,逗号分隔 (必填)")
|
||||
drivePermRemoveCmd.Flags().String("users", "", "用户 userId 列表,逗号分隔 (旧格式)")
|
||||
drivePermRemoveCmd.Flags().String("user", "", "")
|
||||
_ = drivePermRemoveCmd.Flags().MarkHidden("user")
|
||||
drivePermRemoveCmd.Flags().String("members", "", "成员列表 JSON 数组(新格式),只需 type 和 id(USER/DEPT/TAG 还需 corpId),与 --users 互斥")
|
||||
drivePermRemoveCmd.Flags().String("workspace", "", "知识库 ID (选填)")
|
||||
|
||||
// permission 子命令 --node 隐藏别名(保持与迁移前 doc 命令一致)
|
||||
|
||||
+145
-2
@@ -4,6 +4,8 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -134,6 +136,13 @@ func WrapErrorWithOperation(err error, operation string) error {
|
||||
if _, ok := err.(*PATError); ok {
|
||||
return err
|
||||
}
|
||||
// 框架确认门禁错误(deferred ConfirmSafety 从 CallTool 返回)必须原样透传:
|
||||
// “加 --yes 重试”的语义只能由 reason=confirmation_required 表达,文本分类会
|
||||
// 误路由(commandPath 含 "permission" 的命令会被判成 AUTH_PERMISSION_DENIED,
|
||||
// 其余则丢失 reason 退化为 UNCLASSIFIED)。
|
||||
if apperrors.IsConfirmationRequired(err) {
|
||||
return err
|
||||
}
|
||||
msg := err.Error()
|
||||
|
||||
// File lock timeout (cross-process lock)
|
||||
@@ -254,6 +263,24 @@ func WrapErrorWithOperation(err error, operation string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// 服务端返回的用户/成员参数校验错误(如“用户不存在”、“不属于当前组织”),
|
||||
// 文本含“不存在”但并非文档资源缺失,需在 RESOURCE_NOT_FOUND 分类之前拦截,
|
||||
// 透传服务端原始错误信息。
|
||||
if errContainsAny(msg, "用户不存在", "不属于当前组织", "成员不存在") {
|
||||
errMsg := msg
|
||||
var body map[string]any
|
||||
if json.Unmarshal([]byte(msg), &body) == nil {
|
||||
errMsg = businessErrorDisplayMessage(body, msg)
|
||||
}
|
||||
return &CLIError{
|
||||
Code: CodeMCPToolError,
|
||||
Message: errMsg,
|
||||
Suggestion: "请检查用户 ID 或组织信息是否正确,跨组织用户需通过 --members 格式携带 corpId",
|
||||
Operation: operation,
|
||||
Cause: err,
|
||||
}
|
||||
}
|
||||
|
||||
// Resource not found
|
||||
if errContainsAny(msg, "not found", "不存在", "资源不存在") {
|
||||
suggestion := "Check if the resource exists or if your account has permission"
|
||||
@@ -379,21 +406,98 @@ func suggestForBusinessErrorText(text string) string {
|
||||
return "请确认邮箱地址正确,查看可用邮箱: dws mail mailbox list"
|
||||
case strings.Contains(text, "频率超限") || strings.Contains(text, "rate limit"):
|
||||
return "API rate limit exceeded, wait a moment and retry"
|
||||
case strings.Contains(text, "用户不存在") || strings.Contains(text, "不属于当前组织") ||
|
||||
strings.Contains(text, "成员不存在"):
|
||||
return "请检查用户 ID 或组织信息是否正确,跨组织用户需通过 --members 格式携带 corpId"
|
||||
case strings.Contains(text, "未找到指定工具") || strings.Contains(text, "MCP不存在"):
|
||||
return "后端工具未注册或已下线;这不是参数格式问题。请升级到包含该工具注册的后端/静态端点版本,或改用当前可用替代命令。"
|
||||
case strings.Contains(text, "参数错误") || strings.Contains(text, "param error"):
|
||||
return "Check input parameters. Use --help for available flags"
|
||||
case strings.Contains(text, "无权限访问") || strings.Contains(text, "没有访问权限") ||
|
||||
strings.Contains(text, "没有权限") || strings.Contains(text, "权限不足") ||
|
||||
strings.Contains(text, "no permission") || strings.Contains(text, "permission denied") ||
|
||||
strings.Contains(text, "FORBIDDEN"):
|
||||
return permissionDeniedSuggestion(text)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// documentPermissionServerCodes are permission-denied codes that are
|
||||
// demonstrably drive-specific (their identity carries the forbidden.* domain
|
||||
// prefix). Only these (plus the document/wiki role-threshold message wording)
|
||||
// justify the dws drive permission apply-* guidance. Generic code names such
|
||||
// as NO_PERMISSION and FORBIDDEN are deliberately excluded: attendance
|
||||
// (get-self-setting) and event-subscription tools have been observed returning
|
||||
// NO_PERMISSION, so keying guidance on it would mislead non-document products
|
||||
// exactly the way this classification tries to avoid.
|
||||
var documentPermissionServerCodes = map[string]bool{
|
||||
"forbidden.no.auth": true,
|
||||
"forbidden.accessDenied": true,
|
||||
}
|
||||
|
||||
// documentPermissionErrorText reports whether a permission-denied message is
|
||||
// specific to document/wiki permission (drive/doc/wiki), e.g. the role
|
||||
// threshold wording emitted by the member-management APIs or the document
|
||||
// permission error codes embedded in the message text.
|
||||
func documentPermissionErrorText(text string) bool {
|
||||
if text == "" {
|
||||
return false
|
||||
}
|
||||
lower := strings.ToLower(text)
|
||||
return strings.Contains(lower, "forbidden.no.auth") ||
|
||||
strings.Contains(lower, "forbidden.accessdenied") ||
|
||||
strings.Contains(text, "需要您具备") || strings.Contains(text, "及以上角色")
|
||||
}
|
||||
|
||||
// isDocumentPermissionError reports whether a permission-denied body is
|
||||
// specific to document/wiki access, where applying for access via
|
||||
// dws drive permission apply-* is the actionable next step.
|
||||
func isDocumentPermissionError(body map[string]any) bool {
|
||||
for _, key := range []string{"code", "errorCode", "server_error_code"} {
|
||||
if code, ok := body[key].(string); ok && documentPermissionServerCodes[code] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return documentPermissionErrorText(businessErrorMessage(body))
|
||||
}
|
||||
|
||||
// genericPermissionSuggestion is the product-neutral suggestion for
|
||||
// permission-denied errors that are not specific to document/wiki access.
|
||||
const genericPermissionSuggestion = "Verify your account has permission for this resource"
|
||||
|
||||
// permissionDeniedSuggestion picks between document apply-permission guidance
|
||||
// and a product-neutral hint: only document/wiki-specific errors point at
|
||||
// dws drive permission apply-*, so other products never get a misleading
|
||||
// document-permission suggestion.
|
||||
func permissionDeniedSuggestion(text string) string {
|
||||
if documentPermissionErrorText(text) {
|
||||
return permissionApplyGuidance
|
||||
}
|
||||
return genericPermissionSuggestion
|
||||
}
|
||||
|
||||
// permissionDeniedSuggestionFor resolves the suggestion for a permission-denied
|
||||
// body: document/wiki-specific errors get the apply-permission guidance; other
|
||||
// products keep their product-specific suggestion (e.g. the mail mailbox hint)
|
||||
// or fall back to the product-neutral hint.
|
||||
func permissionDeniedSuggestionFor(body map[string]any) string {
|
||||
if isDocumentPermissionError(body) {
|
||||
return permissionApplyGuidance
|
||||
}
|
||||
if suggestion := suggestForBusinessError(body); suggestion != "" {
|
||||
return suggestion
|
||||
}
|
||||
return genericPermissionSuggestion
|
||||
}
|
||||
|
||||
// ClassifyToolResultContent checks a raw MCP tool result content map for
|
||||
// DWS gateway auth errors and PAT permission error codes. This is used as the
|
||||
// edition.Hooks.ClassifyToolResult callback so the framework's runner returns
|
||||
// a typed error before its generic business-error classification.
|
||||
//
|
||||
// Check order matches ClassifyMCPResponseText: DWS gateway > PAT permission.
|
||||
// Check order matches ClassifyMCPResponseText: DWS gateway > PAT permission >
|
||||
// no-permission guidance.
|
||||
func ClassifyToolResultContent(content map[string]any) error {
|
||||
if _, ok := getDWSGatewayErrorCode(content); ok {
|
||||
raw, _ := json.Marshal(content)
|
||||
@@ -408,6 +512,20 @@ func ClassifyToolResultContent(content map[string]any) error {
|
||||
return &PATError{RawJSON: cleanPATJSON(content, code)}
|
||||
}
|
||||
}
|
||||
|
||||
// Permission-denied business errors: surface apply-permission guidance before
|
||||
// the framework's generic rendering swallows it. Guidance is limited to
|
||||
// document/wiki-specific signals (forbidden.* codes or role-threshold
|
||||
// wording); other products keep the product-neutral suggestion.
|
||||
if isNoPermissionError(content) {
|
||||
suggestion := permissionDeniedSuggestionFor(content)
|
||||
raw, _ := json.Marshal(content)
|
||||
return &CLIError{
|
||||
Code: CodeAuthPermission,
|
||||
Message: businessErrorDisplayMessage(content, string(raw)),
|
||||
Suggestion: suggestion,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -419,6 +537,23 @@ var patNoPermissionCodes = map[string]bool{
|
||||
"PAT_HIGH_RISK_NO_PERMISSION": true,
|
||||
}
|
||||
|
||||
// noPermissionServerCodes are business-level permission-denied codes (from the
|
||||
// MCP server / gateway) that should surface apply-permission guidance instead of
|
||||
// being swallowed by the framework's generic business-error rendering.
|
||||
var noPermissionServerCodes = map[string]bool{
|
||||
"NO_PERMISSION": true,
|
||||
"FORBIDDEN": true,
|
||||
"forbidden.no.auth": true,
|
||||
"forbidden.accessDenied": true,
|
||||
}
|
||||
|
||||
// permissionApplySteps lists the two-step commands to apply for document access.
|
||||
const permissionApplySteps = " 1) 查可申请角色与审批人: dws drive permission apply-info --node <节点ID或URL>\n" +
|
||||
" 2) 选定角色与审批人后发起申请: dws drive permission apply --node <节点ID或URL> --role <EDITOR|DOWNLOADER|READER> --users <审批人userId>"
|
||||
|
||||
// permissionApplyGuidance is the standard suggestion for permission-denied errors.
|
||||
const permissionApplyGuidance = "若你对该文档/知识库暂无访问权限,可发起权限申请:\n" + permissionApplySteps
|
||||
|
||||
// ClassifyMCPResponseText classifies a text response returned by an MCP tool call.
|
||||
// Returns a typed error for known gateway auth failures, PAT interceptions,
|
||||
// and business-level errors embedded in HTTP-200 JSON bodies.
|
||||
@@ -452,10 +587,18 @@ func ClassifyMCPResponseText(text string) error {
|
||||
}
|
||||
}
|
||||
|
||||
if isNoPermissionError(body) {
|
||||
return &CLIError{
|
||||
Code: CodeAuthPermission,
|
||||
Message: text,
|
||||
Suggestion: permissionDeniedSuggestionFor(body),
|
||||
}
|
||||
}
|
||||
|
||||
if isBusinessError(body) {
|
||||
return &CLIError{
|
||||
Code: CodeMCPToolError,
|
||||
Message: text,
|
||||
Message: businessErrorDisplayMessage(body, text),
|
||||
Suggestion: suggestForBusinessError(body),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,10 +123,19 @@ func TestCrossPlatformCoverageBusinessSuggestionsAndResponseClassification(t *te
|
||||
"User has no permission to access this email": "mailbox list",
|
||||
"频率超限": "rate limit",
|
||||
"rate limit": "rate limit",
|
||||
"用户不存在": "--members",
|
||||
"不属于当前组织": "--members",
|
||||
"成员不存在": "--members",
|
||||
"未找到指定工具": "未注册",
|
||||
"MCP不存在": "未注册",
|
||||
"参数错误": "input parameters",
|
||||
"param error": "input parameters",
|
||||
// 非文档类权限文本不再给出 drive permission apply 指引。
|
||||
"无权限访问": "Verify your account",
|
||||
"权限不足": "Verify your account",
|
||||
// 权限关键词叠加文档特征(文档权限码/角色门槛)时保留 apply 指引。
|
||||
"您没有权限访问该文档 (forbidden.no.auth)": "权限申请",
|
||||
"无权限访问:需要您具备 MANAGER 及以上角色": "权限申请",
|
||||
}
|
||||
for input, want := range suggestions {
|
||||
if got := suggestForBusinessErrorText(input); !strings.Contains(got, want) {
|
||||
@@ -152,17 +161,76 @@ func TestCrossPlatformCoverageBusinessSuggestionsAndResponseClassification(t *te
|
||||
t.Errorf("PAT key %s classified as %#v", key, err)
|
||||
}
|
||||
}
|
||||
// forbidden.accessDenied(新接口权限不足错误码)应分类为 AUTH_PERMISSION_DENIED
|
||||
// 并携带权限申请指引,而不是被 generic business-error 渲染吞掉。
|
||||
for _, key := range []string{"code", "errorCode", "server_error_code"} {
|
||||
err := ClassifyToolResultContent(map[string]any{key: "forbidden.accessDenied", "success": false, "errorMsg": "需要您具备 MANAGER 及以上角色"})
|
||||
cli, ok := err.(*CLIError)
|
||||
if !ok || cli.Code != CodeAuthPermission {
|
||||
t.Errorf("accessDenied key %s classified as %#v, want AUTH_PERMISSION_DENIED", key, err)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(cli.Message, "需要您具备") || !strings.Contains(cli.Suggestion, "permission apply") {
|
||||
t.Errorf("accessDenied guidance missing: message=%q suggestion=%q", cli.Message, cli.Suggestion)
|
||||
}
|
||||
}
|
||||
// 带 forbidden.* 域名的权限码是 drive 专属,单独出现即可拿到 apply 指引。
|
||||
for _, code := range []string{"forbidden.no.auth"} {
|
||||
err := ClassifyToolResultContent(map[string]any{"code": code, "success": false, "errorMsg": "no permission"})
|
||||
cli, ok := err.(*CLIError)
|
||||
if !ok || cli.Code != CodeAuthPermission || !strings.Contains(cli.Suggestion, "permission apply") {
|
||||
t.Errorf("document code %s classified as %#v, want AUTH_PERMISSION_DENIED with apply guidance", code, err)
|
||||
}
|
||||
}
|
||||
// 通用码名 NO_PERMISSION 不再单独触发文档 apply 指引(回归测试):
|
||||
// 考勤 get-self-setting、事件订阅等非文档工具也会返回该码。
|
||||
nonDoc := ClassifyToolResultContent(map[string]any{"code": "NO_PERMISSION", "success": false, "errorMsg": "no permission"})
|
||||
if cli, ok := nonDoc.(*CLIError); !ok || cli.Code != CodeAuthPermission ||
|
||||
!strings.Contains(cli.Suggestion, "Verify your account") || strings.Contains(cli.Suggestion, "permission apply") {
|
||||
t.Errorf("NO_PERMISSION classified as %#v, want product-neutral suggestion without apply guidance", nonDoc)
|
||||
}
|
||||
// NO_PERMISSION 叠加文档特征文本(角色门槛)时仍保留 apply 指引。
|
||||
docCombo := ClassifyToolResultContent(map[string]any{"code": "NO_PERMISSION", "success": false, "errorMsg": "需要您具备 MANAGER 及以上角色"})
|
||||
if cli, ok := docCombo.(*CLIError); !ok || cli.Code != CodeAuthPermission || !strings.Contains(cli.Suggestion, "permission apply") {
|
||||
t.Errorf("NO_PERMISSION with document wording classified as %#v, want apply guidance", docCombo)
|
||||
}
|
||||
// 非文档产品的权限错误保留产品专属建议(邮件)或通用提示,
|
||||
// 不得被改写为 drive 文档权限申请指引。
|
||||
mail := ClassifyToolResultContent(map[string]any{"success": false, "errorMsg": "User has no permission to access this email"})
|
||||
if cli, ok := mail.(*CLIError); !ok || cli.Code != CodeAuthPermission ||
|
||||
!strings.Contains(cli.Suggestion, "mailbox list") || strings.Contains(cli.Suggestion, "permission apply") {
|
||||
t.Errorf("mail permission error classified as %#v, want mailbox hint without apply guidance", mail)
|
||||
}
|
||||
generic := ClassifyToolResultContent(map[string]any{"success": false, "code": "FORBIDDEN", "errorMsg": "群权限不足"})
|
||||
if cli, ok := generic.(*CLIError); !ok || cli.Code != CodeAuthPermission ||
|
||||
!strings.Contains(cli.Suggestion, "Verify your account") || strings.Contains(cli.Suggestion, "permission apply") {
|
||||
t.Errorf("generic permission error classified as %#v, want product-neutral suggestion", generic)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
text string
|
||||
kind string
|
||||
text string
|
||||
kind string
|
||||
suggHas string
|
||||
}{
|
||||
{"not-json", "nil"},
|
||||
{`{"errorCode":"USER_TOKEN_ILLEGAL"}`, "cli"},
|
||||
{`{"error":"Missing service_id or access_key"}`, "cli"},
|
||||
{`{"code":"PAT_HIGH_RISK_NO_PERMISSION","extra":{"class":"x","keep":1}}`, "pat"},
|
||||
{`{"success":false,"errorMsg":"rate limit"}`, "cli"},
|
||||
{`{"success":true}`, "nil"},
|
||||
{"not-json", "nil", ""},
|
||||
{"null", "nil", ""},
|
||||
{`{"errorCode":"USER_TOKEN_ILLEGAL"}`, "cli", ""},
|
||||
{`{"error":"Missing service_id or access_key"}`, "cli", ""},
|
||||
{`{"code":"PAT_HIGH_RISK_NO_PERMISSION","extra":{"class":"x","keep":1}}`, "pat", ""},
|
||||
{`{"success":false,"errorMsg":"rate limit"}`, "cli", ""},
|
||||
{`{"success":false,"code":"forbidden.accessDenied","errorMsg":"需要您具备 MANAGER 及以上角色","logId":"abc123"}`, "authperm", "permission apply"},
|
||||
// 非文档产品的权限错误:分类为 AUTH_PERMISSION_DENIED,但建议保持
|
||||
// 产品专属(邮件 mailbox)或通用提示,不带文档 apply 指引。
|
||||
{`{"success":false,"errorMsg":"User has no permission to access this email"}`, "authperm", "mailbox list"},
|
||||
{`{"success":false,"code":"FORBIDDEN","errorMsg":"群权限不足"}`, "authperm", "Verify your account"},
|
||||
{`{"success":false,"code":"FORBIDDEN"}`, "authperm", "Verify your account"},
|
||||
{`{"success":false,"code":"NO_PERMISSION","errorMsg":"no permission"}`, "authperm", "Verify your account"},
|
||||
// 文档/知识库角色门槛文案与文档权限码文本:apply 指引保留。
|
||||
{`{"success":false,"errorMsg":"需要您具备 MANAGER 及以上角色"}`, "authperm", "permission apply"},
|
||||
{`{"success":false,"errorMsg":"forbidden.no.auth: 你无权访问该文档"}`, "authperm", "permission apply"},
|
||||
{`{"success":false,"code":"NO_PERMISSION","errorMsg":"需要您具备 MANAGER 及以上角色"}`, "authperm", "permission apply"},
|
||||
{`{"success":false,"errorMsg":"members[0].id 对应的用户不存在"}`, "cli", ""},
|
||||
{`{"success":true}`, "nil", ""},
|
||||
} {
|
||||
err := ClassifyMCPResponseText(tc.text)
|
||||
switch tc.kind {
|
||||
@@ -174,6 +242,15 @@ func TestCrossPlatformCoverageBusinessSuggestionsAndResponseClassification(t *te
|
||||
if _, ok := err.(*CLIError); !ok {
|
||||
t.Errorf("ClassifyMCPResponseText(%s) = %#v", tc.text, err)
|
||||
}
|
||||
case "authperm":
|
||||
cli, ok := err.(*CLIError)
|
||||
if !ok || cli.Code != CodeAuthPermission {
|
||||
t.Errorf("ClassifyMCPResponseText(%s) = %#v, want AUTH_PERMISSION_DENIED", tc.text, err)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(cli.Suggestion, tc.suggHas) {
|
||||
t.Errorf("ClassifyMCPResponseText(%s) suggestion = %q, want it to contain %q", tc.text, cli.Suggestion, tc.suggHas)
|
||||
}
|
||||
case "pat":
|
||||
if _, ok := err.(*PATError); !ok {
|
||||
t.Errorf("ClassifyMCPResponseText(%s) = %#v", tc.text, err)
|
||||
@@ -235,3 +312,82 @@ func TestCrossPlatformCoveragePATCleanupAndMatchingHelpers(t *testing.T) {
|
||||
t.Fatalf("stripClassFields slice = %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageBusinessErrorMessagePriority 同步自内部 MR 28965577:
|
||||
// errorMessage 是新接口返回的用户/成员校验错误的字段,优先级介于 errorMsg 与 message 之间。
|
||||
func TestCrossPlatformCoverageBusinessErrorMessagePriority(t *testing.T) {
|
||||
if msg := businessErrorMessage(map[string]any{"errorMsg": "first", "errorMessage": "second", "message": "third", "error": "fourth"}); msg != "first" {
|
||||
t.Errorf("businessErrorMessage = %q, want %q (errorMsg has priority)", msg, "first")
|
||||
}
|
||||
want := "members[0].id 对应的用户不存在"
|
||||
if msg := businessErrorMessage(map[string]any{"errorMessage": want, "message": "third", "error": "fourth"}); msg != want {
|
||||
t.Errorf("businessErrorMessage = %q, want %q (errorMessage fallback)", msg, want)
|
||||
}
|
||||
if msg := businessErrorMessage(map[string]any{"message": "second", "error": "third"}); msg != "second" {
|
||||
t.Errorf("businessErrorMessage = %q, want %q (message fallback)", msg, "second")
|
||||
}
|
||||
if msg := businessErrorMessage(map[string]any{}); msg != "" {
|
||||
t.Errorf("businessErrorMessage(empty) = %q, want empty", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageBusinessErrorDisplayMessage 验证 code/logId 附加以便排查。
|
||||
func TestCrossPlatformCoverageBusinessErrorDisplayMessage(t *testing.T) {
|
||||
got := businessErrorDisplayMessage(map[string]any{"errorMessage": "权限不足", "logId": "abc123"}, "raw")
|
||||
if got != "权限不足 (logId: abc123)" {
|
||||
t.Errorf("businessErrorDisplayMessage = %q, want logId appended", got)
|
||||
}
|
||||
// errorCode/code 存在时附加在后,保证后端错误码可见
|
||||
got = businessErrorDisplayMessage(map[string]any{"errorMessage": "失败", "errorCode": "forbidden.document.sizeOverLimit"}, "raw")
|
||||
if got != "失败 (code: forbidden.document.sizeOverLimit)" {
|
||||
t.Errorf("businessErrorDisplayMessage = %q, want code appended", got)
|
||||
}
|
||||
got = businessErrorDisplayMessage(map[string]any{"message": "失败", "code": "forbidden.accessDenied", "logId": "l1"}, "raw")
|
||||
if got != "失败 (code: forbidden.accessDenied, logId: l1)" {
|
||||
t.Errorf("businessErrorDisplayMessage = %q, want code+logId appended", got)
|
||||
}
|
||||
// logId 已包含在 message 中时不重复追加
|
||||
got = businessErrorDisplayMessage(map[string]any{"errorMessage": "失败 logId:abc123", "logId": "abc123"}, "raw")
|
||||
if got != "失败 logId:abc123" {
|
||||
t.Errorf("businessErrorDisplayMessage = %q, want no duplicate logId", got)
|
||||
}
|
||||
// 无任何 message 字段时回退 rawText
|
||||
if got := businessErrorDisplayMessage(map[string]any{"success": false}, "raw-fallback"); got != "raw-fallback" {
|
||||
t.Errorf("businessErrorDisplayMessage = %q, want rawText fallback", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageUserNotInOrgErrors 验证「用户不存在/不属于当前组织/成员不存在」
|
||||
// 在 RESOURCE_NOT_FOUND 之前被拦截为 MCP_TOOL_ERROR 并携带 --members corpId 建议。
|
||||
func TestCrossPlatformCoverageUserNotInOrgErrors(t *testing.T) {
|
||||
suggestion := suggestForBusinessError(map[string]any{"errorMessage": "members[0].id 对应的用户不存在或不属于当前组织(实际值:209499),请检查后重试。"})
|
||||
if !strings.Contains(suggestion, "--members") {
|
||||
t.Errorf("suggestForBusinessError should mention --members for user/org error, got: %q", suggestion)
|
||||
}
|
||||
|
||||
for _, msg := range []string{
|
||||
"用户不存在",
|
||||
"不属于当前组织",
|
||||
"成员不存在",
|
||||
} {
|
||||
cli, ok := WrapErrorWithOperation(errors.New(msg), "wiki/add_member").(*CLIError)
|
||||
if !ok {
|
||||
t.Fatalf("WrapErrorWithOperation(%q) not a CLIError", msg)
|
||||
}
|
||||
if cli.Code != CodeMCPToolError {
|
||||
t.Errorf("WrapErrorWithOperation(%q).Code = %s, want %s (not RESOURCE_NOT_FOUND)", msg, cli.Code, CodeMCPToolError)
|
||||
}
|
||||
if !strings.Contains(cli.Suggestion, "corpId") {
|
||||
t.Errorf("WrapErrorWithOperation(%q).Suggestion should mention corpId, got: %q", msg, cli.Suggestion)
|
||||
}
|
||||
}
|
||||
|
||||
// JSON 体形式:透传 errorMessage 且附 logId
|
||||
cli, ok := WrapErrorWithOperation(errors.New(`{"errorMessage":"用户不存在","logId":"lid-1"}`), "wiki/add_member").(*CLIError)
|
||||
if !ok {
|
||||
t.Fatal("WrapErrorWithOperation(json) not a CLIError")
|
||||
}
|
||||
if cli.Message != "用户不存在 (logId: lid-1)" {
|
||||
t.Errorf("WrapErrorWithOperation(json).Message = %q, want errorMessage + logId", cli.Message)
|
||||
}
|
||||
}
|
||||
|
||||
+89
-10
@@ -255,7 +255,11 @@ func parseMCPToolTextResult(serverID, toolName string, result *edition.ToolResul
|
||||
}
|
||||
if isBusinessError(errBody) {
|
||||
return "", &CLIError{
|
||||
Code: CodeMCPToolError,
|
||||
Code: CodeMCPToolError,
|
||||
// 注意:这里必须保留原始响应 JSON。此路径是数据编排层
|
||||
// (如 drive list --depth 的限流重试)的输入,下游会从
|
||||
// Message 反解析 errorCode;人话提取(含 code/logId 附加)
|
||||
// 只用于 callMCPToolInternalOptsContext 的终端展示路径。
|
||||
Message: c.Text,
|
||||
Suggestion: suggestForBusinessError(errBody),
|
||||
}
|
||||
@@ -440,6 +444,20 @@ func callMCPToolInternalOptsContext(ctx context.Context, explicitServerID, toolN
|
||||
// 尝试将返回文本解析为 JSON,进行错误分类
|
||||
var errBody map[string]any
|
||||
if json.Unmarshal([]byte(c.Text), &errBody) == nil {
|
||||
// errBody == nil 表示文本为 "null",服务端返回了空响应。
|
||||
// 仅对已确认“空响应=写成功”契约的 permission/member update/remove
|
||||
// 工具适配为空对象 {},避免消费方解析 null 时出错;其它工具的
|
||||
// 合法 null 保持原样输出,不改变未版本化的机器输出契约。
|
||||
if errBody == nil {
|
||||
if nullOnSuccessTools[toolName] {
|
||||
if deps.Caller.Format() == "json" {
|
||||
return printJSON(map[string]any{})
|
||||
}
|
||||
deps.Out.PrintRaw("{}")
|
||||
return nil
|
||||
}
|
||||
return renderLegacyMCPText(toolName, c.Text, unescapeHTML)
|
||||
}
|
||||
// 网关层错误(如 token 过期)
|
||||
if _, ok := getDWSGatewayErrorCode(errBody); ok {
|
||||
return &CLIError{Code: CodeAuthTokenExpired, Message: c.Text, Suggestion: authExpiredSuggestion()}
|
||||
@@ -454,7 +472,7 @@ func callMCPToolInternalOptsContext(ctx context.Context, explicitServerID, toolN
|
||||
}
|
||||
// 业务逻辑错误
|
||||
if isBusinessError(errBody) {
|
||||
return &CLIError{Code: CodeMCPToolError, Message: c.Text, Suggestion: suggestForBusinessError(errBody)}
|
||||
return &CLIError{Code: CodeMCPToolError, Message: businessErrorDisplayMessage(errBody, c.Text), Suggestion: suggestForBusinessError(errBody)}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,6 +483,18 @@ func callMCPToolInternalOptsContext(ctx context.Context, explicitServerID, toolN
|
||||
return printJSON(result)
|
||||
}
|
||||
|
||||
// nullOnSuccessTools lists MCP tools whose server contract is confirmed to
|
||||
// return a literal JSON null for successful no-payload writes (permission /
|
||||
// member update/remove). Only these get the null→{} adaptation; every other
|
||||
// tool keeps its raw null output so the shared machine-output contract stays
|
||||
// unchanged.
|
||||
var nullOnSuccessTools = map[string]bool{
|
||||
"update_permission": true,
|
||||
"remove_permission": true,
|
||||
"update_member": true,
|
||||
"remove_member": true,
|
||||
}
|
||||
|
||||
// RenderLegacyMCPText renders an already-fetched MCP text response through the
|
||||
// exact legacy formatter. It lets dual validation execute the business request
|
||||
// once, validate a shadow unified result, and still preserve legacy bytes.
|
||||
@@ -765,15 +795,64 @@ func getDWSGatewayErrorCode(errBody map[string]any) (string, bool) {
|
||||
// suggestForBusinessError returns a user-facing suggestion for known business
|
||||
// error patterns in a parsed JSON body, or "" if no specific suggestion applies.
|
||||
func suggestForBusinessError(body map[string]any) string {
|
||||
msg := ""
|
||||
if v, ok := body["errorMsg"].(string); ok {
|
||||
msg = v
|
||||
} else if v, ok := body["message"].(string); ok {
|
||||
msg = v
|
||||
} else if v, ok := body["error"].(string); ok {
|
||||
msg = v
|
||||
return suggestForBusinessErrorText(businessErrorMessage(body))
|
||||
}
|
||||
|
||||
// businessErrorMessage extracts the human-readable message from a parsed error
|
||||
// body, checking errorMsg > errorMessage > message > error. Returns "" if none present.
|
||||
func businessErrorMessage(body map[string]any) string {
|
||||
for _, k := range []string{"errorMsg", "errorMessage", "message", "error"} {
|
||||
if v, ok := body[k].(string); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return suggestForBusinessErrorText(msg)
|
||||
return ""
|
||||
}
|
||||
|
||||
// businessErrorDisplayMessage extracts the human-readable message from a parsed
|
||||
// error body, appends the backend error code and logId if present (for
|
||||
// traceability), and falls back to rawText if no message field is found.
|
||||
func businessErrorDisplayMessage(body map[string]any, rawText string) string {
|
||||
msg := businessErrorMessage(body)
|
||||
if msg == "" {
|
||||
return rawText
|
||||
}
|
||||
var extras []string
|
||||
for _, k := range []string{"errorCode", "error_code", "code"} {
|
||||
if code, ok := body[k].(string); ok && code != "" && !strings.Contains(msg, code) {
|
||||
extras = append(extras, "code: "+code)
|
||||
break
|
||||
}
|
||||
}
|
||||
if logId, ok := body["logId"].(string); ok && logId != "" && !strings.Contains(msg, logId) {
|
||||
extras = append(extras, "logId: "+logId)
|
||||
}
|
||||
if len(extras) > 0 {
|
||||
msg = msg + " (" + strings.Join(extras, ", ") + ")"
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// isNoPermissionError reports whether a parsed error body represents a
|
||||
// permission-denied error, by known server codes or message text. Used to
|
||||
// surface apply-permission guidance before the framework's generic rendering.
|
||||
func isNoPermissionError(body map[string]any) bool {
|
||||
for _, key := range []string{"code", "errorCode", "server_error_code"} {
|
||||
if code, ok := body[key].(string); ok && noPermissionServerCodes[code] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
msg := businessErrorMessage(body)
|
||||
if msg == "" {
|
||||
return false
|
||||
}
|
||||
lower := strings.ToLower(msg)
|
||||
return strings.Contains(msg, "无权限访问") || strings.Contains(msg, "没有访问权限") ||
|
||||
strings.Contains(msg, "没有权限") || strings.Contains(msg, "权限不足") ||
|
||||
strings.Contains(lower, "no permission") || strings.Contains(lower, "permission denied") ||
|
||||
strings.Contains(lower, "forbidden.no.auth") ||
|
||||
strings.Contains(lower, "forbidden.accessdenied") ||
|
||||
strings.Contains(msg, "需要您具备") || strings.Contains(msg, "及以上角色")
|
||||
}
|
||||
|
||||
// confirmDelete is a convenience wrapper around cmdutil.ConfirmDelete that
|
||||
|
||||
@@ -0,0 +1,738 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// 同步自闭源 MR 28965577(知识库/节点权限增删改查改造):
|
||||
// 权限/成员 add/update/remove 支持 --members 新格式(USER/DEPT/CONVERSATION/TAG),
|
||||
// list 支持 nextToken 翻页。以下用例覆盖 CLI → MCP 参数装配契约。
|
||||
|
||||
func TestCollectMembersParsesNewFormat(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
if err := executePR868Command(t, newDriveCommand(), "permission", "add", "--node", "n1",
|
||||
"--members", `[{"type":"USER","id":"u1","roleId":"reader","corpId":"c1"},{"type":"TAG","id":"t1","roleId":"editor","corpId":"c1"}]`,
|
||||
"--notify=false"); err != nil {
|
||||
t.Fatalf("permission add --members: %v", err)
|
||||
}
|
||||
if caller.tool != "add_permission" {
|
||||
t.Fatalf("tool=%q", caller.tool)
|
||||
}
|
||||
members, ok := caller.args["members"].([]map[string]any)
|
||||
if !ok || len(members) != 2 {
|
||||
t.Fatalf("members=%#v", caller.args["members"])
|
||||
}
|
||||
if members[0]["roleId"] != "READER" || members[1]["roleId"] != "EDITOR" {
|
||||
t.Fatalf("roleId normalize failed: %#v", members)
|
||||
}
|
||||
if notify, ok := caller.args["notify"].(bool); !ok || notify {
|
||||
t.Fatalf("notify should be false when --notify=false: %#v", caller.args["notify"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectMembersValidation(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
members string
|
||||
wantError string
|
||||
}{
|
||||
{"invalid json", "[{", "JSON 解析失败"},
|
||||
{"empty array", "[]", "不能为空数组"},
|
||||
{"missing type", `[{"id":"u1","roleId":"READER","corpId":"c1"}]`, "缺少必填字段 type"},
|
||||
{"missing id", `[{"type":"USER","roleId":"READER","corpId":"c1"}]`, "缺少必填字段 id"},
|
||||
{"missing corpId", `[{"type":"USER","id":"u1","roleId":"READER"}]`, "需携带 corpId"},
|
||||
{"missing roleId", `[{"type":"USER","id":"u1","corpId":"c1"}]`, "缺少必填字段 roleId"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
installScriptedCaller(t, caller)
|
||||
err := executePR868Command(t, newDriveCommand(), "permission", "add", "--node", "n1", "--members", tc.members)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantError) {
|
||||
t.Fatalf("expected error containing %q, got %v", tc.wantError, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMembersExclusivity(t *testing.T) {
|
||||
t.Run("members and users are mutually exclusive", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
installScriptedCaller(t, caller)
|
||||
err := executePR868Command(t, newDriveCommand(), "permission", "add", "--node", "n1",
|
||||
"--users", "u1", "--members", `[{"type":"USER","id":"u1","roleId":"READER","corpId":"c1"}]`)
|
||||
if err == nil || !strings.Contains(err.Error(), "互斥") {
|
||||
t.Fatalf("expected mutual exclusion error, got %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("one of members or users is required", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
installScriptedCaller(t, caller)
|
||||
err := executePR868Command(t, newDriveCommand(), "permission", "add", "--node", "n1")
|
||||
if err == nil || !strings.Contains(err.Error(), "之一") {
|
||||
t.Fatalf("expected required error, got %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("role is redundant with members", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
installScriptedCaller(t, caller)
|
||||
err := executePR868Command(t, newDriveCommand(), "permission", "add", "--node", "n1",
|
||||
"--role", "READER", "--members", `[{"type":"USER","id":"u1","roleId":"READER","corpId":"c1"}]`)
|
||||
if err == nil || !strings.Contains(err.Error(), "不需要 --role") {
|
||||
t.Fatalf("expected no-role error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPermissionListPagination(t *testing.T) {
|
||||
t.Run("drive permission list passes nextToken and pageSize", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
if err := executePR868Command(t, newDriveCommand(), "permission", "list", "--node", "n1",
|
||||
"--limit", "50", "--next-token", "50"); err != nil {
|
||||
t.Fatalf("permission list: %v", err)
|
||||
}
|
||||
if caller.tool != "list_permission" {
|
||||
t.Fatalf("tool=%q", caller.tool)
|
||||
}
|
||||
if caller.args["pageSize"] != 50 {
|
||||
t.Fatalf("pageSize=%#v", caller.args["pageSize"])
|
||||
}
|
||||
if caller.args["nextToken"] != "50" {
|
||||
t.Fatalf("nextToken=%#v", caller.args["nextToken"])
|
||||
}
|
||||
})
|
||||
t.Run("doc permission list passes nextToken", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
if err := executePR868Command(t, newDocCommand(), "permission", "list", "--node", "n1",
|
||||
"--next-token", "30"); err != nil {
|
||||
t.Fatalf("doc permission list: %v", err)
|
||||
}
|
||||
if caller.args["nextToken"] != "30" {
|
||||
t.Fatalf("nextToken=%#v", caller.args["nextToken"])
|
||||
}
|
||||
})
|
||||
t.Run("wiki member list passes nextToken and pageSize", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
if err := executePR868Command(t, newWikiCommand(), "member", "list", "--workspace", "ws1",
|
||||
"--limit", "50", "--next-token", "50"); err != nil {
|
||||
t.Fatalf("wiki member list: %v", err)
|
||||
}
|
||||
if caller.tool != "list_member" {
|
||||
t.Fatalf("tool=%q", caller.tool)
|
||||
}
|
||||
if caller.args["pageSize"] != 50 || caller.args["nextToken"] != "50" {
|
||||
t.Fatalf("args=%#v", caller.args)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPermissionRemoveWithMembers(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
// remove 是 destructive 入口:Safety confirmation=user_required,测试里
|
||||
// 注入根级 --yes 后直接确认执行(参数装配本身由下方断言验证)。
|
||||
root := newDriveCommand()
|
||||
root.PersistentFlags().Bool("yes", false, "confirm high-risk operation")
|
||||
if err := executePR868Command(t, root, "permission", "remove", "--node", "n1",
|
||||
"--members", `[{"type":"CONVERSATION","id":"cid1"}]`, "--yes"); err != nil {
|
||||
t.Fatalf("permission remove --members: %v", err)
|
||||
}
|
||||
if caller.tool != "remove_permission" {
|
||||
t.Fatalf("tool=%q", caller.tool)
|
||||
}
|
||||
members, ok := caller.args["members"].([]map[string]any)
|
||||
if !ok || len(members) != 1 || members[0]["id"] != "cid1" {
|
||||
t.Fatalf("members=%#v", caller.args["members"])
|
||||
}
|
||||
// remove 语义下 roleId 不应被要求
|
||||
if _, hasRole := members[0]["roleId"]; hasRole {
|
||||
t.Fatalf("remove members should not require roleId: %#v", members[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiMemberAddWithMembersRejectsOwner(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
installScriptedCaller(t, caller)
|
||||
err := executePR868Command(t, newWikiCommand(), "member", "add", "--workspace", "ws1",
|
||||
"--members", `[{"type":"USER","id":"u1","roleId":"OWNER","corpId":"c1"}]`)
|
||||
if err == nil || !strings.Contains(err.Error(), "OWNER") {
|
||||
t.Fatalf("expected OWNER rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────
|
||||
// collectMembers / validateMembersExclusivity 单元级用例
|
||||
// 同步自内部 MR 28965577 补齐的覆盖:四类型混传、remove 语义、
|
||||
// 超过 30 个、corpId 按类型校验、--user 别名。
|
||||
// ──────────────────────────────────────────────────────
|
||||
|
||||
// newMembersCmd 构造带 --members/--users/--user/--role 的最小命令,
|
||||
// 与生产命令的 flag 注册保持一致。
|
||||
func newMembersCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("members", "", "")
|
||||
cmd.Flags().String("users", "", "")
|
||||
cmd.Flags().String("user", "", "")
|
||||
cmd.Flags().String("role", "", "")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func TestCollectMembersMixedTypes(t *testing.T) {
|
||||
cmd := newMembersCmd()
|
||||
_ = cmd.Flags().Set("members", `[{"type":"USER","id":"u1","roleId":"MANAGER","corpId":"c1"},{"type":"DEPT","id":"d1","roleId":"editor","corpId":"c1"},{"type":"CONVERSATION","id":"cid1","roleId":"READER"},{"type":"TAG","id":"t1","roleId":"DOWNLOADER","corpId":"c1"}]`)
|
||||
got, err := collectMembers(cmd, false)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(got) != 4 {
|
||||
t.Fatalf("expected 4 members, got %d", len(got))
|
||||
}
|
||||
if got[1]["roleId"] != "EDITOR" {
|
||||
t.Errorf("member[1] roleId should be normalized to EDITOR, got %v", got[1]["roleId"])
|
||||
}
|
||||
if got[0]["corpId"] != "c1" {
|
||||
t.Errorf("corpId should be preserved, got %v", got[0]["corpId"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectMembersRemoveSemantics(t *testing.T) {
|
||||
t.Run("remove only needs type and id", func(t *testing.T) {
|
||||
cmd := newMembersCmd()
|
||||
_ = cmd.Flags().Set("members", `[{"type":"USER","id":"u1","corpId":"c1"},{"type":"DEPT","id":"d1","corpId":"c1"}]`)
|
||||
got, err := collectMembers(cmd, true)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 members, got %d", len(got))
|
||||
}
|
||||
})
|
||||
t.Run("add still requires roleId", func(t *testing.T) {
|
||||
cmd := newMembersCmd()
|
||||
_ = cmd.Flags().Set("members", `[{"type":"USER","id":"u1","corpId":"c1"}]`)
|
||||
if _, err := collectMembers(cmd, false); err == nil {
|
||||
t.Fatal("expected error for missing roleId, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCollectMembersOver30(t *testing.T) {
|
||||
cmd := newMembersCmd()
|
||||
members := "["
|
||||
for i := 0; i < 31; i++ {
|
||||
if i > 0 {
|
||||
members += ","
|
||||
}
|
||||
members += fmt.Sprintf(`{"type":"USER","id":"u%d","roleId":"READER","corpId":"c"}`, i)
|
||||
}
|
||||
members += "]"
|
||||
_ = cmd.Flags().Set("members", members)
|
||||
if _, err := collectMembers(cmd, false); err == nil {
|
||||
t.Fatal("expected error for >30 members, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectMembersCorpIDByType(t *testing.T) {
|
||||
t.Run("DEPT requires corpId", func(t *testing.T) {
|
||||
cmd := newMembersCmd()
|
||||
_ = cmd.Flags().Set("members", `[{"type":"DEPT","id":"d1","roleId":"EDITOR"}]`)
|
||||
if _, err := collectMembers(cmd, false); err == nil {
|
||||
t.Fatal("expected error for DEPT missing corpId, got nil")
|
||||
}
|
||||
})
|
||||
t.Run("TAG requires corpId", func(t *testing.T) {
|
||||
cmd := newMembersCmd()
|
||||
_ = cmd.Flags().Set("members", `[{"type":"TAG","id":"t1","roleId":"DOWNLOADER"}]`)
|
||||
if _, err := collectMembers(cmd, false); err == nil {
|
||||
t.Fatal("expected error for TAG missing corpId, got nil")
|
||||
}
|
||||
})
|
||||
t.Run("CONVERSATION works without corpId", func(t *testing.T) {
|
||||
cmd := newMembersCmd()
|
||||
_ = cmd.Flags().Set("members", `[{"type":"CONVERSATION","id":"cid1","roleId":"READER"}]`)
|
||||
got, err := collectMembers(cmd, false)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for CONVERSATION without corpId: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected 1 member, got %d", len(got))
|
||||
}
|
||||
})
|
||||
t.Run("TAG with corpId passes", func(t *testing.T) {
|
||||
cmd := newMembersCmd()
|
||||
_ = cmd.Flags().Set("members", `[{"type":"TAG","id":"t1","roleId":"DOWNLOADER","corpId":"c1"}]`)
|
||||
got, err := collectMembers(cmd, false)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected 1 member, got %d", len(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateMembersExclusivityUserAlias(t *testing.T) {
|
||||
t.Run("--user alias counts as users", func(t *testing.T) {
|
||||
cmd := newMembersCmd()
|
||||
_ = cmd.Flags().Set("user", "u1")
|
||||
if err := validateMembersExclusivity(cmd); err != nil {
|
||||
t.Errorf("unexpected error for --user alias: %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("--user alias conflicts with --members", func(t *testing.T) {
|
||||
cmd := newMembersCmd()
|
||||
_ = cmd.Flags().Set("user", "u1")
|
||||
_ = cmd.Flags().Set("members", `[{"type":"USER","id":"u1","roleId":"READER","corpId":"c1"}]`)
|
||||
if err := validateMembersExclusivity(cmd); err == nil {
|
||||
t.Error("expected error when both --user and --members set, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────
|
||||
// isNoPermissionError — 新接口 forbidden.accessDenied 错误码
|
||||
// ──────────────────────────────────────────────────────
|
||||
|
||||
func TestIsNoPermissionErrorAccessDenied(t *testing.T) {
|
||||
t.Run("server code forbidden.accessDenied", func(t *testing.T) {
|
||||
for _, key := range []string{"code", "errorCode", "server_error_code"} {
|
||||
body := map[string]any{key: "forbidden.accessDenied"}
|
||||
if !isNoPermissionError(body) {
|
||||
t.Errorf("isNoPermissionError(%s=forbidden.accessDenied) = false, want true", key)
|
||||
}
|
||||
}
|
||||
})
|
||||
t.Run("access-denied message text", func(t *testing.T) {
|
||||
for _, msg := range []string{
|
||||
"需要您具备 EDITOR 及以上角色",
|
||||
"需要您具备 MANAGER 及以上角色才能执行此操作",
|
||||
"forbidden.accessDenied",
|
||||
"Forbidden.AccessDenied",
|
||||
} {
|
||||
body := map[string]any{"errorMsg": msg}
|
||||
if !isNoPermissionError(body) {
|
||||
t.Errorf("isNoPermissionError(msg=%q) = false, want true", msg)
|
||||
}
|
||||
}
|
||||
})
|
||||
t.Run("normal body is not permission error", func(t *testing.T) {
|
||||
body := map[string]any{"success": true, "errorMsg": ""}
|
||||
if isNoPermissionError(body) {
|
||||
t.Error("isNoPermissionError(success body) = true, want false")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────
|
||||
// --notify bool flag 解析(NoArgs 防御 `--notify false` 空格形式)
|
||||
// ──────────────────────────────────────────────────────
|
||||
|
||||
// newNotifyCmd 构造与生产 add/update 命令一致的最小命令:
|
||||
// --notify bool flag + Args: cobra.NoArgs。
|
||||
func newNotifyCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "test",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error { return nil },
|
||||
}
|
||||
cmd.Flags().Bool("notify", false, "")
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
return cmd
|
||||
}
|
||||
|
||||
func TestNotifyFlagParsing(t *testing.T) {
|
||||
t.Run("equals form sets false", func(t *testing.T) {
|
||||
cmd := newNotifyCmd()
|
||||
cmd.SetArgs([]string{"--notify=false"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got, _ := cmd.Flags().GetBool("notify"); got {
|
||||
t.Error("--notify=false: got true, want false")
|
||||
}
|
||||
if !cmd.Flags().Changed("notify") {
|
||||
t.Error("--notify=false should mark flag as changed")
|
||||
}
|
||||
})
|
||||
t.Run("bare flag defaults true via NoOptDefVal", func(t *testing.T) {
|
||||
cmd := newNotifyCmd()
|
||||
cmd.SetArgs([]string{"--notify"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got, _ := cmd.Flags().GetBool("notify"); !got {
|
||||
t.Error("--notify (no value): got false, want true (NoOptDefVal)")
|
||||
}
|
||||
if !cmd.Flags().Changed("notify") {
|
||||
t.Error("--notify should mark flag as changed")
|
||||
}
|
||||
})
|
||||
t.Run("space form rejected by NoArgs", func(t *testing.T) {
|
||||
cmd := newNotifyCmd()
|
||||
// "--notify false" 被 pflag 解析为 notify=true(NoOptDefVal)+ 位置参数 "false"。
|
||||
// Args: cobra.NoArgs 拒绝位置参数,防止用户误以为传了 false。
|
||||
cmd.SetArgs([]string{"--notify", "false"})
|
||||
if err := cmd.Execute(); err == nil {
|
||||
t.Fatal("expected error for '--notify false' (space form) with NoArgs, got nil")
|
||||
}
|
||||
if got, _ := cmd.Flags().GetBool("notify"); !got {
|
||||
t.Error("pflag should set notify=true via NoOptDefVal for bare --notify, got false")
|
||||
}
|
||||
})
|
||||
t.Run("omitted stays unchanged", func(t *testing.T) {
|
||||
cmd := newNotifyCmd()
|
||||
cmd.SetArgs([]string{})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if cmd.Flags().Changed("notify") {
|
||||
t.Error("notify should not be marked changed when omitted")
|
||||
}
|
||||
if got, _ := cmd.Flags().GetBool("notify"); got {
|
||||
t.Error("omitted notify: got true, want false (default)")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────
|
||||
// pageSize 1..50 运行时边界(P2 意见:服务端 pageSize 上限 50)
|
||||
// ──────────────────────────────────────────────────────
|
||||
|
||||
func newPageSizeCmd(changed string, value int) *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().Int("limit", 30, "")
|
||||
cmd.Flags().Int("max-results", 0, "")
|
||||
_ = cmd.Flags().Set(changed, fmt.Sprintf("%d", value))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func TestPermissionPageSizeFromFlagsBoundaries(t *testing.T) {
|
||||
t.Run("rejects out-of-range values", func(t *testing.T) {
|
||||
for _, flag := range []string{"limit", "max-results"} {
|
||||
for _, size := range []int{-1, 0, 51, 100} {
|
||||
if _, _, err := permissionPageSizeFromFlags(newPageSizeCmd(flag, size)); err == nil {
|
||||
t.Errorf("--%s %d: expected error, got nil", flag, size)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
t.Run("accepts in-range values", func(t *testing.T) {
|
||||
for _, size := range []int{1, 30, 50} {
|
||||
got, ok, err := permissionPageSizeFromFlags(newPageSizeCmd("limit", size))
|
||||
if err != nil {
|
||||
t.Errorf("--limit %d: unexpected error %v", size, err)
|
||||
continue
|
||||
}
|
||||
if !ok || got != size {
|
||||
t.Errorf("--limit %d: got (%d, %v)", size, got, ok)
|
||||
}
|
||||
}
|
||||
})
|
||||
t.Run("omitted returns ok=false", func(t *testing.T) {
|
||||
// newPageSizeCmd 强制 Set(changed),先验证 Set 后 Changed 生效,
|
||||
// 再用未设置任何 flag 的命令验证默认路径。
|
||||
if got, ok, err := permissionPageSizeFromFlags(newPageSizeCmd("limit", 30)); err != nil || !ok || got != 30 {
|
||||
t.Fatalf("setup sanity failed: got (%d, %v, %v), want (30, true, nil)", got, ok, err)
|
||||
}
|
||||
fresh := &cobra.Command{Use: "test"}
|
||||
fresh.Flags().Int("limit", 30, "")
|
||||
fresh.Flags().Int("max-results", 0, "")
|
||||
if got, ok, err := permissionPageSizeFromFlags(fresh); err != nil || ok || got != 0 {
|
||||
t.Errorf("omitted flags: got (%d, %v, %v), want (0, false, nil)", got, ok, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPermissionListPageSizeRuntimeGuard(t *testing.T) {
|
||||
type listCase struct {
|
||||
name string
|
||||
root func() *cobra.Command
|
||||
path []string
|
||||
nodeID []string
|
||||
}
|
||||
cases := []listCase{
|
||||
{"drive", newDriveCommand, []string{"permission", "list"}, []string{"--node", "n1"}},
|
||||
{"doc", newDocCommand, []string{"permission", "list"}, []string{"--node", "n1"}},
|
||||
{"wiki", newWikiCommand, []string{"member", "list"}, []string{"--workspace", "ws1"}},
|
||||
}
|
||||
for _, lc := range cases {
|
||||
t.Run(lc.name+" rejects --limit 51", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
installScriptedCaller(t, caller)
|
||||
args := append(append([]string{}, lc.path...), lc.nodeID...)
|
||||
args = append(args, "--limit", "51")
|
||||
err := executePR868Command(t, lc.root(), args...)
|
||||
if err == nil || !strings.Contains(err.Error(), "1..50") {
|
||||
t.Fatalf("expected 1..50 validation error, got %v", err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("list should not call MCP with invalid pageSize, called %d times", caller.calls)
|
||||
}
|
||||
})
|
||||
t.Run(lc.name+" accepts --limit 50", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
args := append(append([]string{}, lc.path...), lc.nodeID...)
|
||||
args = append(args, "--limit", "50")
|
||||
if err := executePR868Command(t, lc.root(), args...); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if caller.args["pageSize"] != 50 {
|
||||
t.Fatalf("pageSize=%#v, want 50", caller.args["pageSize"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────
|
||||
// add 的 --notify 声明默认值必须与实际透传行为一致
|
||||
//
|
||||
// 三个 add 命令都用 `if Flags().Changed("notify")` 守卫来决定是否把 notify
|
||||
// 写入 toolArgs,所以省略该 flag 时字段根本不会发给服务端,实测服务端按
|
||||
// 不通知处理。声明默认值因此必须是 false —— 若有人把它改回 true,help 会
|
||||
// 承诺一个从未透传的默认值(曾经的真实缺陷)。
|
||||
// ──────────────────────────────────────────────────────
|
||||
|
||||
func TestPermissionAddNotifyDefaultMatchesWireBehavior(t *testing.T) {
|
||||
type addCase struct {
|
||||
name string
|
||||
root func() *cobra.Command
|
||||
path []string
|
||||
target []string
|
||||
}
|
||||
cases := []addCase{
|
||||
{"drive", newDriveCommand, []string{"permission", "add"}, []string{"--node", "n1"}},
|
||||
{"doc", newDocCommand, []string{"permission", "add"}, []string{"--node", "n1"}},
|
||||
{"wiki", newWikiCommand, []string{"member", "add"}, []string{"--workspace", "ws1"}},
|
||||
}
|
||||
const members = `[{"type":"USER","id":"u1","roleId":"READER","corpId":"c1"}]`
|
||||
|
||||
runAdd := func(t *testing.T, lc addCase, extra ...string) *scriptedToolCaller {
|
||||
t.Helper()
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
args := append(append([]string{}, lc.path...), lc.target...)
|
||||
args = append(args, "--members", members)
|
||||
args = append(args, extra...)
|
||||
if err := executePR868Command(t, lc.root(), args...); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
return caller
|
||||
}
|
||||
|
||||
for _, lc := range cases {
|
||||
t.Run(lc.name+" omitted sends no notify field", func(t *testing.T) {
|
||||
caller := runAdd(t, lc)
|
||||
if v, present := caller.args["notify"]; present {
|
||||
t.Fatalf("notify must be absent from toolArgs when --notify is omitted, got %#v", v)
|
||||
}
|
||||
})
|
||||
t.Run(lc.name+" declared default is false", func(t *testing.T) {
|
||||
// 声明默认值必须与上面的透传行为一致,否则 help 在说谎。
|
||||
root := lc.root()
|
||||
leaf, _, err := root.Find(lc.path)
|
||||
if err != nil {
|
||||
t.Fatalf("find %v: %v", lc.path, err)
|
||||
}
|
||||
flag := leaf.Flags().Lookup("notify")
|
||||
if flag == nil {
|
||||
t.Fatal("notify flag not declared")
|
||||
}
|
||||
if flag.DefValue != "false" {
|
||||
t.Errorf("notify DefValue=%q, want \"false\": 省略时不透传该字段,声明 true 会承诺一个未生效的默认值", flag.DefValue)
|
||||
}
|
||||
})
|
||||
t.Run(lc.name+" bare --notify opts in", func(t *testing.T) {
|
||||
caller := runAdd(t, lc, "--notify")
|
||||
if notify, ok := caller.args["notify"].(bool); !ok || !notify {
|
||||
t.Fatalf("notify=%#v, want true for bare --notify", caller.args["notify"])
|
||||
}
|
||||
})
|
||||
t.Run(lc.name+" --notify=false is sent explicitly", func(t *testing.T) {
|
||||
caller := runAdd(t, lc, "--notify=false")
|
||||
notify, ok := caller.args["notify"].(bool)
|
||||
if !ok || notify {
|
||||
t.Fatalf("notify=%#v, want explicit false", caller.args["notify"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────
|
||||
// 服务端对已确认“空响应=写成功”契约的 permission/member update/remove
|
||||
// 工具可能返回字面 "null"(操作成功但无返回数据)。CLI 仅对这几个
|
||||
// 工具将其渲染为空对象 {},避免 Agent/下游把 null 当作对象解析时失败;
|
||||
// 其它工具的合法 null 保持原样输出,公共渲染器的机器输出契约不变。
|
||||
// ──────────────────────────────────────────────────────
|
||||
|
||||
func TestCrossPlatformCoverageNullToolResponseRendersEmptyObject(t *testing.T) {
|
||||
// nullOnSuccessTools 集合内:null 适配为 {}
|
||||
for _, format := range []string{"json", "raw"} {
|
||||
t.Run("adapted_"+format, func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: "null"}}, format: format}
|
||||
installScriptedCaller(t, caller)
|
||||
out := &bytes.Buffer{}
|
||||
deps.Out.w = out
|
||||
if err := callMCPToolInternalOptsContext(context.Background(), "drive", "update_permission", nil, false); err != nil {
|
||||
t.Fatalf("null tool response should render as {}: %v", err)
|
||||
}
|
||||
if got := strings.TrimSpace(out.String()); got != "{}" {
|
||||
t.Errorf("format %s renders %q, want {}", format, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
// 集合外的工具:null 原样输出,不再被公共渲染路径改写为 {}
|
||||
for _, format := range []string{"json", "raw"} {
|
||||
t.Run("passthrough_"+format, func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: "null"}}, format: format}
|
||||
installScriptedCaller(t, caller)
|
||||
out := &bytes.Buffer{}
|
||||
deps.Out.w = out
|
||||
if err := callMCPToolInternalOptsContext(context.Background(), "drive", "update_node_permission", nil, false); err != nil {
|
||||
t.Fatalf("null passthrough should not error: %v", err)
|
||||
}
|
||||
if got := strings.TrimSpace(out.String()); got != "null" {
|
||||
t.Errorf("format %s renders %q, want null unchanged", format, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────
|
||||
// update / remove 的 --members 装配与 --users 解析失败分支
|
||||
//
|
||||
// add 的 members+notify 透传已有
|
||||
// TestPermissionAddNotifyDefaultMatchesWireBehavior 覆盖;这里补齐
|
||||
// update(members + notify)与 doc/wiki remove(members)的同构分支,
|
||||
// 以及各产品 add/update/remove 中 collectUserIDs 的错误路径:
|
||||
// --users 传纯空白时 flagOrFallback 视为已提供(非空字符串),
|
||||
// 但 parseCommentMentionIds 过滤空白后为空,collectUserIDs 必须报错
|
||||
// 而不是向服务端发送空 userIds。
|
||||
// ──────────────────────────────────────────────────────
|
||||
|
||||
func TestCrossPlatformCoveragePermissionUpdateWithMembers(t *testing.T) {
|
||||
type updateCase struct {
|
||||
name string
|
||||
root func() *cobra.Command
|
||||
path []string
|
||||
target []string
|
||||
tool string
|
||||
}
|
||||
cases := []updateCase{
|
||||
{"drive", newDriveCommand, []string{"permission", "update"}, []string{"--node", "n1"}, "update_permission"},
|
||||
{"doc", newDocCommand, []string{"permission", "update"}, []string{"--node", "n1"}, "update_permission"},
|
||||
{"wiki", newWikiCommand, []string{"member", "update"}, []string{"--workspace", "ws1"}, "update_member"},
|
||||
}
|
||||
const members = `[{"type":"USER","id":"u1","roleId":"READER","corpId":"c1"}]`
|
||||
for _, uc := range cases {
|
||||
t.Run(uc.name+" passes members and notify", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
args := append(append([]string{}, uc.path...), uc.target...)
|
||||
args = append(args, "--members", members, "--notify")
|
||||
if err := executePR868Command(t, uc.root(), args...); err != nil {
|
||||
t.Fatalf("update --members: %v", err)
|
||||
}
|
||||
if caller.tool != uc.tool {
|
||||
t.Fatalf("tool=%q, want %q", caller.tool, uc.tool)
|
||||
}
|
||||
got, ok := caller.args["members"].([]map[string]any)
|
||||
if !ok || len(got) != 1 || got[0]["id"] != "u1" || got[0]["roleId"] != "READER" {
|
||||
t.Fatalf("members=%#v", caller.args["members"])
|
||||
}
|
||||
if notify, ok := caller.args["notify"].(bool); !ok || !notify {
|
||||
t.Fatalf("notify=%#v, want true for bare --notify", caller.args["notify"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePermissionRemoveWithMembersProducts(t *testing.T) {
|
||||
// drive remove 已由 TestPermissionRemoveWithMembers 覆盖;这里补 doc/wiki。
|
||||
type removeCase struct {
|
||||
name string
|
||||
root func() *cobra.Command
|
||||
path []string
|
||||
target []string
|
||||
tool string
|
||||
}
|
||||
cases := []removeCase{
|
||||
{"doc", newDocCommand, []string{"permission", "remove"}, []string{"--node", "n1"}, "remove_permission"},
|
||||
{"wiki", newWikiCommand, []string{"member", "remove"}, []string{"--workspace", "ws1"}, "remove_member"},
|
||||
}
|
||||
for _, rc := range cases {
|
||||
t.Run(rc.name+" passes members", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
// remove 需用户确认(confirmation=user_required):注入根级 --yes。
|
||||
root := rc.root()
|
||||
root.PersistentFlags().Bool("yes", false, "confirm high-risk operation")
|
||||
args := append(append([]string{}, rc.path...), rc.target...)
|
||||
args = append(args, "--members", `[{"type":"CONVERSATION","id":"cid1"}]`, "--yes")
|
||||
if err := executePR868Command(t, root, args...); err != nil {
|
||||
t.Fatalf("remove --members: %v", err)
|
||||
}
|
||||
if caller.tool != rc.tool {
|
||||
t.Fatalf("tool=%q, want %q", caller.tool, rc.tool)
|
||||
}
|
||||
got, ok := caller.args["members"].([]map[string]any)
|
||||
if !ok || len(got) != 1 || got[0]["id"] != "cid1" {
|
||||
t.Fatalf("members=%#v", caller.args["members"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePermissionUsersBlankParseError(t *testing.T) {
|
||||
type blankCase struct {
|
||||
name string
|
||||
root func() *cobra.Command
|
||||
path []string
|
||||
target []string
|
||||
needRole bool
|
||||
}
|
||||
cases := []blankCase{
|
||||
{"drive add", newDriveCommand, []string{"permission", "add"}, []string{"--node", "n1"}, true},
|
||||
{"drive update", newDriveCommand, []string{"permission", "update"}, []string{"--node", "n1"}, true},
|
||||
{"drive remove", newDriveCommand, []string{"permission", "remove"}, []string{"--node", "n1"}, false},
|
||||
{"doc add", newDocCommand, []string{"permission", "add"}, []string{"--node", "n1"}, true},
|
||||
{"doc update", newDocCommand, []string{"permission", "update"}, []string{"--node", "n1"}, true},
|
||||
{"doc remove", newDocCommand, []string{"permission", "remove"}, []string{"--node", "n1"}, false},
|
||||
{"wiki add", newWikiCommand, []string{"member", "add"}, []string{"--workspace", "ws1"}, true},
|
||||
{"wiki update", newWikiCommand, []string{"member", "update"}, []string{"--workspace", "ws1"}, true},
|
||||
{"wiki remove", newWikiCommand, []string{"member", "remove"}, []string{"--workspace", "ws1"}, false},
|
||||
}
|
||||
for _, bc := range cases {
|
||||
t.Run(bc.name+" rejects blank --users", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
installScriptedCaller(t, caller)
|
||||
args := append(append([]string{}, bc.path...), bc.target...)
|
||||
args = append(args, "--users", " ")
|
||||
if bc.needRole {
|
||||
// add/update 的旧格式分支在 collectUserIDs 之前校验必填 --role。
|
||||
args = append(args, "--role", "READER")
|
||||
}
|
||||
err := executePR868Command(t, bc.root(), args...)
|
||||
if err == nil || !strings.Contains(err.Error(), "--users is required") {
|
||||
t.Fatalf("expected --users is required error, got %v", err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("MCP must not be called when --users resolves empty, called %d times", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
)
|
||||
|
||||
// P2 评审要求(PR #1085):三个游标列表命令(drive/doc permission list、
|
||||
// wiki member list)必须声明 Contract.Pagination(cursor 分页,游标 flag 为
|
||||
// next-token),且 next-token 是该叶的 declared parameter。
|
||||
//
|
||||
// wire 上的 pagination 投影由 output rollout 门控制(schema_runtime_registry
|
||||
// 对未迁移统一结果 envelope 的命令不发布 result/pagination,避免 Schema 与
|
||||
// runtime 输出不一致)。本测试固化「声明已就绪」与「当前仍为 legacy 输出」
|
||||
// 两个事实;命令迁移 unified envelope 后 wire 投影自动生效,届时应更新此
|
||||
// 断言并用 dws schema 验证最终投影。
|
||||
// 命名携带 TestCrossPlatformCoverage 前缀:本测试覆盖本次改动的
|
||||
// Contract.Pagination 声明行,需在 macOS/Windows 平台覆盖率门禁上执行。
|
||||
func TestCrossPlatformCoveragePermissionListPaginationDeclaration(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
root *cobra.Command
|
||||
path []string
|
||||
}{
|
||||
{"drive permission list", newDriveCommand(), []string{"permission", "list"}},
|
||||
{"doc permission list", newDocCommand(), []string{"permission", "list"}},
|
||||
{"wiki member list", newWikiCommand(), []string{"member", "list"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmd, _, err := tc.root.Find(tc.path)
|
||||
if err != nil || cmd == nil || cmd.Name() != tc.path[len(tc.path)-1] {
|
||||
t.Fatalf("find leaf %v: cmd=%v err=%v", tc.path, cmd, err)
|
||||
}
|
||||
final, ok := contractfinal.RuntimeContractFinal(cmd)
|
||||
if !ok {
|
||||
t.Fatalf("missing RuntimeContractFinal on %v", tc.path)
|
||||
}
|
||||
pg := final.Pagination
|
||||
if pg == nil {
|
||||
t.Fatalf("Contract.Pagination not declared on %v", tc.path)
|
||||
}
|
||||
if pg.Kind != contract.PaginationKindCursor || pg.CursorParameter != "next-token" {
|
||||
t.Fatalf("pagination = %+v, want cursor/next-token", pg)
|
||||
}
|
||||
if pg.MetaPath != contract.PaginationMetaPath ||
|
||||
pg.NextTokenPath != contract.PaginationNextTokenPath ||
|
||||
pg.EndpointExhaustedPath != contract.PaginationExhaustedPath {
|
||||
t.Fatalf("pagination framework-owned paths not normalized: %+v", pg)
|
||||
}
|
||||
declared := false
|
||||
for _, p := range final.Parameters {
|
||||
if p.Name == "next-token" {
|
||||
declared = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !declared {
|
||||
t.Fatalf("cursor flag next-token must be a declared parameter on %v", tc.path)
|
||||
}
|
||||
if output.ActiveContract(cmd) != output.ContractLegacy {
|
||||
t.Fatalf("%v migrated to unified result; update this test and verify wire projection via dws schema", tc.path)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -206,8 +206,18 @@ func TestCrossPlatformCoverageDriveAliasAndDownloadVersion(t *testing.T) {
|
||||
if err := executePR868Command(t, newDriveCommand(), "permission", "list", "--node", "n1", "--max-results", "10"); err != nil {
|
||||
t.Fatalf("permission list: %v", err)
|
||||
}
|
||||
if caller.args["maxResults"] != 10 {
|
||||
t.Fatalf("maxResults=%#v", caller.args["maxResults"])
|
||||
if caller.args["pageSize"] != 10 {
|
||||
t.Fatalf("pageSize=%#v", caller.args["pageSize"])
|
||||
}
|
||||
})
|
||||
t.Run("permission list next-token pagination", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
if err := executePR868Command(t, newDriveCommand(), "permission", "list", "--node", "n1", "--next-token", "50"); err != nil {
|
||||
t.Fatalf("permission list --next-token: %v", err)
|
||||
}
|
||||
if caller.args["nextToken"] != "50" {
|
||||
t.Fatalf("nextToken=%#v", caller.args["nextToken"])
|
||||
}
|
||||
})
|
||||
t.Run("cover file-id alias", func(t *testing.T) {
|
||||
|
||||
@@ -144,17 +144,25 @@ func TestSheetCellInfosDirectPassthroughPreservesCompletionMetadata(t *testing.T
|
||||
// Large-CP failure is not a pageable success. Both the raw csv-get path and
|
||||
// both get_cell_infos paths must retain the backend code and safe user message
|
||||
// exactly, matching the generic get_all_sheets error-classification path.
|
||||
//
|
||||
// 两条路径的 Message 形态不同:csv-get / cell-infos-direct 走终端展示路径
|
||||
// (callMCPToolInternalOptsContext),业务错误展示为提取的 errorMessage 加
|
||||
// "(code: ...)" 后缀——后端错误码必须保持可见;range read 走数据编排路径
|
||||
// (parseMCPToolTextResult),下游(如 drive list --depth 的限流重试)需要
|
||||
// 从 Message 反解析 errorCode,因此必须保留原始 JSON payload。
|
||||
func TestSheetReadPreservesWorkbookSizeOverLimitError(t *testing.T) {
|
||||
const response = `{"success":false,"errorCode":"forbidden.document.sizeOverLimit","errorMessage":"The workbook data is too large to process. Use a smaller copy or split the workbook, then try again."}`
|
||||
const wantDisplay = "The workbook data is too large to process. Use a smaller copy or split the workbook, then try again. (code: forbidden.document.sizeOverLimit)"
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
tool string
|
||||
direct bool
|
||||
want string
|
||||
}{
|
||||
{name: "csv-get", args: []string{"csv-get", "--node", "NODE"}, tool: "get_range_as_csv"},
|
||||
{name: "range-read", args: []string{"range", "read", "--node", "NODE"}, tool: "get_cell_infos"},
|
||||
{name: "cell-infos-direct", tool: "get_cell_infos", direct: true},
|
||||
{name: "csv-get", args: []string{"csv-get", "--node", "NODE"}, tool: "get_range_as_csv", want: wantDisplay},
|
||||
{name: "range-read", args: []string{"range", "read", "--node", "NODE"}, tool: "get_cell_infos", want: response},
|
||||
{name: "cell-infos-direct", tool: "get_cell_infos", direct: true, want: wantDisplay},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
@@ -180,8 +188,8 @@ func TestSheetReadPreservesWorkbookSizeOverLimitError(t *testing.T) {
|
||||
if !errors.As(err, &cliErr) || cliErr.Code != CodeMCPToolError {
|
||||
t.Fatalf("error = %T %v, want MCP tool error", err, err)
|
||||
}
|
||||
if cliErr.Message != response {
|
||||
t.Fatalf("backend error payload changed:\n got: %s\nwant: %s", cliErr.Message, response)
|
||||
if cliErr.Message != test.want {
|
||||
t.Fatalf("backend error payload changed:\n got: %s\nwant: %s", cliErr.Message, test.want)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"forbidden.document.sizeOverLimit",
|
||||
|
||||
+158
-61
@@ -529,11 +529,20 @@ func newWikiCommand() *cobra.Command {
|
||||
memberAddCmd := &cobra.Command{
|
||||
Use: "add",
|
||||
Short: "添加知识库成员",
|
||||
Args: cobra.NoArgs,
|
||||
Long: `为指定知识库添加一个或多个成员,并授予指定角色。
|
||||
|
||||
通过 --users 传入逗号分隔的 userId 列表,多个用户将被授予同一角色。
|
||||
两种传参方式(互斥):
|
||||
旧格式:--users 传入逗号分隔的 userId 列表 + --role 指定统一角色(仅 USER 类型)
|
||||
新格式:--members 传入 JSON 数组,支持四种成员类型,每个 member 携带独立 roleId
|
||||
|
||||
支持的角色 (--role)(必须大写):
|
||||
成员类型说明:
|
||||
USER 用户,id 为用户 userId,需携带 corpId(标识用户所属组织)
|
||||
DEPT 部门,id 为部门 ID,需携带 corpId(标识部门所属组织)
|
||||
CONVERSATION 群聊,id 为群聊 conversationId(cid 开头),无需 corpId
|
||||
TAG 角色标签(也称角色组),id 为角色标签 ID,需携带 corpId。当用户要求"添加角色组"或"添加角色标签"时使用此类型
|
||||
|
||||
支持的角色(大小写不敏感):
|
||||
MANAGER 管理员,可读写、管理成员
|
||||
EDITOR 编辑者,可查看、编辑、上传内容
|
||||
DOWNLOADER 查看下载者,可查看并下载内容
|
||||
@@ -541,36 +550,58 @@ func newWikiCommand() *cobra.Command {
|
||||
|
||||
注意:
|
||||
- OWNER 角色不可通过此接口添加,知识库创建者默认为所有者。
|
||||
- 操作者需具备知识库的 OWNER 或 MANAGER 权限。
|
||||
- 操作者须满足该知识库配置的权限管理最低角色要求(默认 MANAGER,可配置为 EDITOR 等),权限不足返回 forbidden.accessDenied。
|
||||
- 单次请求最多 30 个成员,超出请分批调用。
|
||||
- --notify 仅在 --members 新格式时生效,仅对 USER 和 CONVERSATION 类型成员发送通知(DEPT 和 TAG 不通知),默认 false;省略时 CLI 不向服务端发送该字段,服务端按不通知处理,需要通知请显式传 --notify。
|
||||
|
||||
支持通过 --workspace 传入知识库 ID 或知识库 URL,系统自动识别。
|
||||
用户 uid 可通过「钉钉通讯录」相关命令检索,如:
|
||||
dws contact user search --keyword "姓名"`,
|
||||
Example: ` dws wiki member add --workspace <workspaceId> --users uid1 --role READER
|
||||
dws wiki member add --workspace <workspaceId> --users uid1,uid2,uid3 --role EDITOR
|
||||
dws wiki member add --workspace "https://alidocs.dingtalk.com/i/spaces/xxx/overview" --users uid1 --role MANAGER`,
|
||||
dws wiki member add --workspace "https://alidocs.dingtalk.com/i/spaces/xxx/overview" --users uid1 --role MANAGER
|
||||
dws wiki member add --workspace <workspaceId> --members '[{"type":"USER","id":"uid1","roleId":"READER","corpId":"xxx"},{"type":"DEPT","id":"deptId1","roleId":"EDITOR","corpId":"xxx"}]' --notify
|
||||
dws wiki member add --workspace <workspaceId> --members '[{"type":"CONVERSATION","id":"cidXXX","roleId":"READER"},{"type":"TAG","id":"tagId1","roleId":"EDITOR","corpId":"xxx"}]'`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
workspaceID, err := mustFlagOrFallback(cmd, "workspace", "workspace-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRequiredFlags(cmd, "role"); err != nil {
|
||||
if err := validateMembersExclusivity(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
role := normalizePermissionRole(mustGetFlag(cmd, "role"))
|
||||
if role == "OWNER" {
|
||||
return apperrors.NewValidation("OWNER 角色不可通过 wiki member add 添加")
|
||||
toolArgs := map[string]any{"workspaceId": workspaceID}
|
||||
members, mErr := collectMembers(cmd, false)
|
||||
if mErr != nil {
|
||||
return mErr
|
||||
}
|
||||
userIds, err := collectUserIDs(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
if len(members) > 0 {
|
||||
for _, m := range members {
|
||||
if r, _ := m["roleId"].(string); r == "OWNER" {
|
||||
return apperrors.NewValidation("OWNER 角色不可通过 wiki member add 添加")
|
||||
}
|
||||
}
|
||||
toolArgs["members"] = members
|
||||
if cmd.Flags().Changed("notify") {
|
||||
notify, _ := cmd.Flags().GetBool("notify")
|
||||
toolArgs["notify"] = notify
|
||||
}
|
||||
} else {
|
||||
if err := validateRequiredFlags(cmd, "role"); err != nil {
|
||||
return err
|
||||
}
|
||||
role := normalizePermissionRole(mustGetFlag(cmd, "role"))
|
||||
if role == "OWNER" {
|
||||
return apperrors.NewValidation("OWNER 角色不可通过 wiki member add 添加")
|
||||
}
|
||||
userIds, err := collectUserIDs(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["roleId"] = role
|
||||
toolArgs["userIds"] = userIds
|
||||
}
|
||||
return callMCPTool("add_member", map[string]any{
|
||||
"workspaceId": workspaceID,
|
||||
"roleId": role,
|
||||
"userIds": userIds,
|
||||
})
|
||||
return callMCPTool("add_member", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(memberAddCmd, LeafSpec{
|
||||
@@ -605,6 +636,8 @@ func newWikiCommand() *cobra.Command {
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "members", Property: "members"},
|
||||
{Name: "notify", Property: "notify"},
|
||||
{Name: "role", Property: "roleId"},
|
||||
{Name: "users", Property: "userIds"},
|
||||
{Name: "workspace", Property: "workspaceId"},
|
||||
@@ -613,17 +646,30 @@ func newWikiCommand() *cobra.Command {
|
||||
})
|
||||
|
||||
memberAddCmd.Flags().String("workspace", "", "知识库 ID 或 URL (必填)")
|
||||
memberAddCmd.Flags().String("users", "", "被添加的用户 userId 列表,逗号分隔 (必填,单次最多 30 个)")
|
||||
memberAddCmd.Flags().String("users", "", "被添加的用户 userId 列表,逗号分隔 (旧格式,单次最多 30 个)")
|
||||
memberAddCmd.Flags().String("user", "", "")
|
||||
_ = memberAddCmd.Flags().MarkHidden("user")
|
||||
memberAddCmd.Flags().String("role", "", "权限角色: MANAGER / EDITOR / DOWNLOADER / READER (必填,大小写不敏感)")
|
||||
memberAddCmd.Flags().String("role", "", "权限角色: MANAGER / EDITOR / DOWNLOADER / READER (旧格式必填,大小写不敏感)")
|
||||
memberAddCmd.Flags().String("members", "", "成员列表 JSON 数组(新格式),支持 USER/DEPT/CONVERSATION/TAG 类型(TAG=角色组),与 --users 互斥")
|
||||
memberAddCmd.Flags().Bool("notify", false, "是否通知被添加的成员(仅 --members 新格式时生效,需显式传入才通知)")
|
||||
|
||||
memberUpdateCmd := &cobra.Command{
|
||||
Use: "update",
|
||||
Short: "更新知识库成员权限",
|
||||
Args: cobra.NoArgs,
|
||||
Long: `更新指定知识库已有成员的角色。
|
||||
|
||||
支持的角色 (--role)(必须大写):
|
||||
两种传参方式(互斥):
|
||||
旧格式:--users 传入逗号分隔的 userId 列表 + --role 指定统一角色(仅 USER 类型)
|
||||
新格式:--members 传入 JSON 数组,支持四种成员类型,每个 member 携带独立 roleId
|
||||
|
||||
成员类型说明:
|
||||
USER 用户,id 为用户 userId,需携带 corpId
|
||||
DEPT 部门,id 为部门 ID,需携带 corpId
|
||||
CONVERSATION 群聊,id 为群聊 conversationId(cid 开头),无需 corpId
|
||||
TAG 角色标签(也称角色组),id 为角色标签 ID,需携带 corpId
|
||||
|
||||
支持的角色(大小写不敏感):
|
||||
MANAGER 管理员
|
||||
EDITOR 编辑者
|
||||
DOWNLOADER 查看下载者
|
||||
@@ -632,28 +678,45 @@ func newWikiCommand() *cobra.Command {
|
||||
注意:
|
||||
- OWNER 角色不可通过此接口变更。
|
||||
- 同一成员在同一知识库只能拥有一个角色,变更后旧角色自动替换。
|
||||
- 操作者需具备知识库的 OWNER 或 MANAGER 权限。
|
||||
- 操作者须满足该知识库配置的权限管理最低角色要求(默认 MANAGER,可配置为 EDITOR 等),权限不足返回 forbidden.accessDenied。
|
||||
- --notify 仅在 --members 新格式时生效,仅对 USER 和 CONVERSATION 类型成员发送通知,默认 false。
|
||||
|
||||
仅可更新已存在成员关系的成员,新增成员请使用 dws wiki member add。`,
|
||||
Example: ` dws wiki member update --workspace <workspaceId> --users uid1 --role EDITOR
|
||||
dws wiki member update --workspace <workspaceId> --users uid1,uid2 --role READER`,
|
||||
dws wiki member update --workspace <workspaceId> --users uid1,uid2 --role READER
|
||||
dws wiki member update --workspace <workspaceId> --members '[{"type":"USER","id":"uid1","roleId":"EDITOR","corpId":"xxx"}]' --notify=false
|
||||
dws wiki member update --workspace <workspaceId> --members '[{"type":"TAG","id":"tagId1","roleId":"READER","corpId":"xxx"}]'`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
workspaceID, err := mustFlagOrFallback(cmd, "workspace", "workspace-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRequiredFlags(cmd, "role"); err != nil {
|
||||
if err := validateMembersExclusivity(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
userIds, err := collectUserIDs(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
toolArgs := map[string]any{"workspaceId": workspaceID}
|
||||
members, mErr := collectMembers(cmd, false)
|
||||
if mErr != nil {
|
||||
return mErr
|
||||
}
|
||||
return callMCPTool("update_member", map[string]any{
|
||||
"workspaceId": workspaceID,
|
||||
"roleId": normalizePermissionRole(mustGetFlag(cmd, "role")),
|
||||
"userIds": userIds,
|
||||
})
|
||||
if len(members) > 0 {
|
||||
toolArgs["members"] = members
|
||||
if cmd.Flags().Changed("notify") {
|
||||
notify, _ := cmd.Flags().GetBool("notify")
|
||||
toolArgs["notify"] = notify
|
||||
}
|
||||
} else {
|
||||
if err := validateRequiredFlags(cmd, "role"); err != nil {
|
||||
return err
|
||||
}
|
||||
userIds, err := collectUserIDs(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["roleId"] = normalizePermissionRole(mustGetFlag(cmd, "role"))
|
||||
toolArgs["userIds"] = userIds
|
||||
}
|
||||
return callMCPTool("update_member", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(memberUpdateCmd, LeafSpec{
|
||||
@@ -682,6 +745,8 @@ func newWikiCommand() *cobra.Command {
|
||||
Examples: []string{"dws wiki member update --workspace <WS_ID> --users uid1 --role EDITOR --format json"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "members", Property: "members"},
|
||||
{Name: "notify", Property: "notify"},
|
||||
{Name: "role", Property: "roleId"},
|
||||
{Name: "users", Property: "userIds"},
|
||||
{Name: "workspace", Property: "workspaceId"},
|
||||
@@ -690,10 +755,12 @@ func newWikiCommand() *cobra.Command {
|
||||
})
|
||||
|
||||
memberUpdateCmd.Flags().String("workspace", "", "知识库 ID 或 URL (必填)")
|
||||
memberUpdateCmd.Flags().String("users", "", "被更新的用户 userId 列表,逗号分隔 (必填,单次最多 30 个)")
|
||||
memberUpdateCmd.Flags().String("users", "", "被更新的用户 userId 列表,逗号分隔 (旧格式,单次最多 30 个)")
|
||||
memberUpdateCmd.Flags().String("user", "", "")
|
||||
_ = memberUpdateCmd.Flags().MarkHidden("user")
|
||||
memberUpdateCmd.Flags().String("role", "", "新权限角色: MANAGER / EDITOR / DOWNLOADER / READER (必填,大小写不敏感)")
|
||||
memberUpdateCmd.Flags().String("role", "", "新权限角色: MANAGER / EDITOR / DOWNLOADER / READER (旧格式必填,大小写不敏感)")
|
||||
memberUpdateCmd.Flags().String("members", "", "成员列表 JSON 数组(新格式),支持 USER/DEPT/CONVERSATION/TAG 类型(TAG=角色组),与 --users 互斥")
|
||||
memberUpdateCmd.Flags().Bool("notify", false, "是否通知被变更的成员(仅 --members 新格式时生效)")
|
||||
|
||||
memberListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
@@ -701,12 +768,15 @@ func newWikiCommand() *cobra.Command {
|
||||
Short: "查询知识库成员列表",
|
||||
Long: `查询指定知识库的成员列表,返回每位成员的 userId、姓名、角色等信息。
|
||||
|
||||
注意:底层不支持游标分页,--limit 仅控制单次返回的最大条数(最大 50)。
|
||||
若结果被截断(出参 truncated=true),可通过 --filter-role 收窄查询范围;
|
||||
底层一次性返回全量成员后在内存中按 pageSize 分页,支持通过 nextToken 翻页。
|
||||
出参包含 totalCount(全量成员总数)、hasMore(是否还有下一页)和 nextToken(下一页游标)。
|
||||
当 hasMore 为 true 时,传入下一次请求的 --next-token 即可获取下一页。
|
||||
操作者需满足该知识库配置的权限管理最低角色要求,权限不足返回 forbidden.accessDenied。
|
||||
ORG 类型授权不会出现在查询结果中。`,
|
||||
Example: ` dws wiki member list --workspace <workspaceId>
|
||||
dws wiki member list --workspace <workspaceId> --limit 100
|
||||
dws wiki member list --workspace <workspaceId> --filter-role MANAGER,EDITOR`,
|
||||
dws wiki member list --workspace <workspaceId> --limit 50
|
||||
dws wiki member list --workspace <workspaceId> --filter-role MANAGER,EDITOR
|
||||
dws wiki member list --workspace <workspaceId> --next-token <上次返回的 nextToken>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
workspaceID, err := mustFlagOrFallback(cmd, "workspace", "workspace-id")
|
||||
if err != nil {
|
||||
@@ -715,17 +785,13 @@ ORG 类型授权不会出现在查询结果中。`,
|
||||
toolArgs := map[string]any{
|
||||
"workspaceId": workspaceID,
|
||||
}
|
||||
limit := 0
|
||||
if cmd.Flags().Changed("limit") {
|
||||
limit, _ = cmd.Flags().GetInt("limit")
|
||||
} else if cmd.Flags().Changed("max-results") {
|
||||
limit, _ = cmd.Flags().GetInt("max-results")
|
||||
if size, ok, err := permissionPageSizeFromFlags(cmd); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
toolArgs["pageSize"] = size
|
||||
}
|
||||
if limit > 0 {
|
||||
if limit > 50 {
|
||||
return fmt.Errorf("--limit 不能超过 50;底层成员接口不提供游标续页")
|
||||
}
|
||||
toolArgs["maxResults"] = limit
|
||||
if v := flagOrFallback(cmd, "next-token", "cursor", "page-token"); v != "" {
|
||||
toolArgs["nextToken"] = v
|
||||
}
|
||||
if v := mustGetFlag(cmd, "filter-role"); v != "" {
|
||||
toolArgs["filterRoleIds"] = parseRoleList(v)
|
||||
@@ -763,50 +829,79 @@ ORG 类型授权不会出现在查询结果中。`,
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "filter-role", Property: "filterRoleIds"},
|
||||
{Name: "limit", Property: "maxResults"},
|
||||
// limit 不声明 Property:运行时经 cap 校验(1-50)转换为 pageSize,
|
||||
// 属 CLI 分页输入而非 1:1 RPC property(reviewed mapping exclusion)。
|
||||
{Name: "limit"},
|
||||
{Name: "next-token", Property: "nextToken"},
|
||||
{Name: "workspace", Property: "workspaceId"},
|
||||
},
|
||||
Pagination: &contract.PaginationSpec{Kind: contract.PaginationKindCursor, CursorParameter: "next-token"},
|
||||
},
|
||||
})
|
||||
|
||||
memberListCmd.Flags().String("workspace", "", "知识库 ID 或 URL (必填)")
|
||||
memberListCmd.Flags().Int("limit", 30, "返回成员数上限,默认 30,最大 50;底层不支持游标续页")
|
||||
memberListCmd.Flags().Int("limit", 30, "返回成员数上限,默认 30,最大 50")
|
||||
memberListCmd.Flags().Int("max-results", 0, "")
|
||||
_ = memberListCmd.Flags().MarkHidden("max-results")
|
||||
memberListCmd.Flags().String("filter-role", "", "按角色过滤(逗号分隔):OWNER / MANAGER / EDITOR / DOWNLOADER / READER")
|
||||
memberListCmd.Flags().String("next-token", "", "分页游标,首次不传,后续传入上一次返回的 nextToken")
|
||||
|
||||
memberRemoveCmd := &cobra.Command{
|
||||
Use: "remove",
|
||||
Short: "移除知识库成员",
|
||||
Long: `从指定知识库中移除一个或多个成员(仅支持 USER 类型)。
|
||||
Long: `从指定知识库中移除一个或多个成员。
|
||||
|
||||
两种传参方式(互斥):
|
||||
旧格式:--users 传入逗号分隔的 userId 列表(仅 USER 类型)
|
||||
新格式:--members 传入 JSON 数组,支持四种成员类型,只需 type 和 id(USER/DEPT/TAG 还需 corpId)
|
||||
|
||||
成员类型说明:
|
||||
USER 用户,id 为用户 userId,需携带 corpId
|
||||
DEPT 部门,id 为部门 ID,需携带 corpId
|
||||
CONVERSATION 群聊,id 为群聊 conversationId(cid 开头),无需 corpId
|
||||
TAG 角色标签(也称角色组),id 为角色标签 ID,需携带 corpId
|
||||
|
||||
移除后相关用户将无法访问该知识库下的内容(除非通过节点级权限另行授权)。
|
||||
|
||||
注意:
|
||||
- OWNER 角色不可通过此接口移除。
|
||||
- 操作者需具备知识库的 OWNER 或 MANAGER 权限。
|
||||
- 操作者须满足该知识库配置的权限管理最低角色要求(默认 MANAGER,可配置为 EDITOR 等),权限不足返回 forbidden.accessDenied。
|
||||
- 单次请求最多 30 个成员,超出请分批调用。`,
|
||||
Example: ` dws wiki member remove --workspace <workspaceId> --users uid1
|
||||
dws wiki member remove --workspace <workspaceId> --users uid1,uid2,uid3`,
|
||||
dws wiki member remove --workspace <workspaceId> --users uid1,uid2,uid3
|
||||
dws wiki member remove --workspace <workspaceId> --members '[{"type":"USER","id":"uid1","corpId":"xxx"},{"type":"DEPT","id":"deptId1","corpId":"xxx"}]'`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
workspaceID, err := mustFlagOrFallback(cmd, "workspace", "workspace-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
userIds, err := collectUserIDs(cmd)
|
||||
if err != nil {
|
||||
if err := validateMembersExclusivity(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPTool("remove_member", map[string]any{
|
||||
"workspaceId": workspaceID,
|
||||
"userIds": userIds,
|
||||
})
|
||||
toolArgs := map[string]any{"workspaceId": workspaceID}
|
||||
members, mErr := collectMembers(cmd, true)
|
||||
if mErr != nil {
|
||||
return mErr
|
||||
}
|
||||
if len(members) > 0 {
|
||||
toolArgs["members"] = members
|
||||
} else {
|
||||
userIds, err := collectUserIDs(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["userIds"] = userIds
|
||||
}
|
||||
return callMCPTool("remove_member", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(memberRemoveCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
// 批量移除(最多 30 个 USER/DEPT/CONVERSATION/TAG)会一次性撤销整个
|
||||
// 知识库容器级别的成员访问,部门/群聊/角色组还可能间接影响大量用户,
|
||||
// 与删除同级的 destructive 入口,必须经过用户确认(--yes 或交互 yes)。
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "not_required", Idempotency: "unknown",
|
||||
Confirmation: "user_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
@@ -816,14 +911,14 @@ ORG 类型授权不会出现在查询结果中。`,
|
||||
CLIPath: "wiki member remove",
|
||||
PrimaryCLIPath: "wiki member remove",
|
||||
},
|
||||
Description: "从指定知识库中移除一个或多个成员(仅支持 USER 类型)",
|
||||
Description: "从指定知识库中移除一个或多个成员",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "wiki", RPCName: "remove_member"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "从指定知识库中移除一个或多个成员(仅支持 USER 类型)",
|
||||
AgentSummary: "从指定知识库中移除一个或多个成员",
|
||||
UseWhen: []string{"从知识库移除成员访问(离职/清理)时"},
|
||||
AvoidWhen: []string{
|
||||
"改角色用 update;节点级权限用 drive permission remove",
|
||||
@@ -832,15 +927,17 @@ ORG 类型授权不会出现在查询结果中。`,
|
||||
Examples: []string{"dws wiki member remove --workspace <WS_ID> --users uid1 --format json"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "members", Property: "members"},
|
||||
{Name: "users", Property: "userIds"},
|
||||
{Name: "workspace", Property: "workspaceId"},
|
||||
},
|
||||
},
|
||||
})
|
||||
memberRemoveCmd.Flags().String("workspace", "", "知识库 ID 或 URL (必填)")
|
||||
memberRemoveCmd.Flags().String("users", "", "被移除的用户 userId 列表,逗号分隔 (必填,单次最多 30 个)")
|
||||
memberRemoveCmd.Flags().String("users", "", "被移除的用户 userId 列表,逗号分隔 (旧格式,单次最多 30 个)")
|
||||
memberRemoveCmd.Flags().String("user", "", "")
|
||||
_ = memberRemoveCmd.Flags().MarkHidden("user")
|
||||
memberRemoveCmd.Flags().String("members", "", "成员列表 JSON 数组(新格式),只需 type 和 id(USER/DEPT/TAG 还需 corpId),与 --users 互斥")
|
||||
|
||||
// member 子命令的 --workspace-id 隐藏别名(LLMs derive from API field "workspaceId")
|
||||
memberAliasCmds := []*cobra.Command{memberAddCmd, memberUpdateCmd, memberListCmd, memberRemoveCmd}
|
||||
|
||||
@@ -71,6 +71,13 @@ func patConfigDir() string {
|
||||
return filepath.Join(homeDir, ".dws")
|
||||
}
|
||||
|
||||
// BrowserPolicyConfigDir returns the exact configuration directory used by
|
||||
// the native PAT command. Shortcut adapters use this narrow seam so their
|
||||
// write/readback cycle cannot drift to a different policy file.
|
||||
func BrowserPolicyConfigDir() string {
|
||||
return patConfigDir()
|
||||
}
|
||||
|
||||
func LoadBrowserPolicy(configDir string) (*BrowserPolicy, error) {
|
||||
path := patPolicyPath(configDir)
|
||||
data, err := patPolicyReadFile(path)
|
||||
@@ -203,6 +210,32 @@ func SetBrowserPolicy(configDir, explicitAgentCode string, enabled bool) (Browse
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ReadStoredBrowserPolicy reads the exact default or explicitly named agent
|
||||
// entry without consulting the process agent-code environment. This is the
|
||||
// verification companion to SetBrowserPolicy: callers can prove the same
|
||||
// target was persisted rather than observing an environment-selected fallback.
|
||||
func ReadStoredBrowserPolicy(configDir, explicitAgentCode string) (BrowserPolicySelection, error) {
|
||||
agentCode, err := resolveBrowserPolicyWriteAgentCode(explicitAgentCode)
|
||||
if err != nil {
|
||||
return BrowserPolicySelection{}, err
|
||||
}
|
||||
policy, err := LoadBrowserPolicy(configDir)
|
||||
if err != nil {
|
||||
return BrowserPolicySelection{}, err
|
||||
}
|
||||
if agentCode != "" {
|
||||
entry, ok := policy.Agents[agentCode]
|
||||
if !ok {
|
||||
return BrowserPolicySelection{}, fmt.Errorf("PAT browser policy agent entry was not persisted")
|
||||
}
|
||||
return BrowserPolicySelection{Scope: "agent", AgentCode: agentCode, OpenBrowser: entry.OpenBrowser, Source: "agent"}, nil
|
||||
}
|
||||
if policy.Default == nil {
|
||||
return BrowserPolicySelection{}, fmt.Errorf("PAT browser policy default entry was not persisted")
|
||||
}
|
||||
return BrowserPolicySelection{Scope: "default", OpenBrowser: policy.Default.OpenBrowser, Source: "default"}, nil
|
||||
}
|
||||
|
||||
func newBrowserPolicyCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "browser-policy",
|
||||
|
||||
@@ -105,6 +105,9 @@ func TestCrossPlatformCoverageBrowserPolicyInjectedEdges(t *testing.T) {
|
||||
if _, err := LoadBrowserPolicy("x"); !errors.Is(err, failure) {
|
||||
t.Fatalf("read error = %v", err)
|
||||
}
|
||||
if _, err := ReadStoredBrowserPolicy("x", ""); !errors.Is(err, failure) {
|
||||
t.Fatalf("readback error = %v", err)
|
||||
}
|
||||
patPolicyReadFile = func(string) ([]byte, error) { return []byte(`{`), nil }
|
||||
if _, err := LoadBrowserPolicy("x"); err == nil {
|
||||
t.Fatal("invalid policy decoded")
|
||||
@@ -179,6 +182,53 @@ func TestCrossPlatformCoverageBrowserPolicyCommandSetError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageBrowserPolicyReadbackAndSelectionEdges(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
if got := BrowserPolicyConfigDir(); strings.TrimSpace(got) == "" {
|
||||
t.Fatal("browser policy config directory is empty")
|
||||
}
|
||||
if _, err := ReadStoredBrowserPolicy(configDir, "bad code"); err == nil {
|
||||
t.Fatal("invalid readback agent code succeeded")
|
||||
}
|
||||
if _, err := ReadStoredBrowserPolicy(configDir, "missing"); err == nil {
|
||||
t.Fatal("missing agent readback succeeded")
|
||||
}
|
||||
if _, err := ReadStoredBrowserPolicy(configDir, ""); err == nil {
|
||||
t.Fatal("missing default readback succeeded")
|
||||
}
|
||||
if _, err := SetBrowserPolicy(configDir, "agent", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
agent, err := ReadStoredBrowserPolicy(configDir, "agent")
|
||||
if err != nil || agent.Scope != "agent" || agent.AgentCode != "agent" || agent.OpenBrowser {
|
||||
t.Fatalf("agent readback = %#v, %v", agent, err)
|
||||
}
|
||||
resolvedAgent, err := ResolveBrowserPolicy(configDir, "agent")
|
||||
if err != nil || resolvedAgent.Source != "agent" || resolvedAgent.OpenBrowser {
|
||||
t.Fatalf("agent selection = %#v, %v", resolvedAgent, err)
|
||||
}
|
||||
if _, err := SetBrowserPolicy(configDir, "", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defaultPolicy, err := ReadStoredBrowserPolicy(configDir, "")
|
||||
if err != nil || defaultPolicy.Scope != "default" || !defaultPolicy.OpenBrowser {
|
||||
t.Fatalf("default readback = %#v, %v", defaultPolicy, err)
|
||||
}
|
||||
resolvedDefault, err := ResolveBrowserPolicy(configDir, "unknown")
|
||||
if err != nil || resolvedDefault.Source != "default" || !resolvedDefault.OpenBrowser {
|
||||
t.Fatalf("default selection = %#v, %v", resolvedDefault, err)
|
||||
}
|
||||
empty, err := ResolveBrowserPolicy(t.TempDir(), "")
|
||||
if err != nil || empty.Source == "" || !empty.OpenBrowser {
|
||||
t.Fatalf("built-in selection = %#v, %v", empty, err)
|
||||
}
|
||||
|
||||
cmd := newBrowserPolicyCommand()
|
||||
if err := cmd.Execute(); err == nil {
|
||||
t.Fatal("browser policy command accepted missing --enabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePATPureHelperEdges(t *testing.T) {
|
||||
if got := collectChmodProductCodes([]string{"", "a,a", " b "}, []string{"b", "c"}); strings.Join(got, ",") != "a,b,c" {
|
||||
t.Fatalf("product codes = %#v", got)
|
||||
|
||||
@@ -18,6 +18,7 @@ package pat
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
@@ -91,5 +92,17 @@ DWS_CHANNEL 只用于上游 channelCode。`,
|
||||
|
||||
patCmd.AddCommand(newChmodCommand(c))
|
||||
patCmd.AddCommand(newBrowserPolicyCommand())
|
||||
// Shortcuts are mounted before this open-source PAT tree in the app. Fold
|
||||
// that pre-existing service group into the native PAT parent so Cobra and
|
||||
// Schema both see exactly one top-level path while preserving PAT's group
|
||||
// behavior and adding every +leaf alongside chmod/browser-policy.
|
||||
for _, existing := range root.Commands() {
|
||||
if existing.Name() != patCmd.Name() {
|
||||
continue
|
||||
}
|
||||
cobracmd.MergeCommandTree(patCmd, existing)
|
||||
root.RemoveCommand(existing)
|
||||
break
|
||||
}
|
||||
root.AddCommand(patCmd)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package pat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageRegisterCommandsMergesExistingPATShortcutGroup(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
shortcutGroup := &cobra.Command{Use: "pat", Short: "pat shortcuts"}
|
||||
shortcutGroup.AddCommand(&cobra.Command{Use: "+fixture"})
|
||||
root.AddCommand(&cobra.Command{Use: "aaa-other"})
|
||||
root.AddCommand(shortcutGroup)
|
||||
|
||||
RegisterCommands(root, &fakeToolCaller{})
|
||||
|
||||
var patGroups []*cobra.Command
|
||||
for _, command := range root.Commands() {
|
||||
if command.Name() == "pat" {
|
||||
patGroups = append(patGroups, command)
|
||||
}
|
||||
}
|
||||
if len(patGroups) != 1 {
|
||||
t.Fatalf("top-level PAT groups = %d, want 1", len(patGroups))
|
||||
}
|
||||
for _, child := range []string{"+fixture", "chmod", "browser-policy"} {
|
||||
if found, _, err := patGroups[0].Find([]string{child}); err != nil || found == nil || found.Name() != child {
|
||||
t.Errorf("merged PAT child %q missing: found=%v err=%v", child, found, err)
|
||||
}
|
||||
}
|
||||
if patGroups[0].Short != "行为授权管理" {
|
||||
t.Fatalf("native PAT group metadata was not preserved: %q", patGroups[0].Short)
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ package aitable
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
|
||||
@@ -59,6 +60,19 @@ func parseJSONObject(flag, s string) (map[string]any, error) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// trimNonEmpty returns a copy of ss with each element trimmed of surrounding
|
||||
// whitespace and all empty entries dropped. Used to sanitize string-slice
|
||||
// flags (e.g. --field-ids) before they reach the downstream MCP tool.
|
||||
func trimNonEmpty(ss []string) []string {
|
||||
out := ss[:0]
|
||||
for _, s := range ss {
|
||||
if t := strings.TrimSpace(s); t != "" {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// resolveNamedList finds a list in a response envelope without conflating a
|
||||
// missing/malformed collection with a legitimate empty array. Some AITable MCP
|
||||
// versions use a direct collection key while others wrap it one level below
|
||||
|
||||
@@ -0,0 +1,534 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// This file declares the 7 datasource shortcuts for the aitable service:
|
||||
// create / update / sync / sync-status / get-config / list-sources /
|
||||
// get-fields. Each maps 1:1 onto a datasource MCP tool on the "aitable"
|
||||
// server. datasource-type is passed through without CLI-side enum
|
||||
// validation; source-config is validated as a JSON object but passed
|
||||
// through as a raw string (MCP types it as string).
|
||||
|
||||
package aitable
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// datasource: 数据源同步管理(server: aitable)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
// DatasourceCreate 为指定 AI 表格创建数据源同步配置(create_datasource)。
|
||||
var DatasourceCreate = shortcut.Shortcut{
|
||||
Service: "aitable",
|
||||
Command: "+datasource-create",
|
||||
Product: serverMain,
|
||||
Description: "为指定 AI 表格创建数据源同步配置,创建一张数据源表并触发首次全量同步。返回新建数据源表 ID 和同步任务 ID。",
|
||||
Intent: "当用户需要将外部数据源(如审批数据)接入 AI 表格时使用。创建后返回的表 ID 可用于后续同步、更新配置或查询状态。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "not_required", Idempotency: "non_idempotent",
|
||||
},
|
||||
Contract: corecmd.ContractDecl{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aitable",
|
||||
Name: "shortcut_datasource_create",
|
||||
CanonicalPath: "aitable.shortcut_datasource_create",
|
||||
CLIPath: "aitable +datasource-create",
|
||||
PrimaryCLIPath: "aitable +datasource-create",
|
||||
},
|
||||
Description: "为指定 AI 表格创建数据源同步配置,创建一张数据源表并触发首次全量同步。返回新建数据源表 ID 和同步任务 ID。",
|
||||
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: "为指定 AI 表格创建数据源同步配置,创建一张数据源表并触发首次全量同步。返回新建数据源表 ID 和同步任务 ID。",
|
||||
UseWhen: []string{"当用户需要将外部数据源(如审批数据)接入 AI 表格时使用。创建后返回的表 ID 可用于后续同步、更新配置或查询状态。"},
|
||||
AvoidWhen: []string{
|
||||
"目标 Base 已有数据源表且仅需更新配置时(改用 +datasource-update)",
|
||||
"仅需触发已有数据源表的同步时(改用 +datasource-sync)",
|
||||
},
|
||||
Examples: []string{
|
||||
`dws aitable +datasource-create --base-id BASE123 --datasource-type OA --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`,
|
||||
`dws aitable +datasource-create --base-id BASE123 --datasource-type OA --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"time_range","startDate":"2025-01-01","endDate":"2025-12-31","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}' --auto`,
|
||||
},
|
||||
},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "base-id", Type: shortcut.FlagString, Desc: "目标 Base ID(通过 +base-list / +base-search 获取)", Required: true},
|
||||
{Name: "datasource-type", Type: shortcut.FlagString, Desc: "数据源类型,目前支持审批(OA)", Required: true},
|
||||
{Name: "source-config", Type: shortcut.FlagString, Desc: "源配置 JSON 字符串。字段分为两类:须从 +datasource-list-sources 结果原样透传的字段(必填):processCode(审批流程编码)、name(展示名称)、iconUrl(图标 URL)、url(跳转链接);调用方自行设置的字段:dataType(必填,time_range/start_time/recent_time)、recentDays(dataType=recent_time 时有效,7d/30d/1y,默认 30d)、startDate(dataType=time_range/start_time 时有效,yyyy-MM-dd,默认 30 天前)、endDate(dataType=time_range 时有效,yyyy-MM-dd,默认当天)、keepRemovedFields(是否保留已删除字段,默认 false)。约定:syncAll 固定为 true;splitParentTableField 与 enableDataSyncOaDetailList 为下游内部字段,无需传入", Required: true},
|
||||
{Name: "auto", Type: shortcut.FlagBool, Desc: "是否开启自动同步,默认 false;创建新数据源表时该字段始终下发给下游"},
|
||||
{Name: "field-ids", Type: shortcut.FlagStringSlice, Desc: "需要同步的字段 ID 列表,不传时保持现有配置(创建时默认为全部字段)"},
|
||||
{Name: "auto-sync-setting", Type: shortcut.FlagString, Desc: "自动同步频率配置 JSON 字符串,仅在 --auto=true 时生效。字段:syncType(必填,hourly=按小时间隔,scheduled=定时触发)、hourlyInterval(syncType=hourly 时必填,正整数小时)、scheduleType(syncType=scheduled 时必填,daily/weekly/monthly)、timeValue(syncType=scheduled 时必填,HH:mm)、selectedMonthDays(scheduleType=monthly 时必填,每月几号触发,1-31)、selectedWeekdays(scheduleType=weekly 时必填,每周哪几天触发,1=周一…7=周日)、skipNonWorkingDay(可选,默认 false)。不传时使用下游默认自动同步策略"},
|
||||
},
|
||||
Tips: []string{
|
||||
`dws aitable +datasource-create --base-id BASE123 --datasource-type OA --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`,
|
||||
`dws aitable +datasource-create --base-id BASE123 --datasource-type OA --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"time_range","startDate":"2025-01-01","endDate":"2025-12-31","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}' --auto`,
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
if _, err := parseJSONObject("source-config", rt.Str("source-config")); err != nil {
|
||||
return err
|
||||
}
|
||||
params := map[string]any{
|
||||
"baseId": rt.Str("base-id"),
|
||||
"datasourceType": rt.Str("datasource-type"),
|
||||
"sourceConfig": rt.Str("source-config"),
|
||||
}
|
||||
params["auto"] = rt.Bool("auto")
|
||||
if rt.Changed("field-ids") {
|
||||
raw := rt.StrSlice("field-ids")
|
||||
cleaned := trimNonEmpty(raw)
|
||||
if len(cleaned) == 0 {
|
||||
return fmt.Errorf("--field-ids 显式提供时不能为空,如需保持默认请勿传入")
|
||||
}
|
||||
params["fieldIds"] = cleaned
|
||||
}
|
||||
if rt.Changed("auto-sync-setting") {
|
||||
v := rt.Str("auto-sync-setting")
|
||||
if v == "" {
|
||||
return fmt.Errorf("--auto-sync-setting 显式提供时不能为空,如需保持默认请勿传入")
|
||||
}
|
||||
if _, err := parseJSONObject("auto-sync-setting", v); err != nil {
|
||||
return err
|
||||
}
|
||||
params["autoSyncSetting"] = v
|
||||
}
|
||||
data, err := rt.CallMCPData(serverMain, "create_datasource", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(data)
|
||||
},
|
||||
}
|
||||
|
||||
// DatasourceUpdate 更新已有数据源表的同步配置(update_datasource_config)。
|
||||
var DatasourceUpdate = shortcut.Shortcut{
|
||||
Service: "aitable",
|
||||
Command: "+datasource-update",
|
||||
Product: serverMain,
|
||||
Description: "更新指定 AI 表格中已有数据源表的同步配置,支持更新源配置、自动同步开关和同步字段选择。更新后触发一次同步。仅适用于数据源表。",
|
||||
Intent: "当用户需要修改已有数据源表的配置(如更换审批模板、调整同步字段、开关自动同步)时使用。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "not_required", Idempotency: "non_idempotent",
|
||||
},
|
||||
Contract: corecmd.ContractDecl{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aitable",
|
||||
Name: "shortcut_datasource_update",
|
||||
CanonicalPath: "aitable.shortcut_datasource_update",
|
||||
CLIPath: "aitable +datasource-update",
|
||||
PrimaryCLIPath: "aitable +datasource-update",
|
||||
},
|
||||
Description: "更新指定 AI 表格中已有数据源表的同步配置,支持更新源配置、自动同步开关和同步字段选择。更新后触发一次同步。仅适用于数据源表。",
|
||||
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: "更新指定 AI 表格中已有数据源表的同步配置,支持更新源配置、自动同步开关和同步字段选择。更新后触发一次同步。仅适用于数据源表。",
|
||||
UseWhen: []string{"当用户需要修改已有数据源表的配置(如更换审批模板、调整同步字段、开关自动同步)时使用。"},
|
||||
AvoidWhen: []string{
|
||||
"需要创建新数据源表时(改用 +datasource-create)",
|
||||
"仅需触发同步不改配置时(改用 +datasource-sync)",
|
||||
},
|
||||
Examples: []string{
|
||||
`dws aitable +datasource-update --base-id BASE123 --table-id TBL456 --auto`,
|
||||
`dws aitable +datasource-update --base-id BASE123 --table-id TBL456 --source-config '{"processCode":"PROC-YYYY","name":"出差申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`,
|
||||
},
|
||||
},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "base-id", Type: shortcut.FlagString, Desc: "目标 Base ID", Required: true},
|
||||
{Name: "table-id", Type: shortcut.FlagString, Desc: "已存在的数据源表 ID(通过 +base-get / +table-list 获取,仅允许传入 sync=true 的数据源表)", Required: true},
|
||||
{Name: "source-config", Type: shortcut.FlagString, Desc: "可选。新的源配置 JSON 字符串。不传时保持原有配置不变;传入时整体覆盖。字段分为两类:须从 +datasource-list-sources 结果原样透传的字段(必填):processCode(审批流程编码)、name(展示名称)、iconUrl(图标 URL)、url(跳转链接);调用方自行设置的字段:dataType(必填,time_range/start_time/recent_time)、recentDays(dataType=recent_time 时有效,7d/30d/1y,默认 30d)、startDate(dataType=time_range/start_time 时有效,yyyy-MM-dd,默认 30 天前)、endDate(dataType=time_range 时有效,yyyy-MM-dd,默认当天)、keepRemovedFields(默认 false)。约定:syncAll 固定为 true;splitParentTableField 与 enableDataSyncOaDetailList 为下游内部字段,无需传入"},
|
||||
{Name: "auto", Type: shortcut.FlagBool, Desc: "可选。是否开启自动同步;仅显式设置时下发给下游,省略时保持原有自动同步开关不变"},
|
||||
{Name: "field-ids", Type: shortcut.FlagStringSlice, Desc: "需要同步的字段 ID 列表,不传时保持现有配置(创建时默认为全部字段)"},
|
||||
{Name: "auto-sync-setting", Type: shortcut.FlagString, Desc: "可选。自动同步频率配置 JSON 字符串,仅在显式设置 --auto=true 时生效;省略时保持原有自动同步频率配置。字段:syncType(必填,hourly=按小时间隔,scheduled=定时触发)、hourlyInterval(syncType=hourly 时必填,正整数小时)、scheduleType(syncType=scheduled 时必填,daily/weekly/monthly)、timeValue(syncType=scheduled 时必填,HH:mm)、selectedMonthDays(scheduleType=monthly 时必填,每月几号触发,1-31)、selectedWeekdays(scheduleType=weekly 时必填,每周哪几天触发,1=周一…7=周日)、skipNonWorkingDay(可选,默认 false)"},
|
||||
},
|
||||
Tips: []string{
|
||||
`dws aitable +datasource-update --base-id BASE123 --table-id TBL456 --auto`,
|
||||
`dws aitable +datasource-update --base-id BASE123 --table-id TBL456 --source-config '{"processCode":"PROC-YYYY","name":"出差申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`,
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{
|
||||
"baseId": rt.Str("base-id"),
|
||||
"tableId": rt.Str("table-id"),
|
||||
}
|
||||
if rt.Changed("source-config") {
|
||||
if _, err := parseJSONObject("source-config", rt.Str("source-config")); err != nil {
|
||||
return err
|
||||
}
|
||||
params["sourceConfig"] = rt.Str("source-config")
|
||||
}
|
||||
if rt.Changed("auto") {
|
||||
params["auto"] = rt.Bool("auto")
|
||||
}
|
||||
if rt.Changed("field-ids") {
|
||||
raw := rt.StrSlice("field-ids")
|
||||
cleaned := trimNonEmpty(raw)
|
||||
if len(cleaned) == 0 {
|
||||
return fmt.Errorf("--field-ids 显式提供时不能为空,如需保持默认请勿传入")
|
||||
}
|
||||
params["fieldIds"] = cleaned
|
||||
}
|
||||
if rt.Changed("auto-sync-setting") {
|
||||
v := rt.Str("auto-sync-setting")
|
||||
if v == "" {
|
||||
return fmt.Errorf("--auto-sync-setting 显式提供时不能为空,如需保持默认请勿传入")
|
||||
}
|
||||
if _, err := parseJSONObject("auto-sync-setting", v); err != nil {
|
||||
return err
|
||||
}
|
||||
params["autoSyncSetting"] = v
|
||||
}
|
||||
if !rt.Changed("source-config") && !rt.Changed("auto") && !rt.Changed("field-ids") && !rt.Changed("auto-sync-setting") {
|
||||
return fmt.Errorf("至少需要一个配置变更:--source-config、--auto、--field-ids 或 --auto-sync-setting;仅触发同步请使用 +datasource-sync")
|
||||
}
|
||||
data, err := rt.CallMCPData(serverMain, "update_datasource_config", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(data)
|
||||
},
|
||||
}
|
||||
|
||||
// DatasourceSync 对数据源表触发一次手动同步(run_datasource_sync)。
|
||||
var DatasourceSync = shortcut.Shortcut{
|
||||
Service: "aitable",
|
||||
Command: "+datasource-sync",
|
||||
Product: serverMain,
|
||||
Description: "对指定 AI 表格中的数据源表触发一次手动同步。单次最多 5 张表,每张表独立提交,部分失败不影响其他表。该工具仅触发任务即返回,不会等待同步完成。返回结果包含文档链接,用户可打开文档查看同步进度与最终数据。同步运行中(errorCode=4014)属于幂等冲突,会被标记为 failed 并允许调用方稍后重试。非数据源表(sync=false)不能用此工具触发同步,会以参数错误返回。",
|
||||
Intent: "当用户需要手动触发已有数据源表的同步(而非创建或更新配置)时使用。同步任务 ID 可通过 +datasource-sync-status 查询结果。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "not_required", Idempotency: "non_idempotent",
|
||||
},
|
||||
Contract: corecmd.ContractDecl{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aitable",
|
||||
Name: "shortcut_datasource_sync",
|
||||
CanonicalPath: "aitable.shortcut_datasource_sync",
|
||||
CLIPath: "aitable +datasource-sync",
|
||||
PrimaryCLIPath: "aitable +datasource-sync",
|
||||
},
|
||||
Description: "对指定 AI 表格中的数据源表触发一次手动同步。单次最多 5 张表,每张表独立提交,部分失败不影响其他表。该工具仅触发任务即返回,不会等待同步完成。返回结果包含文档链接,用户可打开文档查看同步进度与最终数据。同步运行中(errorCode=4014)属于幂等冲突,会被标记为 failed 并允许调用方稍后重试。非数据源表(sync=false)不能用此工具触发同步,会以参数错误返回。",
|
||||
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: "对指定 AI 表格中的数据源表触发一次手动同步。单次最多 5 张表,每张表独立提交,部分失败不影响其他表。该工具仅触发任务即返回,不会等待同步完成。返回结果包含文档链接,用户可打开文档查看同步进度与最终数据。同步运行中(errorCode=4014)属于幂等冲突,会被标记为 failed 并允许调用方稍后重试。非数据源表(sync=false)不能用此工具触发同步,会以参数错误返回。",
|
||||
UseWhen: []string{"当用户需要手动触发已有数据源表的同步(而非创建或更新配置)时使用。同步任务 ID 可通过 +datasource-sync-status 查询结果。"},
|
||||
AvoidWhen: []string{
|
||||
"需要创建新数据源表时(改用 +datasource-create)",
|
||||
"需要更新配置时(改用 +datasource-update)",
|
||||
},
|
||||
Examples: []string{
|
||||
`dws aitable +datasource-sync --base-id BASE123 --table-ids TBL1,TBL2`,
|
||||
`dws aitable +datasource-sync --base-id BASE123 --table-ids TBL1`,
|
||||
},
|
||||
},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "base-id", Type: shortcut.FlagString, Desc: "目标 Base ID", Required: true},
|
||||
{Name: "table-ids", Type: shortcut.FlagStringSlice, Desc: "待触发同步的数据源表 ID 列表(通过 +base-get / +table-list 获取,仅允许 sync=true 的表,1-5 个)", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`dws aitable +datasource-sync --base-id BASE123 --table-ids TBL1,TBL2`,
|
||||
`dws aitable +datasource-sync --base-id BASE123 --table-ids TBL1`,
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
rawTableIDs := rt.StrSlice("table-ids")
|
||||
tableIDs := trimNonEmpty(rawTableIDs)
|
||||
if len(tableIDs) < 1 || len(tableIDs) > 5 {
|
||||
return fmt.Errorf("--table-ids requires 1-5 table IDs, got %d", len(tableIDs))
|
||||
}
|
||||
params := map[string]any{
|
||||
"baseId": rt.Str("base-id"),
|
||||
"tableIds": tableIDs,
|
||||
}
|
||||
data, err := rt.CallMCPData(serverMain, "run_datasource_sync", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(data)
|
||||
},
|
||||
}
|
||||
|
||||
// DatasourceSyncStatus 查询数据源表同步任务状态(get_datasource_sync_status)。
|
||||
var DatasourceSyncStatus = shortcut.Shortcut{
|
||||
Service: "aitable",
|
||||
Command: "+datasource-sync-status",
|
||||
Product: serverMain,
|
||||
Description: "按任务 ID 查询指定数据源表的同步任务状态。与 +datasource-sync / +datasource-create / +datasource-update 配对使用:这些指令触发同步后返回 taskId,本指令通过 taskId 查询最终结果。支持批量查询(单次最多 5 个 taskId),整体仍返回 success;需遍历 tasks 数组按单条 status 判断真实结果。任务状态包括:RUNNING(同步进行中)、FINISHED(同步完成)、FAILED(同步失败)。失败时会返回 errorCode 和 errorMessage 供排查。",
|
||||
Intent: "当用户触发同步后需要按 taskId 查询同步是否完成、成功或失败时使用。支持批量查询(单次最多 5 个任务 ID)。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: corecmd.ContractDecl{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aitable",
|
||||
Name: "shortcut_datasource_sync_status",
|
||||
CanonicalPath: "aitable.shortcut_datasource_sync_status",
|
||||
CLIPath: "aitable +datasource-sync-status",
|
||||
PrimaryCLIPath: "aitable +datasource-sync-status",
|
||||
},
|
||||
Description: "按任务 ID 查询指定数据源表的同步任务状态。与 +datasource-sync / +datasource-create / +datasource-update 配对使用:这些指令触发同步后返回 taskId,本指令通过 taskId 查询最终结果。支持批量查询(单次最多 5 个 taskId),整体仍返回 success;需遍历 tasks 数组按单条 status 判断真实结果。任务状态包括:RUNNING(同步进行中)、FINISHED(同步完成)、FAILED(同步失败)。失败时会返回 errorCode 和 errorMessage 供排查。",
|
||||
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: "按任务 ID 查询指定数据源表的同步任务状态。与 +datasource-sync / +datasource-create / +datasource-update 配对使用:这些指令触发同步后返回 taskId,本指令通过 taskId 查询最终结果。支持批量查询(单次最多 5 个 taskId),整体仍返回 success;需遍历 tasks 数组按单条 status 判断真实结果。任务状态包括:RUNNING(同步进行中)、FINISHED(同步完成)、FAILED(同步失败)。失败时会返回 errorCode 和 errorMessage 供排查。",
|
||||
UseWhen: []string{"当用户触发同步后需要按 taskId 查询同步是否完成、成功或失败时使用。支持批量查询(单次最多 5 个任务 ID)。"},
|
||||
AvoidWhen: []string{
|
||||
"需要触发同步时(改用 +datasource-sync)",
|
||||
},
|
||||
Examples: []string{
|
||||
`dws aitable +datasource-sync-status --base-id BASE123 --table-id TBL456 --task-ids TASK1,TASK2`,
|
||||
},
|
||||
},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "base-id", Type: shortcut.FlagString, Desc: "目标 Base ID", Required: true},
|
||||
{Name: "table-id", Type: shortcut.FlagString, Desc: "数据源表 ID(通过 +base-get / +table-list 获取,仅允许传入 sync=true 的表)", Required: true},
|
||||
{Name: "task-ids", Type: shortcut.FlagStringSlice, Desc: "待查询的同步任务 ID 列表(由 +datasource-sync / +datasource-create / +datasource-update 返回)。单次最多 5 个,超出请拆分多次调用。", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`dws aitable +datasource-sync-status --base-id BASE123 --table-id TBL456 --task-ids TASK1,TASK2`,
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{
|
||||
"baseId": rt.Str("base-id"),
|
||||
"tableId": rt.Str("table-id"),
|
||||
}
|
||||
rawTaskIDs := rt.StrSlice("task-ids")
|
||||
taskIDs := trimNonEmpty(rawTaskIDs)
|
||||
if len(taskIDs) < 1 || len(taskIDs) > 5 {
|
||||
return fmt.Errorf("--task-ids requires 1-5 task IDs, got %d", len(taskIDs))
|
||||
}
|
||||
params["taskIds"] = taskIDs
|
||||
data, err := rt.CallMCPData(serverMain, "get_datasource_sync_status", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(data)
|
||||
},
|
||||
}
|
||||
|
||||
// DatasourceGetConfig 获取数据源表的同步配置信息(get_datasource_config)。
|
||||
var DatasourceGetConfig = shortcut.Shortcut{
|
||||
Service: "aitable",
|
||||
Command: "+datasource-get-config",
|
||||
Product: serverMain,
|
||||
Description: "获取指定数据源表的同步配置信息,包括源配置、是否全量同步、是否自动同步、同步状态等。仅适用于数据源表(sync=true),普通表会返回错误。仅支持 OA 审批数据源(datasourceType=OA),其他数据源类型暂不支持,待后续开放。返回的 sourceConfig 包含数据源连接信息(如审批模板 ID、源表 ID 等)。",
|
||||
Intent: "当用户需要查看已有数据源表的配置详情(如确认当前同步的审批模板、字段选择、自动同步状态)时使用。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: corecmd.ContractDecl{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aitable",
|
||||
Name: "shortcut_datasource_get_config",
|
||||
CanonicalPath: "aitable.shortcut_datasource_get_config",
|
||||
CLIPath: "aitable +datasource-get-config",
|
||||
PrimaryCLIPath: "aitable +datasource-get-config",
|
||||
},
|
||||
Description: "获取指定数据源表的同步配置信息,包括源配置、是否全量同步、是否自动同步、同步状态等。仅适用于数据源表(sync=true),普通表会返回错误。仅支持 OA 审批数据源(datasourceType=OA),其他数据源类型暂不支持,待后续开放。返回的 sourceConfig 包含数据源连接信息(如审批模板 ID、源表 ID 等)。",
|
||||
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: "获取指定数据源表的同步配置信息,包括源配置、是否全量同步、是否自动同步、同步状态等。仅适用于数据源表(sync=true),普通表会返回错误。仅支持 OA 审批数据源(datasourceType=OA),其他数据源类型暂不支持,待后续开放。返回的 sourceConfig 包含数据源连接信息(如审批模板 ID、源表 ID 等)。",
|
||||
UseWhen: []string{"当用户需要查看已有数据源表的配置详情(如确认当前同步的审批模板、字段选择、自动同步状态)时使用。"},
|
||||
AvoidWhen: []string{
|
||||
"需要更新配置时(改用 +datasource-update)",
|
||||
"需要查询同步任务状态时(改用 +datasource-sync-status)",
|
||||
},
|
||||
Examples: []string{
|
||||
`dws aitable +datasource-get-config --base-id BASE123 --table-id TBL456`,
|
||||
},
|
||||
},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "base-id", Type: shortcut.FlagString, Desc: "目标 Base ID", Required: true},
|
||||
{Name: "table-id", Type: shortcut.FlagString, Desc: "数据源表 ID(通过 +base-get / +table-list 获取,仅允许传入 sync=true 的表)", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`dws aitable +datasource-get-config --base-id BASE123 --table-id TBL456`,
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{
|
||||
"baseId": rt.Str("base-id"),
|
||||
"tableId": rt.Str("table-id"),
|
||||
}
|
||||
data, err := rt.CallMCPData(serverMain, "get_datasource_config", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(data)
|
||||
},
|
||||
}
|
||||
|
||||
// DatasourceListSources 列出指定数据源类型可用的来源(list_datasource_sources)。
|
||||
var DatasourceListSources = shortcut.Shortcut{
|
||||
Service: "aitable",
|
||||
Command: "+datasource-list-sources",
|
||||
Product: serverMain,
|
||||
Description: "列出指定 Base 下可用的数据源条目。仅支持 OA 审批数据源(datasourceType=OA)。返回的每条条目包含 result 字段(下游原始 JSON 字符串)和 sourceType 字段(OA 审批对应 2,仅供参考)。OA 审批场景下 result 为包含 approvals 数组的 JSON 字符串,每个 approval 包含 processCode、name、iconUrl、url、keepRemovedFields、splitParentTableField 等字段。须原样透传至 sourceConfig 的字段(仅以下 4 个):processCode、name、iconUrl、url;调用方自行设置的字段(即使 result 中有值也不透传):keepRemovedFields、splitParentTableField;enableDataSyncOaDetailList 为下游内部字段,无需传入 sourceConfig。调用方应自行解析 result,提取目标模板字段后构造 sourceConfig 传入 +datasource-create。",
|
||||
Intent: "当用户需要查看某类数据源(如审批)的可用来源信息、获取 result/processCode 以便创建或更新数据源配置时使用。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: corecmd.ContractDecl{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aitable",
|
||||
Name: "shortcut_datasource_list_sources",
|
||||
CanonicalPath: "aitable.shortcut_datasource_list_sources",
|
||||
CLIPath: "aitable +datasource-list-sources",
|
||||
PrimaryCLIPath: "aitable +datasource-list-sources",
|
||||
},
|
||||
Description: "列出指定 Base 下可用的数据源条目。仅支持 OA 审批数据源(datasourceType=OA)。返回的每条条目包含 result 字段(下游原始 JSON 字符串)和 sourceType 字段(OA 审批对应 2,仅供参考)。OA 审批场景下 result 为包含 approvals 数组的 JSON 字符串,每个 approval 包含 processCode、name、iconUrl、url、keepRemovedFields、splitParentTableField 等字段。须原样透传至 sourceConfig 的字段(仅以下 4 个):processCode、name、iconUrl、url;调用方自行设置的字段(即使 result 中有值也不透传):keepRemovedFields、splitParentTableField;enableDataSyncOaDetailList 为下游内部字段,无需传入 sourceConfig。调用方应自行解析 result,提取目标模板字段后构造 sourceConfig 传入 +datasource-create。",
|
||||
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: "列出指定 Base 下可用的数据源条目。仅支持 OA 审批数据源(datasourceType=OA)。返回的每条条目包含 result 字段(下游原始 JSON 字符串)和 sourceType 字段(OA 审批对应 2,仅供参考)。OA 审批场景下 result 为包含 approvals 数组的 JSON 字符串,每个 approval 包含 processCode、name、iconUrl、url、keepRemovedFields、splitParentTableField 等字段。须原样透传至 sourceConfig 的字段(仅以下 4 个):processCode、name、iconUrl、url;调用方自行设置的字段(即使 result 中有值也不透传):keepRemovedFields、splitParentTableField;enableDataSyncOaDetailList 为下游内部字段,无需传入 sourceConfig。调用方应自行解析 result,提取目标模板字段后构造 sourceConfig 传入 +datasource-create。",
|
||||
UseWhen: []string{"当用户需要查看某类数据源(如审批)的可用来源信息、获取 result/processCode 以便创建或更新数据源配置时使用。"},
|
||||
AvoidWhen: []string{
|
||||
"需要创建数据源表时(改用 +datasource-create)",
|
||||
"需要获取数据源字段结构时(改用 +datasource-get-fields)",
|
||||
},
|
||||
Examples: []string{
|
||||
`dws aitable +datasource-list-sources --base-id BASE123 --datasource-type OA`,
|
||||
},
|
||||
},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "base-id", Type: shortcut.FlagString, Desc: "目标 Base ID", Required: true},
|
||||
{Name: "datasource-type", Type: shortcut.FlagString, Desc: "数据源类型,目前支持审批(OA)", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`dws aitable +datasource-list-sources --base-id BASE123 --datasource-type OA`,
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{
|
||||
"baseId": rt.Str("base-id"),
|
||||
"datasourceType": rt.Str("datasource-type"),
|
||||
}
|
||||
data, err := rt.CallMCPData(serverMain, "list_datasource_sources", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(data)
|
||||
},
|
||||
}
|
||||
|
||||
// DatasourceGetFields 获取指定数据源来源的可同步字段列表(get_datasource_fields)。
|
||||
var DatasourceGetFields = shortcut.Shortcut{
|
||||
Service: "aitable",
|
||||
Command: "+datasource-get-fields",
|
||||
Product: serverMain,
|
||||
Description: "获取指定数据源下可供同步的字段列表,用于在 +datasource-create / +datasource-update 中决定同步哪些字段。传入从 +datasource-list-sources 获取的 sourceConfig。仅支持 OA 审批数据源(datasourceType=OA),其他数据源类型暂不支持,待后续开放。",
|
||||
Intent: "当用户需要查看某数据源来源有哪些可同步字段、以便在创建或更新数据源时指定 field-ids 时使用。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: corecmd.ContractDecl{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aitable",
|
||||
Name: "shortcut_datasource_get_fields",
|
||||
CanonicalPath: "aitable.shortcut_datasource_get_fields",
|
||||
CLIPath: "aitable +datasource-get-fields",
|
||||
PrimaryCLIPath: "aitable +datasource-get-fields",
|
||||
},
|
||||
Description: "获取指定数据源下可供同步的字段列表,用于在 +datasource-create / +datasource-update 中决定同步哪些字段。传入从 +datasource-list-sources 获取的 sourceConfig。仅支持 OA 审批数据源(datasourceType=OA),其他数据源类型暂不支持,待后续开放。",
|
||||
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: "获取指定数据源下可供同步的字段列表,用于在 +datasource-create / +datasource-update 中决定同步哪些字段。传入从 +datasource-list-sources 获取的 sourceConfig。仅支持 OA 审批数据源(datasourceType=OA),其他数据源类型暂不支持,待后续开放。",
|
||||
UseWhen: []string{"当用户需要查看某数据源来源有哪些可同步字段、以便在创建或更新数据源时指定 field-ids 时使用。"},
|
||||
AvoidWhen: []string{
|
||||
"需要列出可用来源时(改用 +datasource-list-sources)",
|
||||
"需要创建数据源表时(改用 +datasource-create)",
|
||||
},
|
||||
Examples: []string{
|
||||
`dws aitable +datasource-get-fields --base-id BASE123 --datasource-type OA --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`,
|
||||
},
|
||||
},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "base-id", Type: shortcut.FlagString, Desc: "目标 Base ID", Required: true},
|
||||
{Name: "datasource-type", Type: shortcut.FlagString, Desc: "数据源类型,目前支持审批(OA)", Required: true},
|
||||
{Name: "source-config", Type: shortcut.FlagString, Desc: "源配置 JSON 字符串。结构同 +datasource-create 的 --source-config,需含 processCode、name、iconUrl、url、dataType 及对应时间字段", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`dws aitable +datasource-get-fields --base-id BASE123 --datasource-type OA --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`,
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
if _, err := parseJSONObject("source-config", rt.Str("source-config")); err != nil {
|
||||
return err
|
||||
}
|
||||
params := map[string]any{
|
||||
"baseId": rt.Str("base-id"),
|
||||
"datasourceType": rt.Str("datasource-type"),
|
||||
"sourceConfig": rt.Str("source-config"),
|
||||
}
|
||||
data, err := rt.CallMCPData(serverMain, "get_datasource_fields", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(data)
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
shortcut.Register(withReviewedAITableShortcutContracts(
|
||||
DatasourceCreate,
|
||||
DatasourceUpdate,
|
||||
DatasourceSync,
|
||||
DatasourceSyncStatus,
|
||||
DatasourceGetConfig,
|
||||
DatasourceListSources,
|
||||
DatasourceGetFields,
|
||||
)...)
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package aitable
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
type datasourceCoverageCaller struct {
|
||||
err error
|
||||
resp string
|
||||
argLog []map[string]any
|
||||
}
|
||||
|
||||
func (c *datasourceCoverageCaller) CallTool(_ context.Context, _, _ string, args map[string]any) (*edition.ToolResult, error) {
|
||||
c.argLog = append(c.argLog, args)
|
||||
if c.err != nil {
|
||||
return nil, c.err
|
||||
}
|
||||
text := c.resp
|
||||
if text == "" {
|
||||
text = `{"status":"success","data":{}}`
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
|
||||
}
|
||||
|
||||
func (c *datasourceCoverageCaller) Format() string { return "json" }
|
||||
func (c *datasourceCoverageCaller) DryRun() bool { return false }
|
||||
func (c *datasourceCoverageCaller) Fields() string { return "" }
|
||||
func (c *datasourceCoverageCaller) JQ() string { return "" }
|
||||
|
||||
func runDatasourceShortcutCLI(t *testing.T, caller *datasourceCoverageCaller, args ...string) error {
|
||||
t.Helper()
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs(append([]string{"aitable"}, args...))
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
// ── DatasourceCreate error paths ─────────────────────────────────────────────
|
||||
|
||||
func TestCrossPlatformCoverageDatasourceCreateRejectsInvalidSourceConfig(t *testing.T) {
|
||||
err := runDatasourceShortcutCLI(t, &datasourceCoverageCaller{},
|
||||
"+datasource-create", "--base-id", "BASE1", "--datasource-type", "OA",
|
||||
"--source-config", "not-json")
|
||||
if err == nil || !strings.Contains(err.Error(), "source-config") {
|
||||
t.Fatalf("error = %v, want source-config parse error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDatasourceCreateRejectsInvalidAutoSyncSetting(t *testing.T) {
|
||||
err := runDatasourceShortcutCLI(t, &datasourceCoverageCaller{},
|
||||
"+datasource-create", "--base-id", "BASE1", "--datasource-type", "OA",
|
||||
"--source-config", `{"processCode":"P","name":"N","dataType":"recent_time","iconUrl":"u","url":"v"}`,
|
||||
"--auto-sync-setting", "not-json")
|
||||
if err == nil || !strings.Contains(err.Error(), "auto-sync-setting") {
|
||||
t.Fatalf("error = %v, want auto-sync-setting parse error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDatasourceCreatePropagatesCallMCPError(t *testing.T) {
|
||||
err := runDatasourceShortcutCLI(t, &datasourceCoverageCaller{err: errors.New("mcp-error")},
|
||||
"+datasource-create", "--base-id", "BASE1", "--datasource-type", "OA",
|
||||
"--source-config", `{"processCode":"P","name":"N","dataType":"recent_time","iconUrl":"u","url":"v"}`)
|
||||
if err == nil || !strings.Contains(err.Error(), "mcp-error") {
|
||||
t.Fatalf("error = %v, want mcp-error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDatasourceCreateRejectsEmptyFieldIDs(t *testing.T) {
|
||||
caller := &datasourceCoverageCaller{}
|
||||
err := runDatasourceShortcutCLI(t, caller,
|
||||
"+datasource-create", "--base-id", "BASE1", "--datasource-type", "OA",
|
||||
"--source-config", `{"processCode":"P","name":"N","dataType":"recent_time","iconUrl":"u","url":"v"}`,
|
||||
"--field-ids", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "field-ids") {
|
||||
t.Fatalf("error = %v, want field-ids empty error", err)
|
||||
}
|
||||
if len(caller.argLog) != 0 {
|
||||
t.Fatalf("MCP should not be called when empty field-ids is rejected, got %d calls", len(caller.argLog))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDatasourceCreateRejectsWhitespaceOnlyFieldIDs(t *testing.T) {
|
||||
caller := &datasourceCoverageCaller{}
|
||||
err := runDatasourceShortcutCLI(t, caller,
|
||||
"+datasource-create", "--base-id", "BASE1", "--datasource-type", "OA",
|
||||
"--source-config", `{"processCode":"P","name":"N","dataType":"recent_time","iconUrl":"u","url":"v"}`,
|
||||
"--field-ids", " , , ")
|
||||
if err == nil || !strings.Contains(err.Error(), "field-ids") {
|
||||
t.Fatalf("error = %v, want field-ids empty error", err)
|
||||
}
|
||||
if len(caller.argLog) != 0 {
|
||||
t.Fatalf("MCP should not be called when whitespace-only field-ids is rejected, got %d calls", len(caller.argLog))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDatasourceCreateRejectsEmptyAutoSyncSetting(t *testing.T) {
|
||||
caller := &datasourceCoverageCaller{}
|
||||
err := runDatasourceShortcutCLI(t, caller,
|
||||
"+datasource-create", "--base-id", "BASE1", "--datasource-type", "OA",
|
||||
"--source-config", `{"processCode":"P","name":"N","dataType":"recent_time","iconUrl":"u","url":"v"}`,
|
||||
"--auto-sync-setting", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "auto-sync-setting") {
|
||||
t.Fatalf("error = %v, want auto-sync-setting empty error", err)
|
||||
}
|
||||
if len(caller.argLog) != 0 {
|
||||
t.Fatalf("MCP should not be called when empty auto-sync-setting is rejected, got %d calls", len(caller.argLog))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDatasourceCreatePassesFieldIDsThrough(t *testing.T) {
|
||||
caller := &datasourceCoverageCaller{}
|
||||
err := runDatasourceShortcutCLI(t, caller,
|
||||
"+datasource-create", "--base-id", "BASE1", "--datasource-type", "OA",
|
||||
"--source-config", `{"processCode":"P","name":"N","dataType":"recent_time","iconUrl":"u","url":"v"}`,
|
||||
"--field-ids", "fld1,fld2")
|
||||
if err != nil {
|
||||
t.Fatalf("create with valid --field-ids should succeed: %v", err)
|
||||
}
|
||||
if len(caller.argLog) != 1 {
|
||||
t.Fatalf("expected exactly 1 MCP call, got %d", len(caller.argLog))
|
||||
}
|
||||
got, _ := caller.argLog[0]["fieldIds"].([]string)
|
||||
if len(got) != 2 || got[0] != "fld1" || got[1] != "fld2" {
|
||||
t.Fatalf("fieldIds = %#v, want [fld1 fld2]", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── DatasourceUpdate error paths ─────────────────────────────────────────────
|
||||
|
||||
func TestCrossPlatformCoverageDatasourceUpdateRejectsInvalidSourceConfig(t *testing.T) {
|
||||
err := runDatasourceShortcutCLI(t, &datasourceCoverageCaller{},
|
||||
"+datasource-update", "--base-id", "BASE1", "--table-id", "TBL1",
|
||||
"--source-config", "not-json")
|
||||
if err == nil || !strings.Contains(err.Error(), "source-config") {
|
||||
t.Fatalf("error = %v, want source-config parse error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDatasourceUpdateRejectsInvalidAutoSyncSetting(t *testing.T) {
|
||||
err := runDatasourceShortcutCLI(t, &datasourceCoverageCaller{},
|
||||
"+datasource-update", "--base-id", "BASE1", "--table-id", "TBL1",
|
||||
"--auto-sync-setting", "not-json")
|
||||
if err == nil || !strings.Contains(err.Error(), "auto-sync-setting") {
|
||||
t.Fatalf("error = %v, want auto-sync-setting parse error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDatasourceUpdateRejectsNoChanges(t *testing.T) {
|
||||
err := runDatasourceShortcutCLI(t, &datasourceCoverageCaller{},
|
||||
"+datasource-update", "--base-id", "BASE1", "--table-id", "TBL1")
|
||||
if err == nil || !strings.Contains(err.Error(), "至少需要一个配置变更") {
|
||||
t.Fatalf("error = %v, want no-changes error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDatasourceUpdatePropagatesCallMCPError(t *testing.T) {
|
||||
err := runDatasourceShortcutCLI(t, &datasourceCoverageCaller{err: errors.New("mcp-error")},
|
||||
"+datasource-update", "--base-id", "BASE1", "--table-id", "TBL1", "--auto")
|
||||
if err == nil || !strings.Contains(err.Error(), "mcp-error") {
|
||||
t.Fatalf("error = %v, want mcp-error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDatasourceUpdateRejectsEmptyFieldIDs(t *testing.T) {
|
||||
caller := &datasourceCoverageCaller{}
|
||||
err := runDatasourceShortcutCLI(t, caller,
|
||||
"+datasource-update", "--base-id", "BASE1", "--table-id", "TBL1",
|
||||
"--field-ids", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "field-ids") {
|
||||
t.Fatalf("error = %v, want field-ids empty error", err)
|
||||
}
|
||||
if len(caller.argLog) != 0 {
|
||||
t.Fatalf("MCP should not be called when empty field-ids is rejected, got %d calls", len(caller.argLog))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDatasourceUpdateRejectsWhitespaceOnlyFieldIDs(t *testing.T) {
|
||||
caller := &datasourceCoverageCaller{}
|
||||
err := runDatasourceShortcutCLI(t, caller,
|
||||
"+datasource-update", "--base-id", "BASE1", "--table-id", "TBL1",
|
||||
"--field-ids", " , ")
|
||||
if err == nil || !strings.Contains(err.Error(), "field-ids") {
|
||||
t.Fatalf("error = %v, want field-ids empty error", err)
|
||||
}
|
||||
if len(caller.argLog) != 0 {
|
||||
t.Fatalf("MCP should not be called when whitespace-only field-ids is rejected, got %d calls", len(caller.argLog))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDatasourceUpdateRejectsEmptyAutoSyncSetting(t *testing.T) {
|
||||
caller := &datasourceCoverageCaller{}
|
||||
err := runDatasourceShortcutCLI(t, caller,
|
||||
"+datasource-update", "--base-id", "BASE1", "--table-id", "TBL1",
|
||||
"--auto-sync-setting", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "auto-sync-setting") {
|
||||
t.Fatalf("error = %v, want auto-sync-setting empty error", err)
|
||||
}
|
||||
if len(caller.argLog) != 0 {
|
||||
t.Fatalf("MCP should not be called when empty auto-sync-setting is rejected, got %d calls", len(caller.argLog))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDatasourceUpdatePassesFieldIDsThrough(t *testing.T) {
|
||||
caller := &datasourceCoverageCaller{}
|
||||
err := runDatasourceShortcutCLI(t, caller,
|
||||
"+datasource-update", "--base-id", "BASE1", "--table-id", "TBL1",
|
||||
"--field-ids", "fldA,fldB,fldC")
|
||||
if err != nil {
|
||||
t.Fatalf("update with valid --field-ids should succeed: %v", err)
|
||||
}
|
||||
if len(caller.argLog) != 1 {
|
||||
t.Fatalf("expected exactly 1 MCP call, got %d", len(caller.argLog))
|
||||
}
|
||||
got, _ := caller.argLog[0]["fieldIds"].([]string)
|
||||
if len(got) != 3 || got[0] != "fldA" || got[1] != "fldB" || got[2] != "fldC" {
|
||||
t.Fatalf("fieldIds = %#v, want [fldA fldB fldC]", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── DatasourceSync error paths ────────────────────────────────────────────────
|
||||
|
||||
func TestCrossPlatformCoverageDatasourceSyncShortcutRejectsTooManyTableIDs(t *testing.T) {
|
||||
err := runDatasourceShortcutCLI(t, &datasourceCoverageCaller{},
|
||||
"+datasource-sync", "--base-id", "BASE1", "--table-ids", "T1,T2,T3,T4,T5,T6")
|
||||
if err == nil || !strings.Contains(err.Error(), "1-5") {
|
||||
t.Fatalf("error = %v, want 1-5 limit", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDatasourceSyncShortcutPropagatesCallMCPError(t *testing.T) {
|
||||
err := runDatasourceShortcutCLI(t, &datasourceCoverageCaller{err: errors.New("mcp-error")},
|
||||
"+datasource-sync", "--base-id", "BASE1", "--table-ids", "TBL1")
|
||||
if err == nil || !strings.Contains(err.Error(), "mcp-error") {
|
||||
t.Fatalf("error = %v, want mcp-error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── DatasourceSyncStatus error paths ─────────────────────────────────────────
|
||||
|
||||
func TestCrossPlatformCoverageDatasourceSyncStatusShortcutRejectsTooManyTaskIDs(t *testing.T) {
|
||||
err := runDatasourceShortcutCLI(t, &datasourceCoverageCaller{},
|
||||
"+datasource-sync-status", "--base-id", "BASE1", "--table-id", "TBL1",
|
||||
"--task-ids", "T1,T2,T3,T4,T5,T6")
|
||||
if err == nil || !strings.Contains(err.Error(), "1-5") {
|
||||
t.Fatalf("error = %v, want 1-5 limit", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDatasourceSyncStatusShortcutPropagatesCallMCPError(t *testing.T) {
|
||||
err := runDatasourceShortcutCLI(t, &datasourceCoverageCaller{err: errors.New("mcp-error")},
|
||||
"+datasource-sync-status", "--base-id", "BASE1", "--table-id", "TBL1",
|
||||
"--task-ids", "TASK1")
|
||||
if err == nil || !strings.Contains(err.Error(), "mcp-error") {
|
||||
t.Fatalf("error = %v, want mcp-error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── DatasourceSync whitespace trimming ──────────────────────────────────────
|
||||
|
||||
func TestCrossPlatformCoverageDatasourceSyncRejectsWhitespaceOnlyTableIDs(t *testing.T) {
|
||||
caller := &datasourceCoverageCaller{}
|
||||
err := runDatasourceShortcutCLI(t, caller,
|
||||
"+datasource-sync", "--base-id", "BASE1", "--table-ids", " , ")
|
||||
if err == nil || (!strings.Contains(err.Error(), "table-ids") && !strings.Contains(err.Error(), "1-5")) {
|
||||
t.Fatalf("error = %v, want table-ids rejection", err)
|
||||
}
|
||||
if len(caller.argLog) != 0 {
|
||||
t.Fatalf("MCP should not be called when whitespace-only table-ids is rejected, got %d calls", len(caller.argLog))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDatasourceSyncTrimsWhitespaceFromTableIDs(t *testing.T) {
|
||||
caller := &datasourceCoverageCaller{}
|
||||
err := runDatasourceShortcutCLI(t, caller,
|
||||
"+datasource-sync", "--base-id", "BASE1", "--table-ids", " TBL1 , TBL2 ")
|
||||
if err != nil {
|
||||
t.Fatalf("sync with whitespace-padded --table-ids should succeed: %v", err)
|
||||
}
|
||||
if len(caller.argLog) != 1 {
|
||||
t.Fatalf("expected exactly 1 MCP call, got %d", len(caller.argLog))
|
||||
}
|
||||
got, _ := caller.argLog[0]["tableIds"].([]string)
|
||||
if len(got) != 2 || got[0] != "TBL1" || got[1] != "TBL2" {
|
||||
t.Fatalf("tableIds = %#v, want [TBL1 TBL2]", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── DatasourceSyncStatus whitespace trimming ───────────────────────────────
|
||||
|
||||
func TestCrossPlatformCoverageDatasourceSyncStatusRejectsWhitespaceOnlyTaskIDs(t *testing.T) {
|
||||
caller := &datasourceCoverageCaller{}
|
||||
err := runDatasourceShortcutCLI(t, caller,
|
||||
"+datasource-sync-status", "--base-id", "BASE1", "--table-id", "TBL1",
|
||||
"--task-ids", " , ")
|
||||
if err == nil || (!strings.Contains(err.Error(), "task-ids") && !strings.Contains(err.Error(), "1-5")) {
|
||||
t.Fatalf("error = %v, want task-ids rejection", err)
|
||||
}
|
||||
if len(caller.argLog) != 0 {
|
||||
t.Fatalf("MCP should not be called when whitespace-only task-ids is rejected, got %d calls", len(caller.argLog))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDatasourceSyncStatusTrimsWhitespaceFromTaskIDs(t *testing.T) {
|
||||
caller := &datasourceCoverageCaller{}
|
||||
err := runDatasourceShortcutCLI(t, caller,
|
||||
"+datasource-sync-status", "--base-id", "BASE1", "--table-id", "TBL1",
|
||||
"--task-ids", " TASK1 , TASK2 ")
|
||||
if err != nil {
|
||||
t.Fatalf("sync-status with whitespace-padded --task-ids should succeed: %v", err)
|
||||
}
|
||||
if len(caller.argLog) != 1 {
|
||||
t.Fatalf("expected exactly 1 MCP call, got %d", len(caller.argLog))
|
||||
}
|
||||
got, _ := caller.argLog[0]["taskIds"].([]string)
|
||||
if len(got) != 2 || got[0] != "TASK1" || got[1] != "TASK2" {
|
||||
t.Fatalf("taskIds = %#v, want [TASK1 TASK2]", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── DatasourceGetConfig error path ───────────────────────────────────────────
|
||||
|
||||
func TestCrossPlatformCoverageDatasourceGetConfigShortcutPropagatesCallMCPError(t *testing.T) {
|
||||
err := runDatasourceShortcutCLI(t, &datasourceCoverageCaller{err: errors.New("mcp-error")},
|
||||
"+datasource-get-config", "--base-id", "BASE1", "--table-id", "TBL1")
|
||||
if err == nil || !strings.Contains(err.Error(), "mcp-error") {
|
||||
t.Fatalf("error = %v, want mcp-error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── DatasourceListSources error path ─────────────────────────────────────────
|
||||
|
||||
func TestCrossPlatformCoverageDatasourceListSourcesShortcutPropagatesCallMCPError(t *testing.T) {
|
||||
err := runDatasourceShortcutCLI(t, &datasourceCoverageCaller{err: errors.New("mcp-error")},
|
||||
"+datasource-list-sources", "--base-id", "BASE1", "--datasource-type", "OA")
|
||||
if err == nil || !strings.Contains(err.Error(), "mcp-error") {
|
||||
t.Fatalf("error = %v, want mcp-error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── DatasourceGetFields error paths ──────────────────────────────────────────
|
||||
|
||||
func TestCrossPlatformCoverageDatasourceGetFieldsShortcutRejectsInvalidSourceConfig(t *testing.T) {
|
||||
err := runDatasourceShortcutCLI(t, &datasourceCoverageCaller{},
|
||||
"+datasource-get-fields", "--base-id", "BASE1", "--datasource-type", "OA",
|
||||
"--source-config", "not-json")
|
||||
if err == nil || !strings.Contains(err.Error(), "source-config") {
|
||||
t.Fatalf("error = %v, want source-config parse error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDatasourceGetFieldsShortcutPropagatesCallMCPError(t *testing.T) {
|
||||
err := runDatasourceShortcutCLI(t, &datasourceCoverageCaller{err: errors.New("mcp-error")},
|
||||
"+datasource-get-fields", "--base-id", "BASE1", "--datasource-type", "OA",
|
||||
"--source-config", `{"processCode":"P","name":"N","dataType":"recent_time","iconUrl":"u","url":"v"}`)
|
||||
if err == nil || !strings.Contains(err.Error(), "mcp-error") {
|
||||
t.Fatalf("error = %v, want mcp-error", err)
|
||||
}
|
||||
}
|
||||
@@ -36,8 +36,8 @@ func TestCrossPlatformCoverageAITableSemanticCatalogExactlyCoversRegisteredSurfa
|
||||
}
|
||||
registered[item.Command] = item
|
||||
}
|
||||
if len(registered) != 93 || len(source.Shortcuts) != 93 {
|
||||
t.Fatalf("registered/catalog = %d/%d, want 93/93", len(registered), len(source.Shortcuts))
|
||||
if len(registered) != 100 || len(source.Shortcuts) != 100 {
|
||||
t.Fatalf("registered/catalog = %d/%d, want 100/100", len(registered), len(source.Shortcuts))
|
||||
}
|
||||
|
||||
var missing, stale []string
|
||||
|
||||
@@ -32,13 +32,16 @@ import (
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chat"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/contact"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/devapp"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/devdoc"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/ding"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/doc"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/drive"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/hrbrain"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/live"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/mail"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/minutes"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/oa"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/pat"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/report"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/sheet"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/smart"
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package builtin_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageDevdocSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) {
|
||||
raw, err := os.ReadFile("../semantic_catalog_devdoc.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var source chatSemanticCatalogFixture
|
||||
if err := json.Unmarshal(raw, &source); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if source.Service != "devdoc" || len(source.Shortcuts) != 1 {
|
||||
t.Fatalf("service/count = %q/%d", source.Service, len(source.Shortcuts))
|
||||
}
|
||||
registered := map[string]shortcut.Shortcut{}
|
||||
for _, item := range shortcut.All() {
|
||||
if item.Service == "devdoc" {
|
||||
registered[item.Command] = item
|
||||
}
|
||||
}
|
||||
if len(registered) != 1 {
|
||||
t.Fatalf("registered Devdoc shortcuts = %d, want 1", len(registered))
|
||||
}
|
||||
for command, record := range source.Shortcuts {
|
||||
item, ok := registered[command]
|
||||
if !ok {
|
||||
t.Fatalf("catalog entry %s is not registered", command)
|
||||
}
|
||||
if !record.Reviewed || record.Public || record.Availability != shortcut.AvailabilityUnavailable {
|
||||
t.Fatalf("catalog entry is not reviewed private unavailable: %#v", record)
|
||||
}
|
||||
if !item.Hidden || !item.SemanticReviewed || item.Availability != shortcut.AvailabilityUnavailable || item.SemanticDelta != record.SemanticDelta {
|
||||
t.Fatalf("runtime semantic facts drifted: %#v", item)
|
||||
}
|
||||
if item.Contract.Empty() || item.Contract.Result == nil || item.OutputRollout != output.RolloutUnifiedActive || strings.TrimSpace(item.Safety.Effect) == "" {
|
||||
t.Fatal("unavailable Devdoc Shortcut lacks Contract/Result/Safety/unified output")
|
||||
}
|
||||
if item.Contract.Interface == nil || item.Contract.Interface.Availability != "unavailable" || !strings.Contains(string(item.Contract.Result.DataSchema), "zeroMatchProven") {
|
||||
t.Fatal("unavailable Devdoc Shortcut does not publish the semantic zero-match boundary")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// 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 TestCrossPlatformCoverageHRbrainSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) {
|
||||
raw, err := os.ReadFile("../semantic_catalog_hrbrain.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var source chatSemanticCatalogFixture
|
||||
if err := json.Unmarshal(raw, &source); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registered := map[string]shortcut.Shortcut{}
|
||||
for _, item := range shortcut.All() {
|
||||
if item.Service == "hrbrain" {
|
||||
registered[item.Command] = item
|
||||
}
|
||||
}
|
||||
if source.Service != "hrbrain" || len(source.Shortcuts) != 11 || len(registered) != 11 {
|
||||
t.Fatalf("service/catalog/registered = %q/%d/%d", source.Service, len(source.Shortcuts), len(registered))
|
||||
}
|
||||
var missing, stale []string
|
||||
blockerCounts := map[string]int{}
|
||||
for command, item := range registered {
|
||||
record, ok := source.Shortcuts[command]
|
||||
if !ok {
|
||||
missing = append(missing, command)
|
||||
continue
|
||||
}
|
||||
if record.Public || record.Availability != "" || !record.Reviewed || !item.SemanticReviewed || item.Availability != shortcut.AvailabilityUnavailable || !item.Hidden {
|
||||
t.Errorf("%s availability/review drift", command)
|
||||
}
|
||||
if strings.TrimSpace(item.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta {
|
||||
t.Errorf("%s semantic delta drift", command)
|
||||
}
|
||||
if item.OutputRollout != output.RolloutUnifiedActive || item.Contract.Empty() || item.Contract.Result == nil || strings.TrimSpace(item.Safety.Effect) == "" {
|
||||
t.Errorf("%s lacks Contract/Result/Safety/unified output", command)
|
||||
}
|
||||
if item.Contract.Interface == nil || strings.Contains(item.Contract.Interface.Reason, "shortcut_defect") {
|
||||
t.Errorf("%s lacks a reviewed non-Shortcut blocker", command)
|
||||
} else if strings.Contains(item.Contract.Interface.Reason, "classified=adapter_business_service") {
|
||||
blockerCounts["adapter_business_service"]++
|
||||
} else if strings.Contains(item.Contract.Interface.Reason, "classified=tenant_fixture") {
|
||||
blockerCounts["tenant_fixture"]++
|
||||
} else {
|
||||
t.Errorf("%s has unknown blocker classification", 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)
|
||||
}
|
||||
if blockerCounts["adapter_business_service"] != 9 || blockerCounts["tenant_fixture"] != 2 {
|
||||
t.Fatalf("blocker counts = %#v", blockerCounts)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package builtin_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoveragePATSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) {
|
||||
raw, err := os.ReadFile("../semantic_catalog_pat.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var source chatSemanticCatalogFixture
|
||||
if err := json.Unmarshal(raw, &source); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registered := map[string]shortcut.Shortcut{}
|
||||
for _, item := range shortcut.All() {
|
||||
if item.Service == "pat" {
|
||||
registered[item.Command] = item
|
||||
}
|
||||
}
|
||||
if source.Service != "pat" || len(source.Shortcuts) != 2 || len(registered) != 2 {
|
||||
t.Fatalf("service/catalog/registered = %q/%d/%d", source.Service, len(source.Shortcuts), len(registered))
|
||||
}
|
||||
public := 0
|
||||
for command, record := range source.Shortcuts {
|
||||
item, ok := registered[command]
|
||||
if !ok || !record.Reviewed || !item.SemanticReviewed || strings.TrimSpace(item.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta {
|
||||
t.Fatalf("%s semantic review drift", command)
|
||||
}
|
||||
if record.Public {
|
||||
public++
|
||||
if item.Hidden || item.Availability != shortcut.AvailabilityAvailable || item.OutputRollout != output.RolloutUnifiedActive || item.Contract.Result == nil || strings.TrimSpace(item.Safety.Effect) == "" {
|
||||
t.Fatalf("%s public contract drift", command)
|
||||
}
|
||||
} else if !item.Hidden || item.Availability != shortcut.AvailabilityUnavailable {
|
||||
t.Fatalf("%s unavailable visibility drift", command)
|
||||
}
|
||||
if command == "+authorize" {
|
||||
if record.Disposition != shortcut.DispositionAliasInternal || record.Primary != "pat chmod" || item.PrimaryCommand != "pat chmod" || !strings.Contains(item.SemanticDelta, "classified=routed") {
|
||||
t.Fatalf("%s must route to the reviewed pat chmod execution chain", command)
|
||||
}
|
||||
if item.Contract.Interface == nil || !strings.Contains(item.Contract.Interface.Reason, "blocked_fixture") || !strings.Contains(item.Contract.Interface.Reason, "classified=routed") {
|
||||
t.Fatalf("%s must separate routed implementation from unproved live write", command)
|
||||
}
|
||||
}
|
||||
}
|
||||
if public != 1 {
|
||||
t.Fatalf("public PAT shortcuts = %d, want 1", public)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
// Package devdoc registers strict Shortcut adapters for DingTalk Open Platform
|
||||
// documentation discovery.
|
||||
package devdoc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"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 devdocCompositeReason = "Unavailable after exact Shortcut plus raw isolation: the downstream interface performs semantic recall and returned nonempty neighbors for runtime-random zero-match candidates, so no guaranteed zero-match query can satisfy the public list/search proof contract."
|
||||
|
||||
type devdocPageEvidence struct {
|
||||
CurrentPage int
|
||||
PageSize int
|
||||
TotalCount int
|
||||
HasMore bool
|
||||
}
|
||||
|
||||
func devdocSearchResult() *contract.ResultSpec {
|
||||
return &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
DataSchema: json.RawMessage(`{"type":"object","description":"严格校验但未满足公开 zero-match 证明的语义文档搜索页","properties":{"count":{"type":"integer","description":"当前页文档数量"},"documents":{"type":"array","description":"当前页开放平台文档","items":{"type":"object","description":"带稳定 URL 的开放平台文档","properties":{"title":{"type":"string","description":"文档标题"},"url":{"type":"string","description":"文档稳定 URL"},"description":{"type":"string","description":"文档摘要"}},"required":["title","url"],"additionalProperties":false}},"currentPage":{"type":"integer","description":"服务端确认的当前页码"},"pageSize":{"type":"integer","description":"服务端确认的分页大小"},"totalCount":{"type":"integer","description":"服务端报告的语义召回总数"},"complete":{"type":"boolean","description":"是否到达语义召回末页;不表示 query zero-match 已证明"},"matchSemantics":{"type":"string","description":"下游搜索匹配语义","enum":["semantic"]},"zeroMatchProven":{"type":"boolean","description":"是否通过保证零匹配 query 证明公开投影;当前固定为 false"}},"required":["count","documents","currentPage","pageSize","totalCount","complete","matchSemantics","zeroMatchProven"],"additionalProperties":false}`),
|
||||
}
|
||||
}
|
||||
|
||||
func devdocPagination() *contract.PaginationSpec {
|
||||
return &contract.PaginationSpec{
|
||||
Kind: contract.PaginationKindCursor,
|
||||
CursorParameter: "page",
|
||||
MetaPath: contract.PaginationMetaPath,
|
||||
EndpointExhaustedPath: contract.PaginationExhaustedPath,
|
||||
NextTokenPath: contract.PaginationNextTokenPath,
|
||||
}
|
||||
}
|
||||
|
||||
var SearchDocs = shortcut.Shortcut{
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Service: "devdoc",
|
||||
Command: "+search-docs",
|
||||
Product: "devdoc",
|
||||
Description: "审计钉钉开放平台语义文档搜索结果",
|
||||
Intent: "需要语义搜索钉钉开放平台文档且明确接受无法证明 query zero-match 时才使用;当前 Shortcut 保持 unavailable,请路由到 raw `devdoc article search`。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Hidden: true,
|
||||
Availability: shortcut.AvailabilityUnavailable,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: corecmd.ContractDecl{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "devdoc", Name: "shortcut_search_docs", CanonicalPath: "devdoc.shortcut_search_docs",
|
||||
CLIPath: "devdoc +search-docs", PrimaryCLIPath: "devdoc +search-docs",
|
||||
},
|
||||
Description: "审计钉钉开放平台语义文档搜索结果",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceUnavailable, Reason: devdocCompositeReason,
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "审计钉钉开放平台语义文档搜索结果",
|
||||
UseWhen: []string{"需要语义搜索钉钉开放平台文档且明确接受无法证明 query zero-match 时才使用;当前 Shortcut 保持 unavailable,请路由到 raw `devdoc article search`。"},
|
||||
AvoidWhen: []string{"需要精确匹配或保证零命中时不要使用;搜索用户业务文档时使用 drive/wiki/doc"},
|
||||
Examples: []string{`dws devdoc +search-docs --query "OAuth2 接入" --size 10 --format json`},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "query", Property: "keyword"},
|
||||
{Name: "page", Property: "page", InterfaceType: "number"},
|
||||
{Name: "size", Property: "size", InterfaceType: "number"},
|
||||
},
|
||||
Result: devdocSearchResult(),
|
||||
Pagination: devdocPagination(),
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "query", Type: shortcut.FlagString, Required: true, Desc: "搜索关键词去除空白后必须非空"},
|
||||
{Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "页码必须大于 0"},
|
||||
{Name: "size", Type: shortcut.FlagInt, Default: "10", Desc: "每页数量必须在 1 到 50 之间"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"query"}, Description: "搜索关键词去除空白后必须非空"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"page"}, Description: "页码必须大于 0"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"size"}, Description: "每页数量必须在 1 到 50 之间"},
|
||||
},
|
||||
Tips: []string{`dws devdoc +search-docs --query "OAuth2 接入" --size 10 --format json`},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
if strings.TrimSpace(rt.Str("query")) == "" {
|
||||
return apperrors.NewValidation("--query 不能为空")
|
||||
}
|
||||
if rt.Int("page") < 1 {
|
||||
return apperrors.NewValidation("--page 必须大于 0")
|
||||
}
|
||||
if size := rt.Int("size"); size < 1 || size > 50 {
|
||||
return apperrors.NewValidation("--size 必须在 1 到 50 之间")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Execute: executeSearchDocs,
|
||||
}
|
||||
|
||||
func executeSearchDocs(rt *shortcut.RuntimeContext) error {
|
||||
page, size := rt.Int("page"), rt.Int("size")
|
||||
data, err := rt.CallMCPData("devdoc", "search_open_platform_docs", map[string]any{
|
||||
"keyword": strings.TrimSpace(rt.Str("query")),
|
||||
"page": page,
|
||||
"size": size,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
documents, evidence, err := devdocProjectSearch(data, page, size)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]any{
|
||||
"count": len(documents),
|
||||
"documents": documents,
|
||||
"currentPage": evidence.CurrentPage,
|
||||
"pageSize": evidence.PageSize,
|
||||
"totalCount": evidence.TotalCount,
|
||||
"complete": !evidence.HasMore,
|
||||
"matchSemantics": "semantic",
|
||||
"zeroMatchProven": false,
|
||||
}
|
||||
next := ""
|
||||
if evidence.HasMore {
|
||||
next = strconv.Itoa(evidence.CurrentPage + 1)
|
||||
}
|
||||
// Strict page evidence guarantees the only two valid framework states:
|
||||
// terminal+empty-next or continuing+positive next page.
|
||||
pagination, _ := output.NewPagination(!evidence.HasMore, next)
|
||||
meta := &output.Meta{Count: output.NewCount(len(documents)), Pagination: pagination}
|
||||
return output.StoreResult(rt.Command().Context(), output.Success(payload, output.WithMeta(meta)))
|
||||
}
|
||||
|
||||
func devdocProjectSearch(data map[string]any, requestedPage, requestedSize int) ([]map[string]any, devdocPageEvidence, error) {
|
||||
const operation = "devdoc/search_open_platform_docs"
|
||||
items, err := responsecheck.RequireObjectCollection(data, operation, "result.items")
|
||||
if err != nil {
|
||||
return nil, devdocPageEvidence{}, err
|
||||
}
|
||||
// RequireObjectCollection already proved that result is an object while
|
||||
// resolving result.items, so a second fallible shape check is unreachable.
|
||||
result, _ := data["result"].(map[string]any)
|
||||
evidence, err := devdocParsePage(result, requestedPage, requestedSize, len(items))
|
||||
if err != nil {
|
||||
return nil, devdocPageEvidence{}, err
|
||||
}
|
||||
documents := make([]map[string]any, 0, len(items))
|
||||
seenURLs := make(map[string]struct{}, len(items))
|
||||
for index, item := range items {
|
||||
title, titleOK := devdocNonEmptyString(item["title"])
|
||||
url, urlOK := devdocNonEmptyString(item["url"])
|
||||
if !titleOK || !urlOK {
|
||||
return nil, devdocPageEvidence{}, responsecheck.Error(operation, "malformed_item", fmt.Sprintf("文档结果第 %d 项缺少非空 title 或 url", index))
|
||||
}
|
||||
if _, duplicate := seenURLs[url]; duplicate {
|
||||
return nil, devdocPageEvidence{}, responsecheck.Error(operation, "duplicate_item_identity", fmt.Sprintf("文档结果第 %d 项 URL 重复", index))
|
||||
}
|
||||
seenURLs[url] = struct{}{}
|
||||
document := map[string]any{"title": title, "url": url}
|
||||
if description, ok := devdocOptionalString(item["desc"]); ok {
|
||||
document["description"] = description
|
||||
}
|
||||
documents = append(documents, document)
|
||||
}
|
||||
return documents, evidence, nil
|
||||
}
|
||||
|
||||
func devdocParsePage(result map[string]any, requestedPage, requestedSize, itemCount int) (devdocPageEvidence, error) {
|
||||
const operation = "devdoc/search_open_platform_docs"
|
||||
currentPage, ok := devdocInteger(result["currentPage"])
|
||||
if !ok || currentPage < 1 || currentPage != requestedPage {
|
||||
return devdocPageEvidence{}, responsecheck.Error(operation, "pagination_page_mismatch", "响应 currentPage 缺失、错型或与请求页码不一致")
|
||||
}
|
||||
pageSize, ok := devdocInteger(result["pageSize"])
|
||||
if !ok || pageSize < 1 || pageSize != requestedSize {
|
||||
return devdocPageEvidence{}, responsecheck.Error(operation, "pagination_size_mismatch", "响应 pageSize 缺失、错型或与请求分页大小不一致")
|
||||
}
|
||||
totalCount, ok := devdocInteger(result["totalCount"])
|
||||
if !ok || totalCount < 0 || totalCount < itemCount {
|
||||
return devdocPageEvidence{}, responsecheck.Error(operation, "invalid_total_count", "响应 totalCount 缺失、错型、为负数或小于当前页条数")
|
||||
}
|
||||
if itemCount > pageSize {
|
||||
return devdocPageEvidence{}, responsecheck.Error(operation, "page_size_exceeded", "当前页结果数量超过服务端 pageSize")
|
||||
}
|
||||
hasMore, ok := result["hasMore"].(bool)
|
||||
if !ok {
|
||||
return devdocPageEvidence{}, responsecheck.Error(operation, "malformed_pagination", "响应 hasMore 缺失或不是布尔值")
|
||||
}
|
||||
pageEnd := currentPage * pageSize
|
||||
if hasMore && pageEnd >= totalCount {
|
||||
return devdocPageEvidence{}, responsecheck.Error(operation, "conflicting_pagination", "hasMore=true 但 currentPage/pageSize/totalCount 已到达末页")
|
||||
}
|
||||
if !hasMore && pageEnd < totalCount {
|
||||
return devdocPageEvidence{}, responsecheck.Error(operation, "conflicting_pagination", "hasMore=false 但 totalCount 表明仍有下一页")
|
||||
}
|
||||
return devdocPageEvidence{CurrentPage: currentPage, PageSize: pageSize, TotalCount: totalCount, HasMore: hasMore}, nil
|
||||
}
|
||||
|
||||
func devdocInteger(value any) (int, bool) {
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
if math.IsNaN(typed) || math.IsInf(typed, 0) || typed != math.Trunc(typed) || typed > float64(math.MaxInt) {
|
||||
return 0, false
|
||||
}
|
||||
return int(typed), true
|
||||
case int:
|
||||
return typed, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func devdocNonEmptyString(value any) (string, bool) {
|
||||
text, ok := value.(string)
|
||||
text = strings.TrimSpace(text)
|
||||
return text, ok && text != ""
|
||||
}
|
||||
|
||||
func devdocOptionalString(value any) (string, bool) {
|
||||
if value == nil {
|
||||
return "", false
|
||||
}
|
||||
return devdocNonEmptyString(value)
|
||||
}
|
||||
|
||||
func init() {
|
||||
shortcut.Register(SearchDocs)
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package devdoc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"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/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type devdocCoverageCaller struct {
|
||||
response string
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (caller *devdocCoverageCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
|
||||
caller.calls++
|
||||
if caller.err != nil {
|
||||
return nil, caller.err
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: caller.response}}}, nil
|
||||
}
|
||||
func (*devdocCoverageCaller) Format() string { return "json" }
|
||||
func (*devdocCoverageCaller) DryRun() bool { return false }
|
||||
func (*devdocCoverageCaller) Fields() string { return "" }
|
||||
func (*devdocCoverageCaller) JQ() string { return "" }
|
||||
|
||||
func runDevdocCoverage(t *testing.T, caller *devdocCoverageCaller, args ...string) (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", "")
|
||||
service := &cobra.Command{Use: "devdoc"}
|
||||
service.AddCommand(corecmd.New(shortcut.FromShortcut(SearchDocs)))
|
||||
root.AddCommand(service)
|
||||
ctx, _ := output.WithResultStore(context.Background())
|
||||
root.SetContext(ctx)
|
||||
var stdout bytes.Buffer
|
||||
root.SetOut(&stdout)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(append([]string{"devdoc", SearchDocs.Command}, args...))
|
||||
executed, err := root.ExecuteC()
|
||||
if err == nil {
|
||||
_, _, err = output.EmitStoredResult(executed)
|
||||
}
|
||||
return stdout.String(), err
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDevdocUnavailableContractIsUnifiedTypedAndTruthful(t *testing.T) {
|
||||
if SearchDocs.OutputRollout != output.RolloutUnifiedActive {
|
||||
t.Fatalf("rollout = %q", SearchDocs.OutputRollout)
|
||||
}
|
||||
if !SearchDocs.Hidden || SearchDocs.Availability != "unavailable" || SearchDocs.Contract.Interface == nil || SearchDocs.Contract.Interface.Availability != "unavailable" {
|
||||
t.Fatal("Devdoc semantic search must remain hidden/unavailable until a guaranteed zero-match query is provable")
|
||||
}
|
||||
if SearchDocs.Contract.Empty() || SearchDocs.Contract.Result == nil || SearchDocs.Contract.Pagination == nil {
|
||||
t.Fatal("Devdoc Shortcut must retain Contract, Result, and Pagination for audit")
|
||||
}
|
||||
resultSchema := string(SearchDocs.Contract.Result.DataSchema)
|
||||
if !strings.Contains(resultSchema, "matchSemantics") || !strings.Contains(resultSchema, "zeroMatchProven") || strings.TrimSpace(SearchDocs.Safety.Effect) == "" {
|
||||
t.Fatal("Devdoc Result must declare semantic matching and the unproven zero-match boundary")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDevdocSearchRejectsBrokenResponses(t *testing.T) {
|
||||
explicitEmpty := map[string]any{
|
||||
"success": true,
|
||||
"result": map[string]any{
|
||||
"items": []any{}, "currentPage": float64(3), "pageSize": float64(10),
|
||||
"totalCount": float64(2), "hasMore": false,
|
||||
},
|
||||
}
|
||||
documents, page, err := devdocProjectSearch(explicitEmpty, 3, 10)
|
||||
if err != nil || len(documents) != 0 || page.HasMore {
|
||||
t.Fatalf("explicit empty page: documents=%v page=%+v err=%v", documents, page, err)
|
||||
}
|
||||
// This proves only that an explicitly empty terminal page is parsed
|
||||
// truthfully. It is deliberately not evidence for a zero-match query.
|
||||
|
||||
broken := map[string]map[string]any{
|
||||
"empty response": {},
|
||||
"missing success": {"result": map[string]any{"items": []any{}}},
|
||||
"success false": {"success": false, "result": map[string]any{"items": []any{}}},
|
||||
"wrong success type": {"success": "true", "result": map[string]any{"items": []any{}}},
|
||||
"missing collection": {"success": true, "result": map[string]any{"hasMore": false}},
|
||||
"wrong collection type": {"success": true, "result": map[string]any{"items": map[string]any{}, "currentPage": float64(1), "pageSize": float64(10), "totalCount": float64(0), "hasMore": false}},
|
||||
"bad item": {"success": true, "result": map[string]any{"items": []any{"bad"}, "currentPage": float64(1), "pageSize": float64(10), "totalCount": float64(1), "hasMore": false}},
|
||||
"missing item identity": {"success": true, "result": map[string]any{"items": []any{map[string]any{"title": "doc"}}, "currentPage": float64(1), "pageSize": float64(10), "totalCount": float64(1), "hasMore": false}},
|
||||
"duplicate identity": {"success": true, "result": map[string]any{"items": []any{map[string]any{"title": "one", "url": "https://example.invalid/doc"}, map[string]any{"title": "two", "url": "https://example.invalid/doc"}}, "currentPage": float64(1), "pageSize": float64(10), "totalCount": float64(2), "hasMore": false}},
|
||||
"missing pagination": {"success": true, "result": map[string]any{"items": []any{}}},
|
||||
"mismatched page": {"success": true, "result": map[string]any{"items": []any{}, "currentPage": float64(2), "pageSize": float64(10), "totalCount": float64(0), "hasMore": false}},
|
||||
"wrong hasMore type": {"success": true, "result": map[string]any{"items": []any{}, "currentPage": float64(1), "pageSize": float64(10), "totalCount": float64(0), "hasMore": "false"}},
|
||||
"false completion": {"success": true, "result": map[string]any{"items": []any{}, "currentPage": float64(1), "pageSize": float64(10), "totalCount": float64(11), "hasMore": false}},
|
||||
"false continuation": {"success": true, "result": map[string]any{"items": []any{}, "currentPage": float64(1), "pageSize": float64(10), "totalCount": float64(10), "hasMore": true}},
|
||||
}
|
||||
for name, payload := range broken {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if got, _, err := devdocProjectSearch(payload, 1, 10); err == nil {
|
||||
t.Fatalf("broken response accepted: %#v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDevdocSearchProjectsKnownNonemptyPage(t *testing.T) {
|
||||
payload := map[string]any{
|
||||
"success": true,
|
||||
"result": map[string]any{
|
||||
"items": []any{
|
||||
map[string]any{"title": "Authentication", "url": "https://example.invalid/auth", "desc": "Guide"},
|
||||
map[string]any{"title": "Errors", "url": "https://example.invalid/errors", "desc": nil},
|
||||
},
|
||||
"currentPage": float64(1), "pageSize": float64(2), "totalCount": float64(3), "hasMore": true,
|
||||
},
|
||||
}
|
||||
documents, page, err := devdocProjectSearch(payload, 1, 2)
|
||||
if err != nil || len(documents) != 2 || !page.HasMore {
|
||||
t.Fatalf("documents=%#v page=%+v err=%v", documents, page, err)
|
||||
}
|
||||
if documents[0]["description"] != "Guide" {
|
||||
t.Fatalf("first document=%#v", documents[0])
|
||||
}
|
||||
if _, exists := documents[1]["description"]; exists {
|
||||
t.Fatalf("nil description must be omitted: %#v", documents[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDevdocExecuteValidationAndPagination(t *testing.T) {
|
||||
blankCommand := corecmd.New(shortcut.FromShortcut(SearchDocs))
|
||||
if err := blankCommand.Flags().Set("query", " "); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SearchDocs.Validate(shortcut.RuntimeContextForTest(blankCommand, SearchDocs)); err == nil {
|
||||
t.Fatal("blank query validation succeeded")
|
||||
}
|
||||
for name, args := range map[string][]string{
|
||||
"blank query": {"--query", " "},
|
||||
"zero page": {"--query", "query", "--page", "0"},
|
||||
"zero size": {"--query", "query", "--size", "0"},
|
||||
"large size": {"--query", "query", "--size", "51"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
caller := &devdocCoverageCaller{}
|
||||
if _, err := runDevdocCoverage(t, caller, args...); err == nil {
|
||||
t.Fatal("invalid input succeeded")
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("remote calls before validation = %d", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for name, response := range map[string]string{
|
||||
"terminal": `{"success":true,"result":{"items":[{"title":"Doc","url":"https://example.invalid/doc"}],"currentPage":1,"pageSize":10,"totalCount":1,"hasMore":false}}`,
|
||||
"continuing": `{"success":true,"result":{"items":[{"title":"Doc","url":"https://example.invalid/doc"}],"currentPage":1,"pageSize":1,"totalCount":2,"hasMore":true}}`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
caller := &devdocCoverageCaller{response: response}
|
||||
args := []string{"--query", " query "}
|
||||
if name == "continuing" {
|
||||
args = append(args, "--size", "1")
|
||||
}
|
||||
if _, err := runDevdocCoverage(t, caller, args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls != 1 {
|
||||
t.Fatalf("calls = %d", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
caller := &devdocCoverageCaller{err: errors.New("transport")}
|
||||
if _, err := runDevdocCoverage(t, caller, "--query", "query"); err == nil {
|
||||
t.Fatal("transport failure succeeded")
|
||||
}
|
||||
malformed := &devdocCoverageCaller{response: `{"success":true,"result":{"items":"bad"}}`}
|
||||
if _, err := runDevdocCoverage(t, malformed, "--query", "query"); err == nil {
|
||||
t.Fatal("malformed projection succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDevdocPageAndScalarEdges(t *testing.T) {
|
||||
base := func() map[string]any {
|
||||
return map[string]any{
|
||||
"success": true,
|
||||
"result": map[string]any{
|
||||
"items": []any{}, "currentPage": float64(1), "pageSize": float64(2),
|
||||
"totalCount": float64(0), "hasMore": false,
|
||||
},
|
||||
}
|
||||
}
|
||||
for name, mutate := range map[string]func(map[string]any){
|
||||
"page size mismatch": func(result map[string]any) { result["pageSize"] = float64(3) },
|
||||
"negative total": func(result map[string]any) { result["totalCount"] = float64(-1) },
|
||||
"total below items": func(result map[string]any) {
|
||||
result["items"] = []any{map[string]any{"title": "one", "url": "https://example.invalid/one"}}
|
||||
result["totalCount"] = float64(0)
|
||||
},
|
||||
"items exceed size": func(result map[string]any) {
|
||||
result["items"] = []any{
|
||||
map[string]any{"title": "one", "url": "https://example.invalid/one"},
|
||||
map[string]any{"title": "two", "url": "https://example.invalid/two"},
|
||||
map[string]any{"title": "three", "url": "https://example.invalid/three"},
|
||||
}
|
||||
result["totalCount"] = float64(3)
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
payload := base()
|
||||
mutate(payload["result"].(map[string]any))
|
||||
if _, _, err := devdocProjectSearch(payload, 1, 2); err == nil {
|
||||
t.Fatal("invalid page succeeded")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, value := range []any{math.NaN(), math.Inf(1), 1.5, float64(math.MaxInt) * 2, "1"} {
|
||||
if _, ok := devdocInteger(value); ok {
|
||||
t.Fatalf("invalid integer accepted: %#v", value)
|
||||
}
|
||||
}
|
||||
if got, ok := devdocInteger(7); !ok || got != 7 {
|
||||
t.Fatalf("int projection = %d, %v", got, ok)
|
||||
}
|
||||
if _, ok := devdocOptionalString(7); ok {
|
||||
t.Fatal("non-string optional value accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,632 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
// Package hrbrain registers fail-closed Shortcut adapters for DingTalk
|
||||
// Organization Brain reads. Every declaration remains unavailable until the
|
||||
// lower service supplies a verifiable non-null result and safe live fixtures.
|
||||
package hrbrain
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"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 (
|
||||
hrbrainBlockerAdapterBusiness = "adapter_business_service"
|
||||
hrbrainBlockerTenantFixture = "tenant_fixture"
|
||||
)
|
||||
|
||||
func hrbrainUnavailableReason(command string) string {
|
||||
switch command {
|
||||
case "+get-pool", "+list-pool-employees":
|
||||
return "classified=" + hrbrainBlockerTenantFixture + "; exact Shortcut and raw atomic calls reach the same remote operation and upstream error, but no safe nonempty pool fixture exists to prove identity and pagination."
|
||||
case "+list-pools", "+profile-labels":
|
||||
return "classified=" + hrbrainBlockerAdapterBusiness + "; raw atomic calls return success=true with result=null and the exact Shortcut rejects the response, so the business response contract cannot prove a collection or legitimate empty result."
|
||||
case "+profile-metadata", "+query-profile", "+profile-career", "+profile-performance", "+search-employees", "+search-employees-structured", "+search-fields":
|
||||
return "classified=" + hrbrainBlockerAdapterBusiness + "; exact Shortcut and raw atomic calls reach the same remote server, operation, and upstream error, so no Shortcut-layer defect can be repaired without downstream capability or business-service recovery."
|
||||
default:
|
||||
panic("missing reviewed HRbrain blocker classification for " + command)
|
||||
}
|
||||
}
|
||||
|
||||
type hrbrainPage struct {
|
||||
CurrentPage int
|
||||
PageSize int
|
||||
TotalCount int
|
||||
HasMore bool
|
||||
}
|
||||
|
||||
func hrbrainReadSafety() contract.SafetySpec {
|
||||
return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"}
|
||||
}
|
||||
|
||||
func hrbrainObjectResult(description string) *contract.ResultSpec {
|
||||
return &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
DataSchema: json.RawMessage(fmt.Sprintf(
|
||||
`{"type":"object","description":%q,"properties":{"value":{"type":"object","description":"严格校验且身份匹配的 HRbrain 业务对象","additionalProperties":true}},"required":["value"],"additionalProperties":false}`,
|
||||
description,
|
||||
)),
|
||||
SensitivePaths: []string{"value.name", "value.mobile", "value.email", "value.workNo", "value.userId"},
|
||||
}
|
||||
}
|
||||
|
||||
func hrbrainCollectionResult(description string) *contract.ResultSpec {
|
||||
return &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
DataSchema: json.RawMessage(fmt.Sprintf(
|
||||
`{"type":"object","description":%q,"properties":{"count":{"type":"integer","description":"当前响应中的有效记录数量"},"items":{"type":"array","description":%q,"items":{"type":"object","description":"严格校验且带稳定身份的 HRbrain 业务记录","additionalProperties":true}}},"required":["count","items"],"additionalProperties":false}`,
|
||||
description, description,
|
||||
)),
|
||||
SensitivePaths: []string{"items.name", "items.mobile", "items.email", "items.workNo", "items.userId"},
|
||||
}
|
||||
}
|
||||
|
||||
func hrbrainPageResult(description string) *contract.ResultSpec {
|
||||
return &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
DataSchema: json.RawMessage(fmt.Sprintf(
|
||||
`{"type":"object","description":%q,"properties":{"count":{"type":"integer","description":"当前页有效记录数量"},"items":{"type":"array","description":%q,"items":{"type":"object","description":"严格校验且带稳定身份的 HRbrain 业务记录","additionalProperties":true}},"currentPage":{"type":"integer","description":"服务端确认的当前页码"},"pageSize":{"type":"integer","description":"服务端确认的分页大小"},"totalCount":{"type":"integer","description":"服务端报告的总记录数"},"complete":{"type":"boolean","description":"服务端证据是否证明到达末页"}},"required":["count","items","currentPage","pageSize","totalCount","complete"],"additionalProperties":false}`,
|
||||
description, description,
|
||||
)),
|
||||
SensitivePaths: []string{"items.name", "items.mobile", "items.email", "items.workNo", "items.userId"},
|
||||
}
|
||||
}
|
||||
|
||||
func hrbrainPagination() *contract.PaginationSpec {
|
||||
return &contract.PaginationSpec{
|
||||
Kind: contract.PaginationKindCursor, CursorParameter: "page",
|
||||
MetaPath: contract.PaginationMetaPath, EndpointExhaustedPath: contract.PaginationExhaustedPath,
|
||||
NextTokenPath: contract.PaginationNextTokenPath,
|
||||
}
|
||||
}
|
||||
|
||||
func hrbrainContract(command, description, intent string, result *contract.ResultSpec, pagination *contract.PaginationSpec, params []contract.ParamDecl, examples ...string) corecmd.ContractDecl {
|
||||
name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_")
|
||||
path := "hrbrain " + command
|
||||
return corecmd.ContractDecl{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "hrbrain", Name: name, CanonicalPath: "hrbrain." + name,
|
||||
CLIPath: path, PrimaryCLIPath: path,
|
||||
},
|
||||
Description: description,
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceUnavailable, Reason: hrbrainUnavailableReason(command),
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: description,
|
||||
UseWhen: []string{intent},
|
||||
AvoidWhen: []string{"该 HRbrain Shortcut 当前 unavailable;不要把 null、业务错误或缺少安全 fixture 当作成功,基础通讯录查询改用 contact"},
|
||||
Examples: examples,
|
||||
},
|
||||
Parameters: params,
|
||||
Result: result,
|
||||
Pagination: pagination,
|
||||
}
|
||||
}
|
||||
|
||||
func hrbrainBase(command, description, intent string, result *contract.ResultSpec, pagination *contract.PaginationSpec, flags []shortcut.Flag, params []contract.ParamDecl, examples ...string) shortcut.Shortcut {
|
||||
return shortcut.Shortcut{
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Service: "hrbrain",
|
||||
Command: command,
|
||||
Product: "hrbrain",
|
||||
Description: description,
|
||||
Intent: intent,
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: hrbrainReadSafety(),
|
||||
Contract: hrbrainContract(command, description, intent, result, pagination, params, examples...),
|
||||
Flags: flags,
|
||||
Tips: examples,
|
||||
Hidden: true,
|
||||
Availability: shortcut.AvailabilityUnavailable,
|
||||
}
|
||||
}
|
||||
|
||||
var ListPools = func() shortcut.Shortcut {
|
||||
declaration := hrbrainBase(
|
||||
"+list-pools", "查询组织大脑人才池列表", "需要按名称、类型、创建人或标签发现人才池时使用;当前必须保持 unavailable,直到下游返回可验证分页数组。",
|
||||
hrbrainPageResult("严格校验的人才池搜索页"), hrbrainPagination(),
|
||||
[]shortcut.Flag{
|
||||
{Name: "keyword", Type: shortcut.FlagString, Desc: "人才池名称关键词"},
|
||||
{Name: "pool-type", Type: shortcut.FlagString, Desc: "人才池类型"},
|
||||
{Name: "creator", Type: shortcut.FlagString, Desc: "创建人稳定身份"},
|
||||
{Name: "labels", Type: shortcut.FlagStringSlice, Desc: "标签列表"},
|
||||
{Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "页码必须大于 0"},
|
||||
{Name: "page-size", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量必须在 1 到 100 之间"},
|
||||
},
|
||||
[]contract.ParamDecl{
|
||||
{Name: "keyword", Property: "keyword"}, {Name: "pool-type", Property: "poolType"},
|
||||
{Name: "creator", Property: "creator"}, {Name: "labels", Property: "labels"},
|
||||
{Name: "page", Property: "currentPage", InterfaceType: "number"}, {Name: "page-size", Property: "pageSize", InterfaceType: "number"},
|
||||
},
|
||||
`dws hrbrain +list-pools --page 1 --page-size 20 --format json`,
|
||||
)
|
||||
declaration.Constraints = hrbrainPageConstraints()
|
||||
declaration.Validate = hrbrainValidatePage
|
||||
declaration.Execute = func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{"currentPage": rt.Int("page"), "pageSize": rt.Int("page-size")}
|
||||
hrbrainAddOptional(params, rt, "keyword", "keyword")
|
||||
hrbrainAddOptional(params, rt, "pool-type", "poolType")
|
||||
hrbrainAddOptional(params, rt, "creator", "creator")
|
||||
if rt.Changed("labels") {
|
||||
params["labels"] = hrbrainCleanValues(rt.StrSlice("labels"))
|
||||
}
|
||||
return hrbrainCallPage(rt, "list_talent_pools", params, "poolCode")
|
||||
}
|
||||
return declaration
|
||||
}()
|
||||
|
||||
var GetPool = func() shortcut.Shortcut {
|
||||
declaration := hrbrainBase(
|
||||
"+get-pool", "根据 poolCode 查询人才池详情", "已知稳定 poolCode 并需要读取同一人才池详情时使用;没有安全非空 fixture 前保持 unavailable。",
|
||||
hrbrainObjectResult("身份匹配的人才池详情"), nil,
|
||||
[]shortcut.Flag{{Name: "pool-code", Type: shortcut.FlagString, Required: true, Desc: "人才池稳定 poolCode"}},
|
||||
[]contract.ParamDecl{{Name: "pool-code", Property: "poolCode"}},
|
||||
`dws hrbrain +get-pool --pool-code <POOL_CODE> --format json`,
|
||||
)
|
||||
declaration.Execute = func(rt *shortcut.RuntimeContext) error {
|
||||
return hrbrainCallObject(rt, "get_talent_pool_detail", map[string]any{"poolCode": rt.Str("pool-code")}, rt.Str("pool-code"), "poolCode")
|
||||
}
|
||||
return declaration
|
||||
}()
|
||||
|
||||
var ListPoolEmployees = func() shortcut.Shortcut {
|
||||
declaration := hrbrainBase(
|
||||
"+list-pool-employees", "查询指定人才池内的员工列表", "已知稳定 poolCode 并需要分页读取池内员工时使用;缺少安全人才池与员工 fixture 前保持 unavailable。",
|
||||
hrbrainPageResult("严格校验的人才池员工页"), hrbrainPagination(),
|
||||
[]shortcut.Flag{
|
||||
{Name: "pool-code", Type: shortcut.FlagString, Required: true, Desc: "人才池稳定 poolCode"},
|
||||
{Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "页码必须大于 0"},
|
||||
{Name: "page-size", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量必须在 1 到 100 之间"},
|
||||
},
|
||||
[]contract.ParamDecl{
|
||||
{Name: "pool-code", Property: "poolCode"}, {Name: "page", Property: "currentPage", InterfaceType: "number"},
|
||||
{Name: "page-size", Property: "pageSize", InterfaceType: "number"},
|
||||
},
|
||||
`dws hrbrain +list-pool-employees --pool-code <POOL_CODE> --page 1 --page-size 20 --format json`,
|
||||
)
|
||||
declaration.Constraints = hrbrainPageConstraints()
|
||||
declaration.Validate = hrbrainValidatePage
|
||||
declaration.Execute = func(rt *shortcut.RuntimeContext) error {
|
||||
return hrbrainCallPage(rt, "list_pool_employees", map[string]any{
|
||||
"poolCode": rt.Str("pool-code"), "currentPage": rt.Int("page"), "pageSize": rt.Int("page-size"),
|
||||
}, "workNo", "staffId", "userId")
|
||||
}
|
||||
return declaration
|
||||
}()
|
||||
|
||||
var ProfileMetadata = hrbrainCollectionShortcut(
|
||||
"+profile-metadata", "查询员工档案元数据结构", "已知工号并需要发现可查询档案模块与字段时使用;下游工具当前不可验证,保持 unavailable。",
|
||||
"get_profile_metadata", "work-no", "workNo", "档案元数据", []string{"modelCode", "fieldCode", "code"},
|
||||
)
|
||||
|
||||
var QueryProfile = func() shortcut.Shortcut {
|
||||
declaration := hrbrainBase(
|
||||
"+query-profile", "按模块批量查询员工档案数据", "已知工号、模块与字段编码并需要批量读取档案时使用;下游可用性和结果合同未通过 live 验证。",
|
||||
hrbrainCollectionResult("严格校验的员工档案模块结果"), nil,
|
||||
[]shortcut.Flag{
|
||||
{Name: "work-no", Type: shortcut.FlagString, Required: true, Desc: "员工稳定工号"},
|
||||
{Name: "data-queries", Type: shortcut.FlagString, Required: true, Desc: "必须是非空 JSON 数组的模块查询条件"},
|
||||
},
|
||||
[]contract.ParamDecl{{Name: "work-no", Property: "workNo"}, {Name: "data-queries", Property: "dataQueries"}},
|
||||
`dws hrbrain +query-profile --work-no <WORK_NO> --data-queries '[{"modelCode":"basic","fields":["name"]}]' --format json`,
|
||||
)
|
||||
declaration.Constraints = []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"data-queries"}, Description: "必须是非空 JSON 数组的模块查询条件"}}
|
||||
declaration.Validate = func(rt *shortcut.RuntimeContext) error {
|
||||
_, err := hrbrainJSONArray(rt.Str("data-queries"), "--data-queries")
|
||||
return err
|
||||
}
|
||||
declaration.Execute = func(rt *shortcut.RuntimeContext) error {
|
||||
// Validate already established the exact non-empty object-array shape.
|
||||
queries, _ := hrbrainJSONArray(rt.Str("data-queries"), "--data-queries")
|
||||
return hrbrainCallCollection(rt, "query_profile_data", map[string]any{"workNo": rt.Str("work-no"), "dataQueries": queries}, "modelCode", "moduleCode")
|
||||
}
|
||||
return declaration
|
||||
}()
|
||||
|
||||
var ProfileLabels = func() shortcut.Shortcut {
|
||||
declaration := hrbrainBase(
|
||||
"+profile-labels", "批量查询员工档案标签", "已知一组稳定工号并需要读取标签时使用;下游当前 success=true 但 result=null,保持 unavailable。",
|
||||
hrbrainCollectionResult("严格校验的员工标签结果"), nil,
|
||||
[]shortcut.Flag{
|
||||
{Name: "staff-ids", Type: shortcut.FlagStringSlice, Required: true, Desc: "一个或多个非空员工工号"},
|
||||
{Name: "all-label", Type: shortcut.FlagBool, Desc: "是否返回全部标签"},
|
||||
},
|
||||
[]contract.ParamDecl{{Name: "staff-ids", Property: "staffIds"}, {Name: "all-label", Property: "allLabel"}},
|
||||
`dws hrbrain +profile-labels --staff-ids <WORK_NO> --format json`,
|
||||
)
|
||||
declaration.Constraints = []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"staff-ids"}, Description: "必须包含一个或多个非空员工工号"}}
|
||||
declaration.Validate = func(rt *shortcut.RuntimeContext) error {
|
||||
if len(hrbrainCleanValues(rt.StrSlice("staff-ids"))) == 0 {
|
||||
return apperrors.NewValidation("--staff-ids 必须包含一个或多个非空员工工号")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
declaration.Execute = func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{"staffIds": hrbrainCleanValues(rt.StrSlice("staff-ids"))}
|
||||
if rt.Changed("all-label") {
|
||||
params["allLabel"] = rt.Bool("all-label")
|
||||
}
|
||||
return hrbrainCallCollection(rt, "get_profile_label", params, "workNo", "staffId", "userId")
|
||||
}
|
||||
return declaration
|
||||
}()
|
||||
|
||||
var ProfileCareer = hrbrainCollectionShortcut(
|
||||
"+profile-career", "查询员工公司内职业历程", "已知稳定工号并需要读取岗位或职级变动历史时使用;下游未通过 live 可用性验证。",
|
||||
"get_employee_career", "work-no", "workNo", "职业历程", []string{"careerId", "id", "workNo"},
|
||||
)
|
||||
|
||||
var ProfilePerformance = hrbrainCollectionShortcut(
|
||||
"+profile-performance", "查询员工历史绩效记录", "已知稳定工号并需要读取历史绩效时使用;下游未通过 live 可用性验证。",
|
||||
"get_employee_performance", "work-no", "workNo", "绩效记录", []string{"performanceId", "id", "workNo"},
|
||||
)
|
||||
|
||||
var SearchEmployees = func() shortcut.Shortcut {
|
||||
declaration := hrbrainEmployeeSearchBase(
|
||||
"+search-employees", "按关键词、部门、职务、职级或人才池搜索员工", "需要按至少一个基础过滤条件搜索员工时使用;当前下游返回业务错误且缺少安全非空 fixture。",
|
||||
"search_employees",
|
||||
)
|
||||
declaration.Constraints = append(hrbrainPageConstraints(), shortcut.Constraint{
|
||||
Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"keyword", "dept-name", "position-name", "job-level", "pool-code"},
|
||||
Description: "至少提供一个员工搜索过滤条件,禁止无条件枚举组织人员",
|
||||
})
|
||||
declaration.Validate = hrbrainValidatePage
|
||||
declaration.Execute = func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{"currentPage": rt.Int("page"), "pageSize": rt.Int("page-size")}
|
||||
for _, pair := range [][2]string{{"keyword", "keyword"}, {"dept-name", "deptName"}, {"position-name", "positionName"}, {"job-level", "jobLevel"}, {"pool-code", "poolCode"}} {
|
||||
hrbrainAddOptional(params, rt, pair[0], pair[1])
|
||||
}
|
||||
return hrbrainCallPage(rt, "search_employees", params, "workNo", "staffId", "userId")
|
||||
}
|
||||
return declaration
|
||||
}()
|
||||
|
||||
var SearchEmployeesStructured = func() shortcut.Shortcut {
|
||||
declaration := hrbrainBase(
|
||||
"+search-employees-structured", "使用高级条件表达式搜索员工", "已先取得有权限字段,并需要组合过滤表达式搜索员工时使用;下游工具当前不可用。",
|
||||
hrbrainPageResult("严格校验的高级员工搜索页"), hrbrainPagination(),
|
||||
[]shortcut.Flag{
|
||||
{Name: "origin-json", Type: shortcut.FlagString, Required: true, Desc: "必须是非空 JSON 对象的搜索表达式"},
|
||||
{Name: "fields", Type: shortcut.FlagString, Required: true, Desc: "必须是非空 JSON 数组的返回字段"},
|
||||
{Name: "order-by", Type: shortcut.FlagStringSlice, Desc: "排序字段列表"},
|
||||
{Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "页码必须大于 0"},
|
||||
{Name: "page-size", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量必须在 1 到 100 之间"},
|
||||
},
|
||||
[]contract.ParamDecl{
|
||||
{Name: "origin-json", Property: "originJson"}, {Name: "fields", Property: "fields"}, {Name: "order-by", Property: "orderByClauses"},
|
||||
{Name: "page", Property: "currentPage", InterfaceType: "number"}, {Name: "page-size", Property: "pageSize", InterfaceType: "number"},
|
||||
},
|
||||
`dws hrbrain +search-employees-structured --origin-json '{"rules":[],"combinator":"and"}' --fields '[{"label":"name","value":"name"}]' --format json`,
|
||||
)
|
||||
declaration.Constraints = append(hrbrainPageConstraints(),
|
||||
shortcut.Constraint{Kind: shortcut.ConstraintCustom, Flags: []string{"origin-json"}, Description: "必须是非空 JSON 对象的搜索表达式"},
|
||||
shortcut.Constraint{Kind: shortcut.ConstraintCustom, Flags: []string{"fields"}, Description: "必须是非空 JSON 数组的返回字段"},
|
||||
)
|
||||
declaration.Validate = func(rt *shortcut.RuntimeContext) error {
|
||||
if err := hrbrainValidatePage(rt); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := hrbrainJSONObject(rt.Str("origin-json"), "--origin-json"); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := hrbrainJSONArray(rt.Str("fields"), "--fields")
|
||||
return err
|
||||
}
|
||||
declaration.Execute = func(rt *shortcut.RuntimeContext) error {
|
||||
// Validate already established both structured input shapes.
|
||||
origin, _ := hrbrainJSONObject(rt.Str("origin-json"), "--origin-json")
|
||||
fields, _ := hrbrainJSONArray(rt.Str("fields"), "--fields")
|
||||
params := map[string]any{
|
||||
"originJson": string(mustJSON(origin)), "fields": fields,
|
||||
"currentPage": rt.Int("page"), "pageSize": rt.Int("page-size"),
|
||||
}
|
||||
if rt.Changed("order-by") {
|
||||
params["orderByClauses"] = hrbrainCleanValues(rt.StrSlice("order-by"))
|
||||
}
|
||||
return hrbrainCallPage(rt, "search_employees_structured", params, "workNo", "staffId", "userId")
|
||||
}
|
||||
return declaration
|
||||
}()
|
||||
|
||||
var SearchFields = func() shortcut.Shortcut {
|
||||
declaration := hrbrainBase(
|
||||
"+search-fields", "获取当前身份可用的高级人才搜索字段", "构造高级员工搜索表达式前发现字段与操作符时使用;下游工具当前返回业务错误,保持 unavailable。",
|
||||
hrbrainCollectionResult("严格校验的高级搜索字段"), nil, nil, nil,
|
||||
`dws hrbrain +search-fields --format json`,
|
||||
)
|
||||
declaration.Execute = func(rt *shortcut.RuntimeContext) error {
|
||||
return hrbrainCallCollection(rt, "get_search_fields", nil, "fieldCode", "code", "value")
|
||||
}
|
||||
return declaration
|
||||
}()
|
||||
|
||||
func hrbrainCollectionShortcut(command, description, intent, tool, flag, property, resultDescription string, identityKeys []string) shortcut.Shortcut {
|
||||
declaration := hrbrainBase(
|
||||
command, description, intent, hrbrainCollectionResult("严格校验的"+resultDescription), nil,
|
||||
[]shortcut.Flag{{Name: flag, Type: shortcut.FlagString, Required: true, Desc: "员工稳定工号"}},
|
||||
[]contract.ParamDecl{{Name: flag, Property: property}},
|
||||
fmt.Sprintf("dws hrbrain %s --%s <WORK_NO> --format json", command, flag),
|
||||
)
|
||||
declaration.Execute = func(rt *shortcut.RuntimeContext) error {
|
||||
return hrbrainCallCollection(rt, tool, map[string]any{property: rt.Str(flag)}, identityKeys...)
|
||||
}
|
||||
return declaration
|
||||
}
|
||||
|
||||
func hrbrainEmployeeSearchBase(command, description, intent, _ string) shortcut.Shortcut {
|
||||
return hrbrainBase(
|
||||
command, description, intent, hrbrainPageResult("严格校验的员工搜索页"), hrbrainPagination(),
|
||||
[]shortcut.Flag{
|
||||
{Name: "keyword", Type: shortcut.FlagString, Desc: "姓名或工号关键词"},
|
||||
{Name: "dept-name", Type: shortcut.FlagString, Desc: "部门名称"},
|
||||
{Name: "position-name", Type: shortcut.FlagString, Desc: "职务名称"},
|
||||
{Name: "job-level", Type: shortcut.FlagString, Desc: "职级"},
|
||||
{Name: "pool-code", Type: shortcut.FlagString, Desc: "人才池稳定 poolCode"},
|
||||
{Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "页码必须大于 0"},
|
||||
{Name: "page-size", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量必须在 1 到 100 之间"},
|
||||
},
|
||||
[]contract.ParamDecl{
|
||||
{Name: "keyword", Property: "keyword"}, {Name: "dept-name", Property: "deptName"}, {Name: "position-name", Property: "positionName"},
|
||||
{Name: "job-level", Property: "jobLevel"}, {Name: "pool-code", Property: "poolCode"},
|
||||
{Name: "page", Property: "currentPage", InterfaceType: "number"}, {Name: "page-size", Property: "pageSize", InterfaceType: "number"},
|
||||
},
|
||||
`dws hrbrain +search-employees --keyword <QUERY> --page 1 --page-size 20 --format json`,
|
||||
)
|
||||
}
|
||||
|
||||
func hrbrainPageConstraints() []shortcut.Constraint {
|
||||
return []shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"page"}, Description: "页码必须大于 0"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-size"}, Description: "每页数量必须在 1 到 100 之间"},
|
||||
}
|
||||
}
|
||||
|
||||
func hrbrainValidatePage(rt *shortcut.RuntimeContext) error {
|
||||
if rt.Int("page") < 1 {
|
||||
return apperrors.NewValidation("--page 必须大于 0")
|
||||
}
|
||||
if size := rt.Int("page-size"); size < 1 || size > 100 {
|
||||
return apperrors.NewValidation("--page-size 必须在 1 到 100 之间")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hrbrainCallObject(rt *shortcut.RuntimeContext, tool string, params map[string]any, expected string, identityKeys ...string) error {
|
||||
data, err := rt.CallMCPData("hrbrain", tool, params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
value, err := responsecheck.RequireSingleObjectResult(data, "hrbrain/"+tool)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
identity := hrbrainIdentity(value, identityKeys...)
|
||||
if identity == "" {
|
||||
return responsecheck.Error("hrbrain/"+tool, "missing_stable_id", "HRbrain 详情对象缺少稳定身份")
|
||||
}
|
||||
if expected != "" && identity != strings.TrimSpace(expected) {
|
||||
return responsecheck.Error("hrbrain/"+tool, "identity_mismatch", "HRbrain 详情对象身份与请求目标不一致")
|
||||
}
|
||||
return rt.Output(map[string]any{"value": value})
|
||||
}
|
||||
|
||||
func hrbrainCallCollection(rt *shortcut.RuntimeContext, tool string, params map[string]any, identityKeys ...string) error {
|
||||
data, err := rt.CallMCPData("hrbrain", tool, params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, err := hrbrainRequireCollectionResult(data, "hrbrain/"+tool, identityKeys...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"count": len(items), "items": items})
|
||||
}
|
||||
|
||||
func hrbrainCallPage(rt *shortcut.RuntimeContext, tool string, params map[string]any, identityKeys ...string) error {
|
||||
page, size := rt.Int("page"), rt.Int("page-size")
|
||||
data, err := rt.CallMCPData("hrbrain", tool, params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, evidence, err := hrbrainProjectPage(data, "hrbrain/"+tool, page, size, identityKeys...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]any{
|
||||
"count": len(items), "items": items, "currentPage": evidence.CurrentPage,
|
||||
"pageSize": evidence.PageSize, "totalCount": evidence.TotalCount, "complete": !evidence.HasMore,
|
||||
}
|
||||
next := ""
|
||||
if evidence.HasMore {
|
||||
next = strconv.Itoa(evidence.CurrentPage + 1)
|
||||
}
|
||||
// The strict page evidence guarantees either terminal+empty-next or
|
||||
// continuing+positive next page, the two valid framework states.
|
||||
pagination, _ := output.NewPagination(!evidence.HasMore, next)
|
||||
return output.StoreResult(rt.Command().Context(), output.Success(payload, output.WithMeta(&output.Meta{
|
||||
Count: output.NewCount(len(items)), Pagination: pagination,
|
||||
})))
|
||||
}
|
||||
|
||||
func hrbrainRequireCollectionResult(data map[string]any, operation string, identityKeys ...string) ([]map[string]any, error) {
|
||||
result, err := responsecheck.RequireResult(data, operation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, ok := result.([]any)
|
||||
if !ok {
|
||||
return nil, responsecheck.Error(operation, "malformed_collection", fmt.Sprintf("响应 result 应为数组,实际为 %T", result))
|
||||
}
|
||||
return hrbrainValidateItems(raw, operation, identityKeys...)
|
||||
}
|
||||
|
||||
func hrbrainProjectPage(data map[string]any, operation string, requestedPage, requestedSize int, identityKeys ...string) ([]map[string]any, hrbrainPage, error) {
|
||||
result, err := responsecheck.RequireObjectResult(data, operation)
|
||||
if err != nil {
|
||||
return nil, hrbrainPage{}, err
|
||||
}
|
||||
raw, ok := result["items"].([]any)
|
||||
if !ok {
|
||||
if _, present := result["items"]; !present {
|
||||
return nil, hrbrainPage{}, responsecheck.Error(operation, "missing_collection", "成功响应缺少 result.items 数组")
|
||||
}
|
||||
return nil, hrbrainPage{}, responsecheck.Error(operation, "malformed_collection", fmt.Sprintf("响应 result.items 应为数组,实际为 %T", result["items"]))
|
||||
}
|
||||
items, err := hrbrainValidateItems(raw, operation, identityKeys...)
|
||||
if err != nil {
|
||||
return nil, hrbrainPage{}, err
|
||||
}
|
||||
evidence, err := hrbrainParsePage(result, operation, requestedPage, requestedSize, len(items))
|
||||
if err != nil {
|
||||
return nil, hrbrainPage{}, err
|
||||
}
|
||||
return items, evidence, nil
|
||||
}
|
||||
|
||||
func hrbrainValidateItems(raw []any, operation string, identityKeys ...string) ([]map[string]any, error) {
|
||||
items := make([]map[string]any, 0, len(raw))
|
||||
seen := make(map[string]struct{}, len(raw))
|
||||
for index, item := range raw {
|
||||
object, ok := item.(map[string]any)
|
||||
if !ok || len(object) == 0 {
|
||||
return nil, responsecheck.Error(operation, "malformed_item", fmt.Sprintf("结果第 %d 项不是非空对象", index))
|
||||
}
|
||||
identity := hrbrainIdentity(object, identityKeys...)
|
||||
if identity == "" {
|
||||
return nil, responsecheck.Error(operation, "missing_item_identity", fmt.Sprintf("结果第 %d 项缺少稳定身份", index))
|
||||
}
|
||||
if _, duplicate := seen[identity]; duplicate {
|
||||
return nil, responsecheck.Error(operation, "duplicate_item_identity", fmt.Sprintf("结果第 %d 项稳定身份重复", index))
|
||||
}
|
||||
seen[identity] = struct{}{}
|
||||
items = append(items, object)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func hrbrainParsePage(result map[string]any, operation string, requestedPage, requestedSize, itemCount int) (hrbrainPage, error) {
|
||||
currentPage, ok := hrbrainInteger(result["currentPage"])
|
||||
if !ok || currentPage != requestedPage || currentPage < 1 {
|
||||
return hrbrainPage{}, responsecheck.Error(operation, "pagination_page_mismatch", "currentPage 缺失、错型或与请求页码不一致")
|
||||
}
|
||||
pageSize, ok := hrbrainInteger(result["pageSize"])
|
||||
if !ok || pageSize != requestedSize || pageSize < 1 || itemCount > pageSize {
|
||||
return hrbrainPage{}, responsecheck.Error(operation, "pagination_size_mismatch", "pageSize 缺失、错型、与请求不一致或小于结果条数")
|
||||
}
|
||||
totalCount, ok := hrbrainInteger(result["totalCount"])
|
||||
if !ok || totalCount < 0 || totalCount < itemCount {
|
||||
return hrbrainPage{}, responsecheck.Error(operation, "invalid_total_count", "totalCount 缺失、错型、为负数或小于当前页条数")
|
||||
}
|
||||
hasMore, ok := result["hasMore"].(bool)
|
||||
if !ok {
|
||||
return hrbrainPage{}, responsecheck.Error(operation, "malformed_pagination", "hasMore 缺失或不是布尔值")
|
||||
}
|
||||
pageEnd := currentPage * pageSize
|
||||
if hasMore && pageEnd >= totalCount || !hasMore && pageEnd < totalCount {
|
||||
return hrbrainPage{}, responsecheck.Error(operation, "conflicting_pagination", "hasMore 与 currentPage/pageSize/totalCount 相互矛盾")
|
||||
}
|
||||
return hrbrainPage{CurrentPage: currentPage, PageSize: pageSize, TotalCount: totalCount, HasMore: hasMore}, nil
|
||||
}
|
||||
|
||||
func hrbrainIdentity(object map[string]any, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
value := object[key]
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
if text := strings.TrimSpace(typed); text != "" {
|
||||
return text
|
||||
}
|
||||
case float64:
|
||||
if typed == math.Trunc(typed) && !math.IsNaN(typed) && !math.IsInf(typed, 0) {
|
||||
return strconv.FormatInt(int64(typed), 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func hrbrainInteger(value any) (int, bool) {
|
||||
switch typed := value.(type) {
|
||||
case int:
|
||||
return typed, true
|
||||
case float64:
|
||||
if typed != math.Trunc(typed) || math.IsNaN(typed) || math.IsInf(typed, 0) || typed > float64(math.MaxInt) || typed < float64(math.MinInt) {
|
||||
return 0, false
|
||||
}
|
||||
return int(typed), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func hrbrainAddOptional(params map[string]any, rt *shortcut.RuntimeContext, flag, property string) {
|
||||
if value := strings.TrimSpace(rt.Str(flag)); value != "" {
|
||||
params[property] = value
|
||||
}
|
||||
}
|
||||
|
||||
func hrbrainCleanValues(values []string) []string {
|
||||
cleaned := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
if value = strings.TrimSpace(value); value != "" {
|
||||
cleaned = append(cleaned, value)
|
||||
}
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
func hrbrainJSONArray(raw, flag string) ([]any, error) {
|
||||
var value []any
|
||||
if err := json.Unmarshal([]byte(raw), &value); err != nil {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("%s 必须是有效 JSON 数组: %v", flag, err))
|
||||
}
|
||||
if len(value) == 0 {
|
||||
return nil, apperrors.NewValidation(flag + " 必须是非空 JSON 数组")
|
||||
}
|
||||
for index, item := range value {
|
||||
object, ok := item.(map[string]any)
|
||||
if !ok || len(object) == 0 {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("%s 第 %d 项必须是非空对象", flag, index))
|
||||
}
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func hrbrainJSONObject(raw, flag string) (map[string]any, error) {
|
||||
var value map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &value); err != nil {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("%s 必须是有效 JSON 对象: %v", flag, err))
|
||||
}
|
||||
if len(value) == 0 {
|
||||
return nil, apperrors.NewValidation(flag + " 必须是非空 JSON 对象")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func mustJSON(value any) []byte {
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return encoded
|
||||
}
|
||||
|
||||
func init() {
|
||||
shortcut.Register(
|
||||
ListPools, GetPool, ListPoolEmployees,
|
||||
ProfileMetadata, QueryProfile, ProfileLabels, ProfileCareer, ProfilePerformance,
|
||||
SearchEmployees, SearchEmployeesStructured, SearchFields,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package hrbrain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"math"
|
||||
"reflect"
|
||||
"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/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type hrbrainCoverageCaller struct{ calls int }
|
||||
|
||||
func (caller *hrbrainCoverageCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
|
||||
caller.calls++
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"success":true,"result":[]}`}}}, nil
|
||||
}
|
||||
func (*hrbrainCoverageCaller) Format() string { return "json" }
|
||||
func (*hrbrainCoverageCaller) DryRun() bool { return false }
|
||||
func (*hrbrainCoverageCaller) Fields() string { return "" }
|
||||
func (*hrbrainCoverageCaller) JQ() string { return "" }
|
||||
|
||||
type hrbrainParityCaller struct {
|
||||
calls int
|
||||
server string
|
||||
tool string
|
||||
params map[string]any
|
||||
response string
|
||||
err error
|
||||
}
|
||||
|
||||
func (caller *hrbrainParityCaller) CallTool(_ context.Context, server, tool string, params map[string]any) (*edition.ToolResult, error) {
|
||||
caller.calls++
|
||||
caller.server = server
|
||||
caller.tool = tool
|
||||
caller.params = params
|
||||
if caller.err != nil {
|
||||
return nil, caller.err
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: caller.response}}}, nil
|
||||
}
|
||||
func (*hrbrainParityCaller) Format() string { return "json" }
|
||||
func (*hrbrainParityCaller) DryRun() bool { return false }
|
||||
func (*hrbrainParityCaller) Fields() string { return "" }
|
||||
func (*hrbrainParityCaller) JQ() string { return "" }
|
||||
|
||||
func runHRbrainCoverage(t *testing.T, declaration shortcut.Shortcut, caller edition.ToolCaller, 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", "")
|
||||
service := &cobra.Command{Use: "hrbrain"}
|
||||
service.AddCommand(corecmd.New(shortcut.FromShortcut(declaration)))
|
||||
root.AddCommand(service)
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
ctx, _ := output.WithResultStore(context.Background())
|
||||
root.SetContext(ctx)
|
||||
root.SetArgs(append([]string{"hrbrain", declaration.Command}, args...))
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageHRbrainDeclarationsStayUnavailableAndTyped(t *testing.T) {
|
||||
declarations := []shortcut.Shortcut{
|
||||
ListPools, GetPool, ListPoolEmployees, ProfileMetadata, QueryProfile, ProfileLabels,
|
||||
ProfileCareer, ProfilePerformance, SearchEmployees, SearchEmployeesStructured, SearchFields,
|
||||
}
|
||||
if len(declarations) != 11 {
|
||||
t.Fatalf("declarations = %d", len(declarations))
|
||||
}
|
||||
for _, declaration := range declarations {
|
||||
if !declaration.Hidden || declaration.Availability != shortcut.AvailabilityUnavailable {
|
||||
t.Errorf("%s must remain hidden/unavailable", declaration.Command)
|
||||
}
|
||||
if declaration.OutputRollout != output.RolloutUnifiedActive || declaration.Contract.Empty() || declaration.Contract.Result == nil {
|
||||
t.Errorf("%s lacks unified Contract/Result", declaration.Command)
|
||||
}
|
||||
if strings.TrimSpace(declaration.Safety.Effect) == "" || declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != "unavailable" {
|
||||
t.Errorf("%s lacks unavailable interface and Safety", declaration.Command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageHRbrainBlockersAreClassifiedWithoutShortcutDefects(t *testing.T) {
|
||||
commands := []string{
|
||||
ListPools.Command, GetPool.Command, ListPoolEmployees.Command, ProfileMetadata.Command,
|
||||
QueryProfile.Command, ProfileLabels.Command, ProfileCareer.Command, ProfilePerformance.Command,
|
||||
SearchEmployees.Command, SearchEmployeesStructured.Command, SearchFields.Command,
|
||||
}
|
||||
counts := map[string]int{}
|
||||
for _, command := range commands {
|
||||
reason := hrbrainUnavailableReason(command)
|
||||
if strings.Contains(reason, "shortcut_defect") || !strings.Contains(reason, "classified=") {
|
||||
t.Fatalf("%s has unreviewed blocker reason %q", command, reason)
|
||||
}
|
||||
switch {
|
||||
case strings.Contains(reason, "classified="+hrbrainBlockerAdapterBusiness):
|
||||
counts[hrbrainBlockerAdapterBusiness]++
|
||||
case strings.Contains(reason, "classified="+hrbrainBlockerTenantFixture):
|
||||
counts[hrbrainBlockerTenantFixture]++
|
||||
default:
|
||||
t.Fatalf("%s has unknown blocker classification %q", command, reason)
|
||||
}
|
||||
}
|
||||
if counts[hrbrainBlockerAdapterBusiness] != 9 || counts[hrbrainBlockerTenantFixture] != 2 {
|
||||
t.Fatalf("blocker counts = %#v", counts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageHRbrainExactParameterProjectionForAllElevenShortcuts(t *testing.T) {
|
||||
pageResponse := `{"success":true,"result":{"items":[{"poolCode":"pool"}],"currentPage":1,"pageSize":20,"totalCount":1,"hasMore":false}}`
|
||||
employeePageResponse := `{"success":true,"result":{"items":[{"workNo":"worker"}],"currentPage":1,"pageSize":20,"totalCount":1,"hasMore":false}}`
|
||||
queryItems := []any{map[string]any{"modelCode": "model", "fields": []any{"field"}}}
|
||||
structuredFields := []any{map[string]any{"label": "field", "value": "field"}}
|
||||
cases := []struct {
|
||||
name string
|
||||
declaration shortcut.Shortcut
|
||||
args []string
|
||||
tool string
|
||||
params map[string]any
|
||||
response string
|
||||
}{
|
||||
{name: "list pools", declaration: ListPools, tool: "list_talent_pools", params: map[string]any{"currentPage": 1, "pageSize": 20}, response: pageResponse},
|
||||
{name: "get pool", declaration: GetPool, args: []string{"--pool-code", "pool"}, tool: "get_talent_pool_detail", params: map[string]any{"poolCode": "pool"}, response: `{"success":true,"result":{"poolCode":"pool"}}`},
|
||||
{name: "list pool employees", declaration: ListPoolEmployees, args: []string{"--pool-code", "pool"}, tool: "list_pool_employees", params: map[string]any{"poolCode": "pool", "currentPage": 1, "pageSize": 20}, response: employeePageResponse},
|
||||
{name: "profile metadata", declaration: ProfileMetadata, args: []string{"--work-no", "worker"}, tool: "get_profile_metadata", params: map[string]any{"workNo": "worker"}, response: `{"success":true,"result":[{"modelCode":"model"}]}`},
|
||||
{name: "query profile", declaration: QueryProfile, args: []string{"--work-no", "worker", "--data-queries", `[{"modelCode":"model","fields":["field"]}]`}, tool: "query_profile_data", params: map[string]any{"workNo": "worker", "dataQueries": queryItems}, response: `{"success":true,"result":[{"modelCode":"model"}]}`},
|
||||
{name: "profile labels", declaration: ProfileLabels, args: []string{"--staff-ids", "worker"}, tool: "get_profile_label", params: map[string]any{"staffIds": []string{"worker"}}, response: `{"success":true,"result":[{"workNo":"worker"}]}`},
|
||||
{name: "profile career", declaration: ProfileCareer, args: []string{"--work-no", "worker"}, tool: "get_employee_career", params: map[string]any{"workNo": "worker"}, response: `{"success":true,"result":[{"careerId":"career"}]}`},
|
||||
{name: "profile performance", declaration: ProfilePerformance, args: []string{"--work-no", "worker"}, tool: "get_employee_performance", params: map[string]any{"workNo": "worker"}, response: `{"success":true,"result":[{"performanceId":"performance"}]}`},
|
||||
{name: "search employees", declaration: SearchEmployees, args: []string{"--keyword", "worker"}, tool: "search_employees", params: map[string]any{"keyword": "worker", "currentPage": 1, "pageSize": 20}, response: employeePageResponse},
|
||||
{name: "search employees structured", declaration: SearchEmployeesStructured, args: []string{"--origin-json", `{"rules":[{"field":"field"}]}`, "--fields", `[{"label":"field","value":"field"}]`}, tool: "search_employees_structured", params: map[string]any{"originJson": `{"rules":[{"field":"field"}]}`, "fields": structuredFields, "currentPage": 1, "pageSize": 20}, response: employeePageResponse},
|
||||
{name: "search fields", declaration: SearchFields, tool: "get_search_fields", params: map[string]any{}, response: `{"success":true,"result":[{"fieldCode":"field"}]}`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &hrbrainParityCaller{response: tc.response}
|
||||
if err := runHRbrainCoverage(t, tc.declaration, caller, tc.args...); err != nil {
|
||||
t.Fatalf("exact Shortcut failed against valid synthetic response: %v", err)
|
||||
}
|
||||
if caller.calls != 1 || caller.server != "hrbrain" || caller.tool != tc.tool {
|
||||
t.Fatalf("remote route = calls:%d server:%q tool:%q, want 1/hrbrain/%s", caller.calls, caller.server, caller.tool, tc.tool)
|
||||
}
|
||||
if !reflect.DeepEqual(caller.params, tc.params) {
|
||||
t.Fatalf("params = %#v, want %#v", caller.params, tc.params)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageHRbrainRejectsNullWrongCollectionsAndBadItems(t *testing.T) {
|
||||
brokenCollections := []map[string]any{
|
||||
{},
|
||||
{"success": false, "result": []any{}},
|
||||
{"success": true, "result": nil},
|
||||
{"success": true, "result": map[string]any{}},
|
||||
{"success": true, "result": []any{"bad"}},
|
||||
{"success": true, "result": []any{map[string]any{"name": "missing-id"}}},
|
||||
{"success": true, "result": []any{map[string]any{"poolCode": "same"}, map[string]any{"poolCode": "same"}}},
|
||||
}
|
||||
for index, payload := range brokenCollections {
|
||||
if got, err := hrbrainRequireCollectionResult(payload, "hrbrain/test", "poolCode"); err == nil {
|
||||
t.Errorf("broken collection %d accepted: %#v", index, got)
|
||||
}
|
||||
}
|
||||
explicitEmpty := map[string]any{"success": true, "result": []any{}}
|
||||
if got, err := hrbrainRequireCollectionResult(explicitEmpty, "hrbrain/test", "poolCode"); err != nil || len(got) != 0 {
|
||||
t.Fatalf("explicit empty collection: got=%#v err=%v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageHRbrainPaginationFailsClosed(t *testing.T) {
|
||||
valid := map[string]any{"success": true, "result": map[string]any{
|
||||
"items": []any{map[string]any{"poolCode": "pool-1"}},
|
||||
"currentPage": float64(1), "pageSize": float64(1), "totalCount": float64(2), "hasMore": true,
|
||||
}}
|
||||
items, page, err := hrbrainProjectPage(valid, "hrbrain/test", 1, 1, "poolCode")
|
||||
if err != nil || len(items) != 1 || !page.HasMore {
|
||||
t.Fatalf("valid page: items=%#v page=%+v err=%v", items, page, err)
|
||||
}
|
||||
broken := []map[string]any{
|
||||
{"success": true, "result": map[string]any{"items": []any{}}},
|
||||
{"success": true, "result": map[string]any{"items": "bad", "currentPage": float64(1), "pageSize": float64(20), "totalCount": float64(0), "hasMore": false}},
|
||||
{"success": true, "result": map[string]any{"items": []any{}, "currentPage": float64(2), "pageSize": float64(20), "totalCount": float64(0), "hasMore": false}},
|
||||
{"success": true, "result": map[string]any{"items": []any{}, "currentPage": float64(1), "pageSize": float64(20), "totalCount": float64(21), "hasMore": false}},
|
||||
{"success": true, "result": map[string]any{"items": []any{}, "currentPage": float64(1), "pageSize": float64(20), "totalCount": float64(20), "hasMore": true}},
|
||||
{"success": true, "result": map[string]any{"items": []any{}, "currentPage": float64(1), "pageSize": float64(20), "totalCount": float64(0), "hasMore": "false"}},
|
||||
}
|
||||
for index, payload := range broken {
|
||||
if got, evidence, err := hrbrainProjectPage(payload, "hrbrain/test", 1, 20, "poolCode"); err == nil {
|
||||
t.Errorf("broken page %d accepted: items=%#v evidence=%+v", index, got, evidence)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageHRbrainStructuredInputsRejectBadShapes(t *testing.T) {
|
||||
for _, raw := range []string{"", "{}", "[]", "[1]", "[{}]"} {
|
||||
if _, err := hrbrainJSONArray(raw, "--items"); err == nil {
|
||||
t.Errorf("bad array accepted: %q", raw)
|
||||
}
|
||||
}
|
||||
for _, raw := range []string{"", "[]", "{}"} {
|
||||
if _, err := hrbrainJSONObject(raw, "--object"); err == nil {
|
||||
t.Errorf("bad object accepted: %q", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageHRbrainInvalidInputMakesZeroRemoteCalls(t *testing.T) {
|
||||
for name, tc := range map[string]struct {
|
||||
declaration shortcut.Shortcut
|
||||
args []string
|
||||
}{
|
||||
"unfiltered employee search": {declaration: SearchEmployees},
|
||||
"invalid page": {declaration: ListPools, args: []string{"--page", "0"}},
|
||||
"invalid page size": {declaration: ListPoolEmployees, args: []string{"--pool-code", "fixture", "--page-size", "101"}},
|
||||
"bad query json": {declaration: QueryProfile, args: []string{"--work-no", "fixture", "--data-queries", `{}`}},
|
||||
"bad structured json": {declaration: SearchEmployeesStructured, args: []string{"--origin-json", `{}`, "--fields", `[]`}},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
caller := &hrbrainCoverageCaller{}
|
||||
if err := runHRbrainCoverage(t, tc.declaration, caller, tc.args...); err == nil {
|
||||
t.Fatal("invalid input unexpectedly succeeded")
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("remote calls before validation = %d, want 0", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageHRbrainExecutionFailuresAndContinuation(t *testing.T) {
|
||||
for name, tc := range map[string]struct {
|
||||
declaration shortcut.Shortcut
|
||||
args []string
|
||||
response string
|
||||
err error
|
||||
}{
|
||||
"object transport": {declaration: GetPool, args: []string{"--pool-code", "pool"}, err: errors.New("transport")},
|
||||
"object malformed": {declaration: GetPool, args: []string{"--pool-code", "pool"}, response: `{"success":true,"result":null}`},
|
||||
"object missing id": {declaration: GetPool, args: []string{"--pool-code", "pool"}, response: `{"success":true,"result":{"name":"pool"}}`},
|
||||
"object wrong id": {declaration: GetPool, args: []string{"--pool-code", "pool"}, response: `{"success":true,"result":{"poolCode":"other"}}`},
|
||||
"collection transport": {declaration: ProfileLabels, args: []string{"--staff-ids", "worker"}, err: errors.New("transport")},
|
||||
"collection malformed": {declaration: ProfileLabels, args: []string{"--staff-ids", "worker"}, response: `{"success":true,"result":null}`},
|
||||
"page transport": {declaration: SearchEmployees, args: []string{"--keyword", "worker"}, err: errors.New("transport")},
|
||||
"page malformed": {declaration: SearchEmployees, args: []string{"--keyword", "worker"}, response: `{"success":true,"result":null}`},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
caller := &hrbrainParityCaller{response: tc.response, err: tc.err}
|
||||
if err := runHRbrainCoverage(t, tc.declaration, caller, tc.args...); err == nil {
|
||||
t.Fatal("invalid remote result succeeded")
|
||||
}
|
||||
if caller.calls != 1 {
|
||||
t.Fatalf("calls = %d", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
continuing := &hrbrainParityCaller{response: `{"success":true,"result":{"items":[{"workNo":"worker"}],"currentPage":1,"pageSize":1,"totalCount":2,"hasMore":true}}`}
|
||||
if err := runHRbrainCoverage(t, SearchEmployees, continuing, "--keyword", "worker", "--page-size", "1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if continuing.calls != 1 {
|
||||
t.Fatalf("continuing calls = %d", continuing.calls)
|
||||
}
|
||||
|
||||
optionalPool := &hrbrainParityCaller{response: `{"success":true,"result":{"items":[{"poolCode":"pool"}],"currentPage":1,"pageSize":20,"totalCount":1,"hasMore":false}}`}
|
||||
if err := runHRbrainCoverage(t, ListPools, optionalPool,
|
||||
"--keyword", "pool", "--pool-type", "type", "--creator", "creator", "--labels", "one,two"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if labels, ok := optionalPool.params["labels"].([]string); !ok || len(labels) != 2 {
|
||||
t.Fatalf("labels = %#v", optionalPool.params["labels"])
|
||||
}
|
||||
|
||||
labelsCaller := &hrbrainParityCaller{response: `{"success":true,"result":[{"workNo":"worker"}]}`}
|
||||
if err := runHRbrainCoverage(t, ProfileLabels, labelsCaller, "--staff-ids", "worker", "--all-label"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if labelsCaller.params["allLabel"] != true {
|
||||
t.Fatalf("allLabel = %#v", labelsCaller.params["allLabel"])
|
||||
}
|
||||
|
||||
structured := &hrbrainParityCaller{response: `{"success":true,"result":{"items":[{"workNo":"worker"}],"currentPage":1,"pageSize":20,"totalCount":1,"hasMore":false}}`}
|
||||
if err := runHRbrainCoverage(t, SearchEmployeesStructured, structured,
|
||||
"--origin-json", `{"rules":[{"field":"field"}]}`, "--fields", `[{"label":"field","value":"field"}]`, "--order-by", "field"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if order, ok := structured.params["orderByClauses"].([]string); !ok || len(order) != 1 {
|
||||
t.Fatalf("orderByClauses = %#v", structured.params["orderByClauses"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageHRbrainRemainingValidationAndScalarEdges(t *testing.T) {
|
||||
t.Run("unknown blocker", func(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("unknown blocker classification did not panic")
|
||||
}
|
||||
}()
|
||||
_ = hrbrainUnavailableReason("+unknown")
|
||||
})
|
||||
|
||||
profileLabelsCommand := corecmd.New(shortcut.FromShortcut(ProfileLabels))
|
||||
if err := profileLabelsCommand.Flags().Set("staff-ids", ","); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ProfileLabels.Validate(shortcut.RuntimeContextForTest(profileLabelsCommand, ProfileLabels)); err == nil {
|
||||
t.Fatal("blank staff IDs succeeded")
|
||||
}
|
||||
badPage := &hrbrainCoverageCaller{}
|
||||
if err := runHRbrainCoverage(t, SearchEmployeesStructured, badPage,
|
||||
"--origin-json", `{"rule":"value"}`, "--fields", `[{"field":"value"}]`, "--page", "0"); err == nil {
|
||||
t.Fatal("structured search invalid page succeeded")
|
||||
}
|
||||
if badPage.calls != 0 {
|
||||
t.Fatalf("structured invalid page remote calls = %d", badPage.calls)
|
||||
}
|
||||
|
||||
if got := hrbrainIdentity(map[string]any{"id": float64(7)}, "id"); got != "7" {
|
||||
t.Fatalf("numeric identity = %q", got)
|
||||
}
|
||||
for _, value := range []any{math.NaN(), math.Inf(1), 1.5} {
|
||||
if got := hrbrainIdentity(map[string]any{"id": value}, "id"); got != "" {
|
||||
t.Fatalf("invalid identity = %q", got)
|
||||
}
|
||||
}
|
||||
if got, ok := hrbrainInteger(7); !ok || got != 7 {
|
||||
t.Fatalf("integer = %d, %v", got, ok)
|
||||
}
|
||||
for _, value := range []any{math.NaN(), math.Inf(1), 1.5, float64(math.MaxInt) * 2, "7"} {
|
||||
if _, ok := hrbrainInteger(value); ok {
|
||||
t.Fatalf("invalid integer accepted: %#v", value)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("marshal panic", func(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("mustJSON did not panic for unsupported value")
|
||||
}
|
||||
}()
|
||||
_ = mustJSON(make(chan int))
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageHRbrainPageSizeAndTotalContradictions(t *testing.T) {
|
||||
missing := map[string]any{"success": true, "result": map[string]any{}}
|
||||
if _, _, err := hrbrainProjectPage(missing, "hrbrain/test", 1, 1, "workNo"); err == nil {
|
||||
t.Fatal("missing page collection succeeded")
|
||||
}
|
||||
badItem := map[string]any{"success": true, "result": map[string]any{
|
||||
"items": []any{"bad"}, "currentPage": float64(1), "pageSize": float64(1), "totalCount": float64(1), "hasMore": false,
|
||||
}}
|
||||
if _, _, err := hrbrainProjectPage(badItem, "hrbrain/test", 1, 1, "workNo"); err == nil {
|
||||
t.Fatal("bad page item succeeded")
|
||||
}
|
||||
tooMany := map[string]any{"success": true, "result": map[string]any{
|
||||
"items": []any{map[string]any{"workNo": "one"}, map[string]any{"workNo": "two"}},
|
||||
"currentPage": float64(1), "pageSize": float64(1), "totalCount": float64(2), "hasMore": true,
|
||||
}}
|
||||
if _, _, err := hrbrainProjectPage(tooMany, "hrbrain/test", 1, 1, "workNo"); err == nil {
|
||||
t.Fatal("page larger than pageSize succeeded")
|
||||
}
|
||||
badTotal := map[string]any{"success": true, "result": map[string]any{
|
||||
"items": []any{map[string]any{"workNo": "one"}},
|
||||
"currentPage": float64(1), "pageSize": float64(1), "totalCount": float64(0), "hasMore": false,
|
||||
}}
|
||||
if _, _, err := hrbrainProjectPage(badTotal, "hrbrain/test", 1, 1, "workNo"); err == nil {
|
||||
t.Fatal("total below item count succeeded")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
// Package pat registers safe Shortcut routing for PAT authorization tasks.
|
||||
package pat
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"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"
|
||||
patcore "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
var safeAgentCodePattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
|
||||
|
||||
var (
|
||||
patBrowserPolicyConfigDir = patcore.BrowserPolicyConfigDir
|
||||
patSetBrowserPolicy = patcore.SetBrowserPolicy
|
||||
patReadBrowserPolicy = patcore.ReadStoredBrowserPolicy
|
||||
)
|
||||
|
||||
func browserPolicyResult() *contract.ResultSpec {
|
||||
return &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
DataSchema: json.RawMessage(`{"type":"object","description":"本地 PAT 浏览器策略预览或已验证写入结果","properties":{"scope":{"type":"string","description":"写入范围:default 或 agent","enum":["default","agent"]},"openBrowser":{"type":"boolean","description":"策略是否允许打开浏览器"},"executed":{"type":"boolean","description":"本次是否实际写入本地策略"},"verified":{"type":"boolean","description":"是否对同一目标完成磁盘读回验证"}},"required":["scope","openBrowser","executed","verified"],"additionalProperties":false}`),
|
||||
}
|
||||
}
|
||||
|
||||
func authorizeResult() *contract.ResultSpec {
|
||||
return &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomePending, contract.ResultOutcomeFailure},
|
||||
DataSchema: json.RawMessage(`{"type":"object","description":"由 routed 原子命令 pat chmod 持有的 PAT 授权计划或逐 scope 终态账本","properties":{"selected":{"type":"integer","description":"计划选择的 scope 数量"},"skipped":{"type":"integer","description":"计划跳过的 scope 数量"},"pending":{"type":"integer","description":"仍待授权的 scope 数量"},"verified":{"type":"boolean","description":"授权身份与逐 scope 终态是否已验证"}},"required":["selected","skipped","pending","verified"],"additionalProperties":false}`),
|
||||
}
|
||||
}
|
||||
|
||||
var BrowserPolicy = shortcut.Shortcut{
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Service: "pat",
|
||||
Command: "+browser-policy",
|
||||
Product: "pat",
|
||||
Description: "安全配置 PAT 授权时是否允许打开本地浏览器",
|
||||
Intent: "需要为默认策略或一个明确 Agent 配置 PAT 撞墙时是否允许打开本地浏览器时使用;支持无写入预览,实际写入需确认并对同一策略项完成磁盘读回。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: corecmd.ContractDecl{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "pat", Name: "shortcut_browser_policy", CanonicalPath: "pat.shortcut_browser_policy",
|
||||
CLIPath: "pat +browser-policy", PrimaryCLIPath: "pat +browser-policy",
|
||||
},
|
||||
Description: "安全配置 PAT 授权时是否允许打开本地浏览器",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable,
|
||||
Reason: "The Shortcut writes only the local PAT policy file, requires confirmation, supports a no-write request preview, omits agent identity from output, and verifies the exact persisted target.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "安全配置 PAT 授权时是否允许打开本地浏览器",
|
||||
UseWhen: []string{"需要为默认策略或一个明确 Agent 配置 PAT 撞墙时是否允许打开本地浏览器时使用;支持无写入预览,实际写入需确认并对同一策略项完成磁盘读回。"},
|
||||
AvoidWhen: []string{"需要预览或授予行为 scope 时使用原子命令 pat chmod;普通 OAuth 登录使用 auth"},
|
||||
Examples: []string{`dws pat +browser-policy --enabled=false --dry-run --format json`},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "enabled"}, {Name: "agent-code"},
|
||||
},
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: contract.DryRunPreviewRequest},
|
||||
Result: browserPolicyResult(),
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "enabled", Type: shortcut.FlagBool, Required: true, Desc: "是否允许打开本地浏览器;必须显式传 true 或 false"},
|
||||
{Name: "agent-code", Type: shortcut.FlagString, Desc: "可选 Agent 标识必须为 1 到 64 位字母、数字、下划线或连字符;只用于定位本地策略项且不会出现在结果中"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"agent-code"}, Description: "可选 Agent 标识必须为 1 到 64 位字母、数字、下划线或连字符"},
|
||||
},
|
||||
Tips: []string{`dws pat +browser-policy --enabled=false --dry-run --format json`},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
value := strings.TrimSpace(rt.Str("agent-code"))
|
||||
if value != "" && !safeAgentCodePattern.MatchString(value) {
|
||||
return apperrors.NewValidation("--agent-code 格式无效;只允许 1 到 64 位字母、数字、下划线或连字符")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
scope := "default"
|
||||
agentCode := strings.TrimSpace(rt.Str("agent-code"))
|
||||
if agentCode != "" {
|
||||
scope = "agent"
|
||||
}
|
||||
enabled := rt.Bool("enabled")
|
||||
if rt.DryRun() {
|
||||
return rt.Output(map[string]any{"scope": scope, "openBrowser": enabled, "executed": false, "verified": false})
|
||||
}
|
||||
configDir := patBrowserPolicyConfigDir()
|
||||
written, err := patSetBrowserPolicy(configDir, agentCode, enabled)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stored, err := patReadBrowserPolicy(configDir, agentCode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if written.Scope != scope || stored.Scope != scope || written.OpenBrowser != enabled || stored.OpenBrowser != enabled || written.AgentCode != stored.AgentCode {
|
||||
return apperrors.NewInternal("PAT 浏览器策略写后读回不一致;结果未验证")
|
||||
}
|
||||
return rt.Output(map[string]any{"scope": scope, "openBrowser": enabled, "executed": true, "verified": true})
|
||||
},
|
||||
}
|
||||
|
||||
var Authorize = shortcut.Shortcut{
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Service: "pat",
|
||||
Command: "+authorize",
|
||||
Product: "pat",
|
||||
Description: "将 PAT 行为 scope 授权路由到原子命令 pat chmod",
|
||||
Intent: "命令提示缺少 PAT 行为授权时 classified=routed:先使用 pat chmod --dry-run 审核计划,用户确认后再由同一原子命令执行;+authorize 本身保持 unavailable。",
|
||||
Risk: shortcut.RiskHighWrite,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "high", Confirmation: "user_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: corecmd.ContractDecl{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "pat", Name: "shortcut_authorize", CanonicalPath: "pat.shortcut_authorize",
|
||||
CLIPath: "pat +authorize", PrimaryCLIPath: "pat +authorize",
|
||||
},
|
||||
Description: "将 PAT 行为 scope 授权路由到原子命令 pat chmod",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceUnavailable,
|
||||
Reason: "classified=routed; pat chmod already owns the reviewed plan, fallback, pending, per-scope ledger, identity, and confirmation chain. The Shortcut runtime cannot reuse that Cobra/ToolCaller closure without duplicating safety semantics, and a live write remains blocked_fixture, so +authorize stays unavailable while routing to pat chmod.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "将 PAT 行为 scope 授权路由到原子命令 pat chmod",
|
||||
UseWhen: []string{"命令提示缺少 PAT 行为授权时 classified=routed:先使用 pat chmod --dry-run 审核计划,用户确认后再由同一原子命令执行;+authorize 本身保持 unavailable。"},
|
||||
AvoidWhen: []string{"不要向此 unavailable Shortcut 传入任何真实身份、会话、scope 或授权材料;本地浏览器策略使用 pat +browser-policy"},
|
||||
Examples: []string{`dws pat +authorize --format json`},
|
||||
},
|
||||
Result: authorizeResult(),
|
||||
},
|
||||
Hidden: true,
|
||||
Availability: shortcut.AvailabilityUnavailable,
|
||||
Execute: func(*shortcut.RuntimeContext) error {
|
||||
return apperrors.NewValidation("PAT 授权任务 classified=routed;+authorize 当前 unavailable,请使用审核后的原子命令 pat chmod,并先执行 --dry-run")
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
shortcut.Register(BrowserPolicy, Authorize)
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package pat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"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"
|
||||
patcore "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
|
||||
"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 patShortcutCaller struct{ calls int }
|
||||
|
||||
func (caller *patShortcutCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
|
||||
caller.calls++
|
||||
return nil, nil
|
||||
}
|
||||
func (*patShortcutCaller) Format() string { return "json" }
|
||||
func (*patShortcutCaller) DryRun() bool { return false }
|
||||
func (*patShortcutCaller) Fields() string { return "" }
|
||||
func (*patShortcutCaller) JQ() string { return "" }
|
||||
|
||||
type patAtomicCall struct {
|
||||
tool string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
type patAtomicFixtureCaller struct {
|
||||
dryRun bool
|
||||
responses []string
|
||||
calls []patAtomicCall
|
||||
}
|
||||
|
||||
func (caller *patAtomicFixtureCaller) CallTool(_ context.Context, _ string, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
copied := make(map[string]any, len(args))
|
||||
for key, value := range args {
|
||||
copied[key] = value
|
||||
}
|
||||
caller.calls = append(caller.calls, patAtomicCall{tool: tool, args: copied})
|
||||
response := `{"success":true,"code":"OK","data":{}}`
|
||||
if index := len(caller.calls) - 1; index < len(caller.responses) {
|
||||
response = caller.responses[index]
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: response}}}, nil
|
||||
}
|
||||
func (*patAtomicFixtureCaller) Format() string { return "json" }
|
||||
func (caller *patAtomicFixtureCaller) DryRun() bool { return caller.dryRun }
|
||||
func (*patAtomicFixtureCaller) Fields() string { return "" }
|
||||
func (*patAtomicFixtureCaller) JQ() string { return "" }
|
||||
|
||||
func runPATShortcut(t *testing.T, declaration shortcut.Shortcut, caller *patShortcutCaller, input string, args ...string) (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: "pat"}
|
||||
service.AddCommand(corecmd.New(shortcut.FromShortcut(declaration)))
|
||||
root.AddCommand(service)
|
||||
var stdout bytes.Buffer
|
||||
root.SetOut(&stdout)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetIn(strings.NewReader(input))
|
||||
root.SetArgs(append([]string{"pat", declaration.Command}, args...))
|
||||
executed, err := root.ExecuteC()
|
||||
if err == nil {
|
||||
_, _, err = output.EmitStoredResult(executed)
|
||||
}
|
||||
return stdout.String(), err
|
||||
}
|
||||
|
||||
func runPATAtomicRoute(t *testing.T, caller *patAtomicFixtureCaller, args ...string) (string, error) {
|
||||
t.Helper()
|
||||
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.PersistentFlags().String("format", "json", "")
|
||||
root.PersistentFlags().Bool("verbose", false, "")
|
||||
patcore.RegisterCommands(root, caller)
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetIn(strings.NewReader(""))
|
||||
root.SetArgs(append([]string{"pat", "chmod"}, args...))
|
||||
|
||||
oldStdout := os.Stdout
|
||||
readPipe, writePipe, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
os.Stdout = writePipe
|
||||
_, runErr := root.ExecuteC()
|
||||
_ = writePipe.Close()
|
||||
os.Stdout = oldStdout
|
||||
outputBytes, readErr := io.ReadAll(readPipe)
|
||||
_ = readPipe.Close()
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
return string(outputBytes), runErr
|
||||
}
|
||||
|
||||
func runPATNativeBrowserPolicy(t *testing.T, caller *patAtomicFixtureCaller, args ...string) (string, error) {
|
||||
t.Helper()
|
||||
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.PersistentFlags().String("format", "json", "")
|
||||
root.PersistentFlags().Bool("verbose", false, "")
|
||||
patcore.RegisterCommands(root, caller)
|
||||
var stdout bytes.Buffer
|
||||
root.SetOut(&stdout)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetIn(strings.NewReader(""))
|
||||
root.SetArgs(append([]string{"pat", "browser-policy"}, args...))
|
||||
_, err := root.ExecuteC()
|
||||
return stdout.String(), err
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePATAuthorizeRouteUsesAtomicPlanFallbackPendingAndLedger(t *testing.T) {
|
||||
plan := `{"success":true,"code":"OK","data":{"items":[{"scope":"synthetic.item:read","disposition":"selected"}],"selectedScopes":["synthetic.item:read"],"skippedScopes":[],"pendingScopes":[]}}`
|
||||
pending := `{"success":true,"code":"OK","data":{"items":[{"scope":"synthetic.item:read","status":"pending"}],"selectedScopes":["synthetic.item:read"],"grantedScopes":[],"skippedScopes":[],"pendingScopes":["synthetic.item:read"]}}`
|
||||
|
||||
preconfirm := &patAtomicFixtureCaller{responses: []string{plan, pending}}
|
||||
if _, err := runPATAtomicRoute(t, preconfirm, "--product", "synthetic", "--grant-type", "once", "--format", "json"); err == nil {
|
||||
t.Fatal("unconfirmed atomic authorization unexpectedly succeeded")
|
||||
}
|
||||
if len(preconfirm.calls) != 0 {
|
||||
t.Fatalf("atomic calls before confirmation = %d, want 0", len(preconfirm.calls))
|
||||
}
|
||||
|
||||
dryRun := &patAtomicFixtureCaller{dryRun: true, responses: []string{plan}}
|
||||
dryOutput, err := runPATAtomicRoute(t, dryRun, "--product", "synthetic", "--grant-type", "once", "--dry-run", "--format", "json")
|
||||
if err != nil {
|
||||
t.Fatalf("atomic plan dry-run: %v", err)
|
||||
}
|
||||
if len(dryRun.calls) != 1 || dryRun.calls[0].tool != "pat.batch_plan" || dryRun.calls[0].args["dryRun"] != true {
|
||||
t.Fatalf("dry-run route = %#v", dryRun.calls)
|
||||
}
|
||||
if !strings.Contains(dryOutput, `"items"`) || !strings.Contains(dryOutput, `"selectedScopes"`) || !strings.Contains(dryOutput, `"pendingScopes"`) {
|
||||
t.Fatal("dry-run did not preserve the synthetic per-scope plan ledger")
|
||||
}
|
||||
|
||||
confirmed := &patAtomicFixtureCaller{responses: []string{plan, pending}}
|
||||
confirmedOutput, err := runPATAtomicRoute(t, confirmed, "--product", "synthetic", "--grant-type", "once", "--yes", "--format", "json")
|
||||
if err != nil {
|
||||
t.Fatalf("confirmed atomic route: %v", err)
|
||||
}
|
||||
if len(confirmed.calls) != 2 || confirmed.calls[0].tool != "pat.batch_plan" || confirmed.calls[1].tool != "pat.batch_grant" {
|
||||
t.Fatalf("confirmed route = %#v", confirmed.calls)
|
||||
}
|
||||
if scopes, ok := confirmed.calls[1].args["scopes"].([]string); !ok || len(scopes) != 1 || scopes[0] != "synthetic.item:read" {
|
||||
t.Fatalf("grant scopes = %#v", confirmed.calls[1].args["scopes"])
|
||||
}
|
||||
for _, key := range []string{"agentCode", "sessionId", "orgId", "uid"} {
|
||||
if _, present := confirmed.calls[1].args[key]; present {
|
||||
t.Fatalf("synthetic fixture unexpectedly carried identity key %s", key)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(confirmedOutput, `"items"`) || !strings.Contains(confirmedOutput, `"pendingScopes"`) || !strings.Contains(confirmedOutput, `"status":"pending"`) {
|
||||
t.Fatal("confirmed route did not preserve the synthetic pending per-scope ledger")
|
||||
}
|
||||
|
||||
fallback := &patAtomicFixtureCaller{responses: []string{
|
||||
`{"success":false,"errorCode":"PAT_BATCH_SCOPE_NOT_DECLARED","data":{}}`,
|
||||
`{"success":true,"code":"OK","data":{"items":[{"scope":"synthetic.item:read","status":"granted"}],"grantedScopes":["synthetic.item:read"],"pendingScopes":[]}}`,
|
||||
}}
|
||||
if _, err := runPATAtomicRoute(t, fallback, "synthetic.item:read", "--grant-type", "once", "--yes", "--format", "json"); err != nil {
|
||||
t.Fatalf("atomic fallback route: %v", err)
|
||||
}
|
||||
if len(fallback.calls) != 2 || fallback.calls[0].tool != "pat.batch_grant" || fallback.calls[1].tool != "pat.grant" {
|
||||
t.Fatalf("fallback route = %#v", fallback.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePATBrowserPolicyConfirmationDryRunReadbackAndCleanup(t *testing.T) {
|
||||
proofRoot := t.TempDir()
|
||||
exactDir := filepath.Join(proofRoot, "exact")
|
||||
if err := os.Mkdir(exactDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("DWS_CONFIG_DIR", exactDir)
|
||||
policyPath := filepath.Join(exactDir, "pat_policy.json")
|
||||
caller := &patShortcutCaller{}
|
||||
|
||||
if _, err := runPATShortcut(t, BrowserPolicy, caller, "", "--enabled=true"); err == nil {
|
||||
t.Fatal("unconfirmed write unexpectedly succeeded")
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("remote calls before confirmation = %d", caller.calls)
|
||||
}
|
||||
if _, err := os.Stat(policyPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("policy file exists before confirmation: %v", err)
|
||||
}
|
||||
|
||||
if _, err := runPATShortcut(t, BrowserPolicy, caller, "", "--enabled=true", "--dry-run"); err != nil {
|
||||
t.Fatalf("dry-run: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(policyPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("dry-run created policy file: %v", err)
|
||||
}
|
||||
|
||||
if _, err := runPATShortcut(t, BrowserPolicy, caller, "yes\n", "--enabled=true"); err != nil {
|
||||
t.Fatalf("confirmed write: %v", err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("local policy Shortcut made %d remote calls", caller.calls)
|
||||
}
|
||||
stored, err := os.ReadFile(policyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read policy: %v", err)
|
||||
}
|
||||
if bytes.Contains(stored, []byte("agentCode")) || !bytes.Contains(stored, []byte(`"openBrowser": true`)) {
|
||||
t.Fatal("default policy did not persist the expected redacted setting")
|
||||
}
|
||||
if err := os.Remove(policyPath); err != nil {
|
||||
t.Fatalf("cleanup policy: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(policyPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("policy cleanup left residue: %v", err)
|
||||
}
|
||||
|
||||
rawDir := filepath.Join(proofRoot, "raw")
|
||||
if err := os.Mkdir(rawDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("DWS_CONFIG_DIR", rawDir)
|
||||
rawPolicyPath := filepath.Join(rawDir, "pat_policy.json")
|
||||
rawCaller := &patAtomicFixtureCaller{}
|
||||
rawOutput, err := runPATNativeBrowserPolicy(t, rawCaller, "--enabled=false", "--format", "json")
|
||||
if err != nil {
|
||||
t.Fatalf("raw browser policy write: %v", err)
|
||||
}
|
||||
if len(rawCaller.calls) != 0 {
|
||||
t.Fatalf("raw local policy command made %d remote calls", len(rawCaller.calls))
|
||||
}
|
||||
var rawReceipt patcore.BrowserPolicySelection
|
||||
if err := json.Unmarshal([]byte(rawOutput), &rawReceipt); err != nil {
|
||||
t.Fatalf("parse raw browser policy receipt: %v", err)
|
||||
}
|
||||
if rawReceipt.Scope != "default" || rawReceipt.Source != "default" || rawReceipt.OpenBrowser || rawReceipt.AgentCode != "" {
|
||||
t.Fatal("raw browser policy did not return a terminal receipt for the requested target")
|
||||
}
|
||||
rawStored, err := os.ReadFile(rawPolicyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read raw policy: %v", err)
|
||||
}
|
||||
if bytes.Contains(rawStored, []byte("agentCode")) || !bytes.Contains(rawStored, []byte(`"openBrowser": false`)) {
|
||||
t.Fatal("raw default policy did not persist the expected redacted setting")
|
||||
}
|
||||
if err := os.Remove(rawPolicyPath); err != nil {
|
||||
t.Fatalf("cleanup raw policy: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(rawPolicyPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("raw policy cleanup left residue: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePATBrowserPolicyOutputOmitsAgentIdentity(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
caller := &patShortcutCaller{}
|
||||
outputText, err := runPATShortcut(t, BrowserPolicy, caller, "", "--enabled=false", "--agent-code", "DWS_TEST_AGENT", "--yes")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(outputText, "DWS_TEST_AGENT") || strings.Contains(outputText, "agentCode") || strings.Contains(outputText, "agent-code") {
|
||||
t.Fatal("public PAT Shortcut output exposed agent identity")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePATBrowserPolicyWriteReadbackFailures(t *testing.T) {
|
||||
failure := errors.New("fixture failure")
|
||||
t.Run("write failure", func(t *testing.T) {
|
||||
testseam.Swap(t, &patBrowserPolicyConfigDir, func() string { return t.TempDir() })
|
||||
testseam.Swap(t, &patSetBrowserPolicy, func(string, string, bool) (patcore.BrowserPolicySelection, error) {
|
||||
return patcore.BrowserPolicySelection{}, failure
|
||||
})
|
||||
if _, err := runPATShortcut(t, BrowserPolicy, &patShortcutCaller{}, "", "--enabled=true", "--yes"); !errors.Is(err, failure) {
|
||||
t.Fatalf("write error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("read failure", func(t *testing.T) {
|
||||
testseam.Swap(t, &patBrowserPolicyConfigDir, func() string { return t.TempDir() })
|
||||
testseam.Swap(t, &patSetBrowserPolicy, func(string, string, bool) (patcore.BrowserPolicySelection, error) {
|
||||
return patcore.BrowserPolicySelection{Scope: "default", OpenBrowser: true}, nil
|
||||
})
|
||||
testseam.Swap(t, &patReadBrowserPolicy, func(string, string) (patcore.BrowserPolicySelection, error) {
|
||||
return patcore.BrowserPolicySelection{}, failure
|
||||
})
|
||||
if _, err := runPATShortcut(t, BrowserPolicy, &patShortcutCaller{}, "", "--enabled=true", "--yes"); !errors.Is(err, failure) {
|
||||
t.Fatalf("read error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("mismatched readback", func(t *testing.T) {
|
||||
testseam.Swap(t, &patBrowserPolicyConfigDir, func() string { return t.TempDir() })
|
||||
testseam.Swap(t, &patSetBrowserPolicy, func(string, string, bool) (patcore.BrowserPolicySelection, error) {
|
||||
return patcore.BrowserPolicySelection{Scope: "default", OpenBrowser: true}, nil
|
||||
})
|
||||
testseam.Swap(t, &patReadBrowserPolicy, func(string, string) (patcore.BrowserPolicySelection, error) {
|
||||
return patcore.BrowserPolicySelection{Scope: "default", OpenBrowser: false}, nil
|
||||
})
|
||||
if _, err := runPATShortcut(t, BrowserPolicy, &patShortcutCaller{}, "", "--enabled=true", "--yes"); err == nil {
|
||||
t.Fatal("mismatched readback succeeded")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePATInvalidAndUnavailablePathsMakeZeroRemoteCalls(t *testing.T) {
|
||||
caller := &patShortcutCaller{}
|
||||
if _, err := runPATShortcut(t, BrowserPolicy, caller, "", "--enabled=true", "--agent-code", "invalid value", "--yes"); err == nil {
|
||||
t.Fatal("invalid agent code unexpectedly succeeded")
|
||||
}
|
||||
if _, err := runPATShortcut(t, Authorize, caller, "", "--yes"); err == nil {
|
||||
t.Fatal("unavailable authorization Shortcut unexpectedly succeeded")
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("invalid/unavailable PAT paths made %d remote calls", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePATShortcutContracts(t *testing.T) {
|
||||
if BrowserPolicy.Hidden || BrowserPolicy.Availability == shortcut.AvailabilityUnavailable {
|
||||
t.Fatal("browser policy must be public/available before semantic decoration")
|
||||
}
|
||||
if BrowserPolicy.OutputRollout != output.RolloutUnifiedActive || BrowserPolicy.Contract.Result == nil || BrowserPolicy.Contract.DryRun == nil || BrowserPolicy.Contract.DryRun.PreviewKind != "request" || strings.TrimSpace(BrowserPolicy.Safety.Effect) == "" {
|
||||
t.Fatal("public browser policy lacks Result/Safety/DryRun/unified output")
|
||||
}
|
||||
if !Authorize.Hidden || Authorize.Availability != shortcut.AvailabilityUnavailable || Authorize.Contract.Interface.Availability != "unavailable" {
|
||||
t.Fatal("PAT authorization Shortcut must stay hidden/unavailable")
|
||||
}
|
||||
if !strings.Contains(Authorize.Contract.Interface.Reason, "classified=routed") || !strings.Contains(Authorize.Contract.Interface.Reason, "blocked_fixture") {
|
||||
t.Fatal("PAT authorization must separate the routed implementation from unproved live write evidence")
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,13 @@ func generatedPublicShortcutCatalog() map[string]struct{} {
|
||||
"aitable\u0000+dashboard-share-get": {},
|
||||
"aitable\u0000+dashboard-share-update": {},
|
||||
"aitable\u0000+dashboard-update": {},
|
||||
"aitable\u0000+datasource-create": {},
|
||||
"aitable\u0000+datasource-get-config": {},
|
||||
"aitable\u0000+datasource-get-fields": {},
|
||||
"aitable\u0000+datasource-list-sources": {},
|
||||
"aitable\u0000+datasource-sync": {},
|
||||
"aitable\u0000+datasource-sync-status": {},
|
||||
"aitable\u0000+datasource-update": {},
|
||||
"aitable\u0000+export-data": {},
|
||||
"aitable\u0000+field-delete": {},
|
||||
"aitable\u0000+field-get": {},
|
||||
@@ -375,6 +382,7 @@ func generatedPublicShortcutCatalog() map[string]struct{} {
|
||||
"minutes\u0000+upload": {},
|
||||
"minutes\u0000+upload-and-analyze": {},
|
||||
"oa\u0000+search-forms": {},
|
||||
"pat\u0000+browser-policy": {},
|
||||
"report\u0000+inbox-list": {},
|
||||
"report\u0000+outbox-list": {},
|
||||
"report\u0000+report-latest": {},
|
||||
|
||||
@@ -64,6 +64,15 @@ var sheetSemanticCatalogJSON []byte
|
||||
//go:embed semantic_catalog_whiteboard.json
|
||||
var whiteboardSemanticCatalogJSON []byte
|
||||
|
||||
//go:embed semantic_catalog_devdoc.json
|
||||
var devdocSemanticCatalogJSON []byte
|
||||
|
||||
//go:embed semantic_catalog_hrbrain.json
|
||||
var hrbrainSemanticCatalogJSON []byte
|
||||
|
||||
//go:embed semantic_catalog_pat.json
|
||||
var patSemanticCatalogJSON []byte
|
||||
|
||||
type semanticCatalogFile struct {
|
||||
Version int `json:"version"`
|
||||
Service string `json:"service"`
|
||||
@@ -101,6 +110,9 @@ var reviewedSemanticCatalog = mustLoadSemanticCatalogs(
|
||||
reportSemanticCatalogJSON,
|
||||
sheetSemanticCatalogJSON,
|
||||
whiteboardSemanticCatalogJSON,
|
||||
devdocSemanticCatalogJSON,
|
||||
hrbrainSemanticCatalogJSON,
|
||||
patSemanticCatalogJSON,
|
||||
)
|
||||
|
||||
func mustLoadSemanticCatalogs(sources ...[]byte) map[string]semanticCatalogRecord {
|
||||
|
||||
@@ -108,6 +108,14 @@
|
||||
"+list-tables": {"disposition":"semantic_adapter","semantic_delta":"把 Base 目录稳定投影为 tableId/tableName 列表,隐藏非表节点。","risk":"read","public":true,"reviewed":true},
|
||||
"+record-share-links": {"disposition":"primary_smart","semantic_delta":"对超过 20 条 recordId 去重分片调用并合并分享链接及逐批结果。","risk":"read","public":true,"reviewed":true},
|
||||
"+resolve-base": {"disposition":"primary_smart","semantic_delta":"按名称搜索并要求唯一匹配,0 或多结果时显式失败而不猜 baseId。","risk":"read","public":true,"reviewed":true},
|
||||
"+resolve-table": {"disposition":"primary_smart","semantic_delta":"在 Base 内按名称要求唯一匹配,0 或多结果时显式失败而不猜 tableId。","risk":"read","public":true,"reviewed":true}
|
||||
"+resolve-table": {"disposition":"primary_smart","semantic_delta":"在 Base 内按名称要求唯一匹配,0 或多结果时显式失败而不猜 tableId。","risk":"read","public":true,"reviewed":true},
|
||||
|
||||
"+datasource-create": {"disposition":"schema_leaf","semantic_delta":"为指定 Base 创建数据源表并触发首次全量同步,返回新建表 ID 和同步任务 ID。","risk":"write","public":true,"reviewed":true},
|
||||
"+datasource-update": {"disposition":"schema_leaf","semantic_delta":"更新已有数据源表的同步配置并触发一次同步;不改配置可只切换 auto 开关或 field-ids。","risk":"write","public":true,"reviewed":true},
|
||||
"+datasource-sync": {"disposition":"schema_leaf","semantic_delta":"对已有数据源表触发手动同步(单次最多 5 张),仅触发即返回同步任务 ID。","risk":"write","public":true,"reviewed":true},
|
||||
"+datasource-sync-status": {"disposition":"schema_leaf","semantic_delta":"批量查询数据源同步任务状态(RUNNING/FINISHED/FAILED),与 sync/create/update 触发后配对使用。","risk":"read","public":true,"reviewed":true},
|
||||
"+datasource-get-config": {"disposition":"schema_leaf","semantic_delta":"读取已有数据源表的同步配置详情(源配置、字段选择、自动同步状态)的一对一入口。","risk":"read","public":true,"reviewed":true},
|
||||
"+datasource-list-sources": {"disposition":"schema_leaf","semantic_delta":"列出指定 Base 下可用的数据源条目(OA 审批模板等),提取 processCode/name/iconUrl/url 用于 sourceConfig。","risk":"read","public":true,"reviewed":true},
|
||||
"+datasource-get-fields": {"disposition":"schema_leaf","semantic_delta":"获取指定数据源来源的可同步字段列表(字段 ID/名称/类型/是否主键),用于决定 field-ids。","risk":"read","public":true,"reviewed":true}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"version": 1,
|
||||
"service": "devdoc",
|
||||
"default_availability": "unavailable",
|
||||
"shortcuts": {
|
||||
"+search-docs": {
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "严格验证 success、result.items、文档 URL 身份和分页一致性;但 exact 与 raw 对运行时随机 zero-match 候选都返回语义近邻,无法满足 public list/search 的保证零命中证明,因此保持 unavailable。",
|
||||
"risk": "read",
|
||||
"availability": "unavailable",
|
||||
"public": false,
|
||||
"reviewed": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"version": 1,
|
||||
"service": "hrbrain",
|
||||
"default_availability": "unavailable",
|
||||
"shortcuts": {
|
||||
"+list-pools": {"disposition":"semantic_adapter","semantic_delta":"classified=adapter_business_service;raw 明确 success=true/result=null,exact fail-closed,无法证明列表成功或合法空。","risk":"read","public":false,"reviewed":true},
|
||||
"+get-pool": {"disposition":"schema_leaf","semantic_delta":"classified=tenant_fixture;exact/raw 到达同一远端操作与错误,但缺少安全非空 pool fixture,不能验证同 ID 详情。","risk":"read","public":false,"reviewed":true},
|
||||
"+list-pool-employees": {"disposition":"semantic_adapter","semantic_delta":"classified=tenant_fixture;exact/raw 到达同一远端操作与错误,但缺少安全非空人才池与员工 fixture,分页合同未获 live 证明。","risk":"read","public":false,"reviewed":true},
|
||||
"+profile-metadata": {"disposition":"semantic_adapter","semantic_delta":"classified=adapter_business_service;exact/raw 的远端 server、operation、upstream code 一致,档案元数据能力仍未解锁。","risk":"read","public":false,"reviewed":true},
|
||||
"+query-profile": {"disposition":"semantic_adapter","semantic_delta":"classified=adapter_business_service;exact/raw 的远端 server、operation、upstream code 一致,档案查询业务链仍未解锁。","risk":"read","public":false,"reviewed":true},
|
||||
"+profile-labels": {"disposition":"semantic_adapter","semantic_delta":"classified=adapter_business_service;raw 明确 success=true/result=null,exact fail-closed,不能把未知结果投成空标签。","risk":"read","public":false,"reviewed":true},
|
||||
"+profile-career": {"disposition":"schema_leaf","semantic_delta":"classified=adapter_business_service;exact/raw 的远端 server、operation、upstream code 一致,职业历程读取仍未解锁。","risk":"read","public":false,"reviewed":true},
|
||||
"+profile-performance": {"disposition":"schema_leaf","semantic_delta":"classified=adapter_business_service;exact/raw 的远端 server、operation、upstream code 一致,且敏感绩效数据缺少受控 live 证明。","risk":"read","public":false,"reviewed":true},
|
||||
"+search-employees": {"disposition":"semantic_adapter","semantic_delta":"classified=adapter_business_service;exact/raw 的远端 server、operation、upstream code 一致,无法完成已知非空与保证零命中证明。","risk":"read","public":false,"reviewed":true},
|
||||
"+search-employees-structured": {"disposition":"semantic_adapter","semantic_delta":"classified=adapter_business_service;exact/raw 的远端 server、operation、upstream code 一致,字段发现与结构化搜索链仍未解锁。","risk":"read","public":false,"reviewed":true},
|
||||
"+search-fields": {"disposition":"semantic_adapter","semantic_delta":"classified=adapter_business_service;无资源输入的 exact/raw 仍返回同一远端错误,字段发现能力未解锁。","risk":"read","public":false,"reviewed":true}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"version": 1,
|
||||
"service": "pat",
|
||||
"default_availability": "unavailable",
|
||||
"shortcuts": {
|
||||
"+browser-policy": {
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "在隔离本地策略文件上提供显式确认、无写入请求预览、同目标磁盘读回和不暴露 agent identity 的统一结果;exact 写入与清理已通过。",
|
||||
"risk": "write",
|
||||
"availability": "available",
|
||||
"public": true,
|
||||
"reviewed": true
|
||||
},
|
||||
"+authorize": {
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "classified=routed;pat chmod 已持有 plan、fallback、pending、逐 scope ledger、身份校验与确认链,+authorize 不复制执行语义;真实授权写仍因 blocked_fixture 未获证明。",
|
||||
"risk": "high-risk-write",
|
||||
"primary": "pat chmod",
|
||||
"public": false,
|
||||
"reviewed": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ usage() {
|
||||
# Single source of truth for the partition set. CI runs one job per partition and
|
||||
# pins its shard names to this list, so a name that appears here without a
|
||||
# dispatch entry below fails closed rather than silently skipping tests.
|
||||
APP_PARTITIONS='schema a-b c-a-l c-m-r c-s-z c-other d-r s-z-example-fuzz'
|
||||
APP_PARTITIONS='schema a-b c-a-l c-m-o c-p-r c-s-z c-other d-r s-z-example-fuzz'
|
||||
|
||||
mode="${1:-}"
|
||||
partition=""
|
||||
@@ -67,7 +67,8 @@ fi
|
||||
schema_count=0
|
||||
ab_count=0
|
||||
cal_count=0
|
||||
cmr_count=0
|
||||
cmo_count=0
|
||||
cpr_count=0
|
||||
csz_count=0
|
||||
cother_count=0
|
||||
dr_count=0
|
||||
@@ -79,7 +80,8 @@ while IFS= read -r test_name; do
|
||||
Test*Schema*) schema_count=$((schema_count + 1)) ;;
|
||||
Test[A-B]*) ab_count=$((ab_count + 1)) ;;
|
||||
TestCrossPlatformCoverage[A-L]*) cal_count=$((cal_count + 1)) ;;
|
||||
TestCrossPlatformCoverage[M-R]*) cmr_count=$((cmr_count + 1)) ;;
|
||||
TestCrossPlatformCoverage[M-O]*) cmo_count=$((cmo_count + 1)) ;;
|
||||
TestCrossPlatformCoverage[P-R]*) cpr_count=$((cpr_count + 1)) ;;
|
||||
TestCrossPlatformCoverage[S-Z]*) csz_count=$((csz_count + 1)) ;;
|
||||
TestC*) cother_count=$((cother_count + 1)) ;;
|
||||
Test[D-R]*) dr_count=$((dr_count + 1)) ;;
|
||||
@@ -103,7 +105,8 @@ for spec in \
|
||||
"schema:$schema_count" \
|
||||
"a-b:$ab_count" \
|
||||
"c-a-l:$cal_count" \
|
||||
"c-m-r:$cmr_count" \
|
||||
"c-m-o:$cmo_count" \
|
||||
"c-p-r:$cpr_count" \
|
||||
"c-s-z:$csz_count" \
|
||||
"c-other:$cother_count" \
|
||||
"d-r:$dr_count" \
|
||||
@@ -142,14 +145,14 @@ for name in $classified; do
|
||||
done
|
||||
|
||||
total_count="$(wc -l < "$tests" | tr -d ' ')"
|
||||
assigned_count=$((schema_count + ab_count + cal_count + cmr_count + csz_count + cother_count + dr_count + sz_count))
|
||||
assigned_count=$((schema_count + ab_count + cal_count + cmo_count + cpr_count + csz_count + cother_count + dr_count + sz_count))
|
||||
if [ "$assigned_count" -ne "$total_count" ]; then
|
||||
printf 'app race partitions assigned %s tests, want %s\n' "$assigned_count" "$total_count" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'app race partitions cover %s top-level tests exactly once: schema=%s a-b=%s c-a-l=%s c-m-r=%s c-s-z=%s c-other=%s d-r=%s s-z-example-fuzz=%s\n' \
|
||||
"$total_count" "$schema_count" "$ab_count" "$cal_count" "$cmr_count" "$csz_count" "$cother_count" "$dr_count" "$sz_count"
|
||||
printf 'app race partitions cover %s top-level tests exactly once: schema=%s a-b=%s c-a-l=%s c-m-o=%s c-p-r=%s c-s-z=%s c-other=%s d-r=%s s-z-example-fuzz=%s\n' \
|
||||
"$total_count" "$schema_count" "$ab_count" "$cal_count" "$cmo_count" "$cpr_count" "$csz_count" "$cother_count" "$dr_count" "$sz_count"
|
||||
|
||||
if [ "$mode" = "verify" ]; then
|
||||
exit 0
|
||||
@@ -208,7 +211,8 @@ run_named_partition() {
|
||||
schema) run_partition schema no-race "$schema_pattern" ;;
|
||||
a-b) run_partition a-b race '^Test[A-B]' "$schema_pattern" ;;
|
||||
c-a-l) run_partition c-a-l race '^TestCrossPlatformCoverage[A-L]' "$schema_pattern" ;;
|
||||
c-m-r) run_partition c-m-r race '^TestCrossPlatformCoverage[M-R]' "$schema_pattern" ;;
|
||||
c-m-o) run_partition c-m-o race '^TestCrossPlatformCoverage[M-O]' "$schema_pattern" ;;
|
||||
c-p-r) run_partition c-p-r race '^TestCrossPlatformCoverage[P-R]' "$schema_pattern" ;;
|
||||
c-s-z) run_partition c-s-z race '^TestCrossPlatformCoverage[S-Z]' "$schema_pattern" ;;
|
||||
c-other) run_partition c-other race '^TestC' '^Test.*Schema|^TestCrossPlatformCoverage' ;;
|
||||
d-r) run_partition d-r race '^Test[D-R]' "$schema_pattern" ;;
|
||||
|
||||
@@ -37,6 +37,9 @@ SEMANTIC_PATHS = [
|
||||
ROOT / "internal" / "shortcut" / "semantic_catalog_report.json",
|
||||
ROOT / "internal" / "shortcut" / "semantic_catalog_sheet.json",
|
||||
ROOT / "internal" / "shortcut" / "semantic_catalog_whiteboard.json",
|
||||
ROOT / "internal" / "shortcut" / "semantic_catalog_devdoc.json",
|
||||
ROOT / "internal" / "shortcut" / "semantic_catalog_hrbrain.json",
|
||||
ROOT / "internal" / "shortcut" / "semantic_catalog_pat.json",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ SERVICE_TO_SKILL = {
|
||||
"mail": ROOT / "skills" / "multi" / "dingtalk-mail" / "SKILL.md",
|
||||
"minutes": ROOT / "skills" / "multi" / "dingtalk-minutes" / "SKILL.md",
|
||||
"oa": ROOT / "skills" / "multi" / "dingtalk-misc" / "references" / "oa.md",
|
||||
"pat": ROOT / "skills" / "multi" / "dingtalk-misc" / "references" / "pat.md",
|
||||
"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",
|
||||
|
||||
+239
@@ -0,0 +1,239 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
MANIFEST="$ROOT/scripts/policy/shortcut-public-e2e-proof.json"
|
||||
BINARY="${DWS_SHORTCUT_PROOF_BINARY:-$ROOT/dws}"
|
||||
GO_BINARY="${GO:-go}"
|
||||
PROOF_ROOT=""
|
||||
|
||||
fail() {
|
||||
printf 'shortcut public E2E proof failed: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if [ -n "$PROOF_ROOT" ] && [ -d "$PROOF_ROOT" ]; then
|
||||
find "$PROOF_ROOT" -type f -delete 2>/dev/null || true
|
||||
find "$PROOF_ROOT" -depth -type d -exec rmdir -- {} \; 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
trap cleanup EXIT HUP INT TERM
|
||||
|
||||
command -v jq >/dev/null 2>&1 || fail "jq is required"
|
||||
command -v "$GO_BINARY" >/dev/null 2>&1 || fail "Go is required"
|
||||
[ -x "$BINARY" ] || fail "current source binary is missing; run make build"
|
||||
[ -f "$MANIFEST" ] || fail "reviewed proof manifest is missing"
|
||||
|
||||
if [ "${DWS_SHORTCUT_PROOF_ALLOW_DIRTY:-0}" != "1" ] &&
|
||||
[ -n "$(git -C "$ROOT" status --porcelain --untracked-files=all)" ]; then
|
||||
fail "worktree must be clean so evidence is bound to current HEAD"
|
||||
fi
|
||||
|
||||
HEAD_SHA="$(git -C "$ROOT" rev-parse HEAD)"
|
||||
[ -n "$HEAD_SHA" ] || fail "cannot resolve current HEAD"
|
||||
|
||||
jq -e '
|
||||
.version == 1 and
|
||||
(.products | type == "array" and length > 0) and
|
||||
(.public_shortcuts | type == "array") and
|
||||
(.products | group_by(.service) | all(.[]; length == 1)) and
|
||||
(.public_shortcuts | group_by([.service, .command]) | all(.[]; length == 1)) and
|
||||
(.products | all(.[];
|
||||
(.service | type == "string" and length > 0) and
|
||||
(.source_total | type == "number") and
|
||||
(.public | type == "number") and
|
||||
(.unavailable | type == "number") and
|
||||
(.source_total == (.public + .unavailable)))) and
|
||||
(.public_shortcuts | all(.[];
|
||||
(.service | type == "string" and length > 0) and
|
||||
(.command | type == "string" and startswith("+")) and
|
||||
(.owning_atomic | type == "string" and length > 0) and
|
||||
(.proof_class | IN("list", "search", "read", "write")) and
|
||||
(.proof_case | type == "string" and length > 0) and
|
||||
(.stable_target | type == "string" and length > 0) and
|
||||
(.pass_labels | type == "array" and length > 0 and all(.[]; type == "string" and endswith("-PASS")))))
|
||||
' "$MANIFEST" >/dev/null || fail "reviewed proof manifest is malformed"
|
||||
|
||||
SOURCE_TOTAL=0
|
||||
PUBLIC_TOTAL=0
|
||||
UNAVAILABLE_TOTAL=0
|
||||
SEMANTIC_PUBLIC=""
|
||||
RUNTIME_PUBLIC=""
|
||||
|
||||
for service in $(jq -r '.products[].service' "$MANIFEST"); do
|
||||
catalog="$ROOT/internal/shortcut/semantic_catalog_${service}.json"
|
||||
[ -f "$catalog" ] || fail "semantic catalog is missing for $service"
|
||||
|
||||
jq -e --arg service "$service" '
|
||||
. as $catalog |
|
||||
.service == $service and
|
||||
.default_availability == "unavailable" and
|
||||
(.shortcuts | type == "object") and
|
||||
(.shortcuts | to_entries | all(.[];
|
||||
(.key | type == "string" and startswith("+")) and
|
||||
(.value | type == "object") and
|
||||
(.value.reviewed == true) and
|
||||
((.value.semantic_delta | type) == "string" and (.value.semantic_delta | length) > 0) and
|
||||
(if (.value.public // false)
|
||||
then ((.value.availability // $catalog.default_availability) == "available")
|
||||
else ((.value.availability // $catalog.default_availability) == "unavailable")
|
||||
end)))
|
||||
' "$catalog" >/dev/null || fail "$service semantic catalog is not truthfully reviewed"
|
||||
|
||||
source_count="$(jq '.shortcuts | length' "$catalog")"
|
||||
public_count="$(jq '[. as $catalog | .shortcuts | to_entries[] | select((.value.public // false) and ((.value.availability // $catalog.default_availability) == "available"))] | length' "$catalog")"
|
||||
unavailable_count=$((source_count - public_count))
|
||||
expected_source="$(jq -r --arg service "$service" '.products[] | select(.service == $service) | .source_total' "$MANIFEST")"
|
||||
expected_public="$(jq -r --arg service "$service" '.products[] | select(.service == $service) | .public' "$MANIFEST")"
|
||||
expected_unavailable="$(jq -r --arg service "$service" '.products[] | select(.service == $service) | .unavailable' "$MANIFEST")"
|
||||
[ "$source_count" -eq "$expected_source" ] || fail "$service source count drifted"
|
||||
[ "$public_count" -eq "$expected_public" ] || fail "$service public count drifted"
|
||||
[ "$unavailable_count" -eq "$expected_unavailable" ] || fail "$service unavailable count drifted"
|
||||
|
||||
semantic_lines="$(jq -r --arg service "$service" '. as $catalog | .shortcuts | to_entries[] | select((.value.public // false) and ((.value.availability // $catalog.default_availability) == "available")) | [$service, .key] | @tsv' "$catalog")"
|
||||
if [ -n "$semantic_lines" ]; then
|
||||
SEMANTIC_PUBLIC="${SEMANTIC_PUBLIC}${semantic_lines}"$'\n'
|
||||
fi
|
||||
|
||||
runtime_output="$($BINARY shortcut list --service "$service" --format json)"
|
||||
printf '%s' "$runtime_output" | jq -e --arg service "$service" '
|
||||
type == "object" and
|
||||
(.count | type == "number") and
|
||||
(.shortcuts | type == "array") and
|
||||
(.count == (.shortcuts | length)) and
|
||||
(.shortcuts | all(.[];
|
||||
type == "object" and
|
||||
.service == $service and
|
||||
(.command | type == "string" and startswith("+")) and
|
||||
.public == true and
|
||||
.reviewed == true and
|
||||
.availability == "available"))
|
||||
' >/dev/null || fail "$service runtime public inventory is malformed"
|
||||
runtime_count="$(printf '%s' "$runtime_output" | jq '.shortcuts | length')"
|
||||
[ "$runtime_count" -eq "$public_count" ] || fail "$service runtime public count does not match its semantic catalog"
|
||||
runtime_lines="$(printf '%s' "$runtime_output" | jq -r '.shortcuts[] | [.service, .command] | @tsv')"
|
||||
if [ -n "$runtime_lines" ]; then
|
||||
RUNTIME_PUBLIC="${RUNTIME_PUBLIC}${runtime_lines}"$'\n'
|
||||
fi
|
||||
|
||||
SOURCE_TOTAL=$((SOURCE_TOTAL + source_count))
|
||||
PUBLIC_TOTAL=$((PUBLIC_TOTAL + public_count))
|
||||
UNAVAILABLE_TOTAL=$((UNAVAILABLE_TOTAL + unavailable_count))
|
||||
done
|
||||
|
||||
SEMANTIC_PUBLIC="$(printf '%s' "$SEMANTIC_PUBLIC" | sed '/^$/d' | LC_ALL=C sort)"
|
||||
RUNTIME_PUBLIC="$(printf '%s' "$RUNTIME_PUBLIC" | sed '/^$/d' | LC_ALL=C sort)"
|
||||
EXPECTED_PUBLIC="$(jq -r '.public_shortcuts[] | [.service, .command] | @tsv' "$MANIFEST" | LC_ALL=C sort)"
|
||||
[ "$SEMANTIC_PUBLIC" = "$RUNTIME_PUBLIC" ] || fail "semantic and runtime public surfaces differ"
|
||||
[ "$RUNTIME_PUBLIC" = "$EXPECTED_PUBLIC" ] || fail "a public shortcut lacks a reviewed exact plus raw proof case"
|
||||
|
||||
printf 'CURRENT-HEAD-PUBLIC-SURFACE-PASS head=%s source=%d public=%d unavailable=%d\n' "$HEAD_SHA" "$SOURCE_TOTAL" "$PUBLIC_TOTAL" "$UNAVAILABLE_TOTAL"
|
||||
|
||||
(cd "$ROOT" && DWS_PACKAGE_VERSION="${DWS_PACKAGE_VERSION:-0.0.0-test}" "$GO_BINARY" test -count=1 ./internal/shortcut/pat -run '^TestCrossPlatformCoveragePATBrowserPolicyConfirmationDryRunReadbackAndCleanup$' >/dev/null) ||
|
||||
fail "PAT exact/raw zero-call structural proof failed"
|
||||
|
||||
run_pat_browser_policy_default() {
|
||||
schema_output="$($BINARY schema --cli-path 'pat +browser-policy' --compact --format json)"
|
||||
printf '%s' "$schema_output" | jq -e '
|
||||
type == "object" and
|
||||
.cli_path == "pat +browser-policy" and
|
||||
.effect == "write" and
|
||||
.risk == "medium" and
|
||||
.confirmation == "user_required" and
|
||||
.idempotency == "idempotent" and
|
||||
.availability == "available" and
|
||||
.interface_mode == "composite" and
|
||||
(.result | type == "object") and
|
||||
(.result.outcomes | type == "array" and index("success") != null and index("failure") != null) and
|
||||
(.result.data_schema.required | sort == ["executed", "openBrowser", "scope", "verified"])
|
||||
' >/dev/null || fail "pat +browser-policy compact contract is incomplete"
|
||||
|
||||
PROOF_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/dws-pat-public-proof.XXXXXX")"
|
||||
exact_dir="$PROOF_ROOT/exact"
|
||||
raw_dir="$PROOF_ROOT/raw"
|
||||
mkdir -p "$exact_dir" "$raw_dir"
|
||||
exact_path="$exact_dir/pat_policy.json"
|
||||
raw_path="$raw_dir/pat_policy.json"
|
||||
|
||||
if env -u DINGTALK_DWS_AGENTCODE -u DINGTALK_DWS_SESSIONID -u DINGTALK_DWS_ORGID -u DINGTALK_DWS_UID \
|
||||
DWS_CONFIG_DIR="$exact_dir" HTTPS_PROXY=http://127.0.0.1:1 HTTP_PROXY=http://127.0.0.1:1 \
|
||||
"$BINARY" pat +browser-policy --enabled=false --format json </dev/null >/dev/null 2>&1; then
|
||||
fail "pat +browser-policy wrote without confirmation"
|
||||
fi
|
||||
[ ! -e "$exact_path" ] || fail "pat +browser-policy left state before confirmation"
|
||||
printf '%s\n' 'PAT-BROWSER-POLICY-PRECONFIRM-ZERO-CALL-PASS'
|
||||
|
||||
exact_dry_run="$(env -u DINGTALK_DWS_AGENTCODE -u DINGTALK_DWS_SESSIONID -u DINGTALK_DWS_ORGID -u DINGTALK_DWS_UID \
|
||||
DWS_CONFIG_DIR="$exact_dir" HTTPS_PROXY=http://127.0.0.1:1 HTTP_PROXY=http://127.0.0.1:1 \
|
||||
"$BINARY" pat +browser-policy --enabled=false --dry-run --format json)"
|
||||
printf '%s' "$exact_dry_run" | jq -e '
|
||||
type == "object" and .ok == true and .outcome == "success" and
|
||||
(.data | type == "object") and .data.scope == "default" and
|
||||
.data.openBrowser == false and .data.executed == false and .data.verified == false
|
||||
' >/dev/null || fail "pat +browser-policy dry-run result is malformed"
|
||||
[ ! -e "$exact_path" ] || fail "pat +browser-policy dry-run wrote policy state"
|
||||
printf '%s\n' 'PAT-BROWSER-POLICY-DRY-RUN-ZERO-WRITE-PASS'
|
||||
|
||||
exact_output="$(env -u DINGTALK_DWS_AGENTCODE -u DINGTALK_DWS_SESSIONID -u DINGTALK_DWS_ORGID -u DINGTALK_DWS_UID \
|
||||
DWS_CONFIG_DIR="$exact_dir" HTTPS_PROXY=http://127.0.0.1:1 HTTP_PROXY=http://127.0.0.1:1 \
|
||||
"$BINARY" pat +browser-policy --enabled=false --yes --format json)"
|
||||
printf '%s' "$exact_output" | jq -e '
|
||||
type == "object" and .ok == true and .outcome == "success" and
|
||||
(.data | type == "object") and .data.scope == "default" and
|
||||
.data.openBrowser == false and .data.executed == true and .data.verified == true
|
||||
' >/dev/null || fail "pat +browser-policy terminal receipt is malformed"
|
||||
printf '%s\n' 'PAT-BROWSER-POLICY-EXACT-TERMINAL-RECEIPT-PASS'
|
||||
|
||||
jq -e '
|
||||
type == "object" and (.default | type == "object") and
|
||||
.default.openBrowser == false and ((has("agents") | not) or .agents == {})
|
||||
' "$exact_path" >/dev/null || fail "pat +browser-policy exact target readback failed"
|
||||
printf '%s\n' 'PAT-BROWSER-POLICY-EXACT-READBACK-PASS'
|
||||
|
||||
raw_output="$(env -u DINGTALK_DWS_AGENTCODE -u DINGTALK_DWS_SESSIONID -u DINGTALK_DWS_ORGID -u DINGTALK_DWS_UID \
|
||||
DWS_CONFIG_DIR="$raw_dir" HTTPS_PROXY=http://127.0.0.1:1 HTTP_PROXY=http://127.0.0.1:1 \
|
||||
"$BINARY" pat browser-policy --enabled=false --format json)"
|
||||
printf '%s' "$raw_output" | jq -e '
|
||||
type == "object" and .scope == "default" and .source == "default" and
|
||||
.openBrowser == false and (has("agentCode") | not)
|
||||
' >/dev/null || fail "pat browser-policy terminal receipt is malformed"
|
||||
printf '%s\n' 'PAT-BROWSER-POLICY-RAW-TERMINAL-RECEIPT-PASS'
|
||||
|
||||
jq -e '
|
||||
type == "object" and (.default | type == "object") and
|
||||
.default.openBrowser == false and ((has("agents") | not) or .agents == {})
|
||||
' "$raw_path" >/dev/null || fail "pat browser-policy raw target readback failed"
|
||||
printf '%s\n' 'PAT-BROWSER-POLICY-RAW-READBACK-PASS'
|
||||
|
||||
unlink "$exact_path"
|
||||
unlink "$raw_path"
|
||||
[ ! -e "$exact_path" ] || fail "pat +browser-policy cleanup left exact residue"
|
||||
[ ! -e "$raw_path" ] || fail "pat browser-policy cleanup left raw residue"
|
||||
find "$exact_dir" "$raw_dir" -type f -delete
|
||||
[ -z "$(find "$PROOF_ROOT" -type f -print -quit)" ] || fail "PAT browser policy proof left file residue"
|
||||
find "$PROOF_ROOT" -depth -type d -exec rmdir -- {} \;
|
||||
[ ! -e "$PROOF_ROOT" ] || fail "PAT browser policy proof left directory residue"
|
||||
PROOF_ROOT=""
|
||||
printf '%s\n' 'PAT-BROWSER-POLICY-CLEANUP-ABSENCE-PASS'
|
||||
}
|
||||
|
||||
while IFS=$'\t' read -r service command proof_case; do
|
||||
[ -n "$service" ] || continue
|
||||
case "$service $command $proof_case" in
|
||||
'pat +browser-policy pat_browser_policy_default')
|
||||
proof_output="$(run_pat_browser_policy_default)"
|
||||
;;
|
||||
*)
|
||||
fail "$service $command has no executable proof implementation"
|
||||
;;
|
||||
esac
|
||||
actual_labels="$(printf '%s\n' "$proof_output" | sed '/^$/d' | LC_ALL=C sort)"
|
||||
expected_labels="$(jq -r --arg service "$service" --arg command "$command" '.public_shortcuts[] | select(.service == $service and .command == $command) | .pass_labels[]' "$MANIFEST" | LC_ALL=C sort)"
|
||||
[ "$actual_labels" = "$expected_labels" ] || fail "$service $command did not emit every reviewed PASS label"
|
||||
printf '%s\n' "$proof_output"
|
||||
done < <(jq -r '.public_shortcuts[] | [.service, .command, .proof_case] | @tsv' "$MANIFEST")
|
||||
|
||||
printf 'EVERY-PUBLIC-SHORTCUT-DOUBLE-LAYER-E2E-PASS total=%d\n' "$PUBLIC_TOTAL"
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"version": 1,
|
||||
"products": [
|
||||
{
|
||||
"service": "devdoc",
|
||||
"source_total": 1,
|
||||
"public": 0,
|
||||
"unavailable": 1
|
||||
},
|
||||
{
|
||||
"service": "hrbrain",
|
||||
"source_total": 11,
|
||||
"public": 0,
|
||||
"unavailable": 11
|
||||
},
|
||||
{
|
||||
"service": "pat",
|
||||
"source_total": 2,
|
||||
"public": 1,
|
||||
"unavailable": 1
|
||||
}
|
||||
],
|
||||
"public_shortcuts": [
|
||||
{
|
||||
"service": "pat",
|
||||
"command": "+browser-policy",
|
||||
"owning_atomic": "pat browser-policy",
|
||||
"proof_class": "write",
|
||||
"proof_case": "pat_browser_policy_default",
|
||||
"stable_target": "default-policy",
|
||||
"pass_labels": [
|
||||
"PAT-BROWSER-POLICY-PRECONFIRM-ZERO-CALL-PASS",
|
||||
"PAT-BROWSER-POLICY-DRY-RUN-ZERO-WRITE-PASS",
|
||||
"PAT-BROWSER-POLICY-EXACT-TERMINAL-RECEIPT-PASS",
|
||||
"PAT-BROWSER-POLICY-RAW-TERMINAL-RECEIPT-PASS",
|
||||
"PAT-BROWSER-POLICY-EXACT-READBACK-PASS",
|
||||
"PAT-BROWSER-POLICY-RAW-READBACK-PASS",
|
||||
"PAT-BROWSER-POLICY-CLEANUP-ABSENCE-PASS"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -44,7 +44,7 @@ cli_version: ">=1.0.15"
|
||||
| 服务 | shortcut 数 | multi skill |
|
||||
|---|---:|---|
|
||||
| `aisearch` | 1 | `—` |
|
||||
| `aitable` | 93 | `dingtalk-aitable` |
|
||||
| `aitable` | 100 | `dingtalk-aitable` |
|
||||
| `attendance` | 8 | `dingtalk-misc` |
|
||||
| `calendar` | 27 | `dingtalk-calendar` |
|
||||
| `chat` | 98 | `dingtalk-chat` |
|
||||
@@ -56,6 +56,7 @@ cli_version: ">=1.0.15"
|
||||
| `mail` | 8 | `dingtalk-mail` |
|
||||
| `minutes` | 27 | `dingtalk-minutes` |
|
||||
| `oa` | 1 | `dingtalk-misc` |
|
||||
| `pat` | 1 | `dingtalk-misc` |
|
||||
| `report` | 4 | `dingtalk-misc` |
|
||||
| `sheet` | 2 | `dingtalk-misc` |
|
||||
| `todo` | 21 | `dingtalk-todo` |
|
||||
|
||||
@@ -521,17 +521,32 @@ Example:
|
||||
dws doc permission add --node <DOC_ID> --users uid1 --role READER
|
||||
dws doc permission add --node <DOC_ID> --users uid1,uid2 --role EDITOR
|
||||
dws doc permission add --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>" --users uid1 --role MANAGER
|
||||
dws doc permission add --node <DOC_ID> --members '[{"type":"USER","id":"uid1","roleId":"READER","corpId":"xxx"},{"type":"DEPT","id":"deptId1","roleId":"EDITOR","corpId":"xxx"}]' --notify
|
||||
dws doc permission add --node <DOC_ID> --members '[{"type":"CONVERSATION","id":"cidXXX","roleId":"READER"},{"type":"TAG","id":"tagId1","roleId":"EDITOR","corpId":"xxx"}]'
|
||||
Flags:
|
||||
--node string 目标文档/文件夹的 ID 或 URL (必填)
|
||||
--users strings 被授权的用户 userId 列表,逗号分隔 (必填,单次最多 30 个)
|
||||
--role string 授予的角色 (必填,大小写敏感,必须全大写): MANAGER (管理者) / EDITOR (可编辑) / DOWNLOADER (可下载) / READER (可阅读)
|
||||
--users string 被授权的用户 userId 列表,逗号分隔 (旧格式,单次最多 30 个)
|
||||
--role string 授予的角色 (旧格式必填,大小写不敏感): MANAGER (管理者) / EDITOR (可编辑) / DOWNLOADER (可下载) / READER (可阅读)
|
||||
--members string 成员列表 JSON 数组(新格式),支持 USER/DEPT/CONVERSATION/TAG 类型(TAG=角色组),与 --users 互斥
|
||||
--notify bool 是否通知被添加的成员 (仅 --members 新格式时生效,默认 false)
|
||||
--workspace string 所属知识库 ID (选填,仅用于辅助构造返回的 docUrl,业务实际依赖 nodeId)
|
||||
```
|
||||
|
||||
> **两种传参方式(互斥)**:
|
||||
> - 旧格式:`--users` 传入逗号分隔的 userId 列表 + `--role` 指定统一角色(仅 USER 类型)
|
||||
> - 新格式:`--members` 传入 JSON 数组,支持 USER/DEPT/CONVERSATION/TAG 四种成员类型,每个成员携带独立 `roleId`
|
||||
>
|
||||
> **成员类型说明**:
|
||||
> - `USER` — 用户,id 为用户 userId,需携带 `corpId`(标识用户所属组织)
|
||||
> - `DEPT` — 部门,id 为部门 ID,需携带 `corpId`(标识部门所属组织)
|
||||
> - `CONVERSATION` — 群聊,id 为群聊 conversationId(cid 开头),无需 `corpId`
|
||||
> - `TAG` — 角色标签(也称角色组),id 为角色标签 ID,需携带 `corpId`。当用户要求"添加角色组"或"添加角色标签"时使用此类型
|
||||
>
|
||||
> **重要约束**:
|
||||
> - 仅支持 USER 类型授权。
|
||||
> - 角色枚举严格大写:MANAGER / EDITOR / DOWNLOADER / READER(OWNER 不可通过此接口添加)。
|
||||
> - 操作者需在该节点具备「可编辑(EDITOR)」及以上角色(OWNER / MANAGER / EDITOR)。
|
||||
> - 角色枚举(大小写不敏感):MANAGER / EDITOR / DOWNLOADER / READER(OWNER 不可通过此接口添加)。
|
||||
> - 操作者须满足该节点配置的权限管理最低角色要求(默认 MANAGER,可配置为 EDITOR 等),权限不足返回 `forbidden.accessDenied`。
|
||||
> - 单次请求最多 30 个成员,超出请分批调用。
|
||||
> - `--notify` 仅在新格式(`--members`)时生效,仅对 USER 和 CONVERSATION 类型成员发送通知(DEPT 和 TAG 不通知),默认 false;省略时不会向服务端发送该字段,需要通知请显式传 `--notify`。
|
||||
> - 授权对象是文档节点本身,不需要也不应该用 `wiki member add`(那个是知识库容器级授权)。
|
||||
|
||||
### 修改文档权限(节点级)
|
||||
@@ -541,13 +556,36 @@ Usage:
|
||||
Example:
|
||||
dws doc permission update --node <DOC_ID> --users uid1 --role EDITOR
|
||||
dws doc permission update --node <DOC_ID> --users uid1,uid2 --role READER
|
||||
dws doc permission update --node <DOC_ID> --members '[{"type":"USER","id":"uid1","roleId":"EDITOR","corpId":"xxx"}]' --notify=false
|
||||
dws doc permission update --node <DOC_ID> --members '[{"type":"TAG","id":"tagId1","roleId":"READER","corpId":"xxx"}]'
|
||||
Flags:
|
||||
--node string 目标文档/文件夹的 ID 或 URL (必填)
|
||||
--users strings 目标用户 userId 列表,逗号分隔 (必填,单次最多 30 个)
|
||||
--role string 新角色 (必填,大小写敏感,必须全大写): MANAGER / EDITOR / DOWNLOADER / READER
|
||||
--users string 被更新的用户 userId 列表,逗号分隔 (旧格式,单次最多 30 个)
|
||||
--role string 新角色 (旧格式必填,大小写不敏感): MANAGER / EDITOR / DOWNLOADER / READER
|
||||
--members string 成员列表 JSON 数组(新格式),支持 USER/DEPT/CONVERSATION/TAG 类型(TAG=角色组),与 --users 互斥
|
||||
--notify bool 是否通知被变更的成员 (仅 --members 新格式时生效,默认 false)
|
||||
--workspace string 所属知识库 ID (选填)
|
||||
```
|
||||
|
||||
> **两种传参方式(互斥)**:
|
||||
> - 旧格式:`--users` 传入逗号分隔的 userId 列表 + `--role` 指定统一角色(仅 USER 类型)
|
||||
> - 新格式:`--members` 传入 JSON 数组,支持 USER/DEPT/CONVERSATION/TAG 四种成员类型,每个成员携带独立 `roleId`
|
||||
>
|
||||
> **成员类型说明**:
|
||||
> - `USER` — 用户,id 为用户 userId,需携带 `corpId`
|
||||
> - `DEPT` — 部门,id 为部门 ID,需携带 `corpId`
|
||||
> - `CONVERSATION` — 群聊,id 为群聊 conversationId(cid 开头),无需 `corpId`
|
||||
> - `TAG` — 角色标签(也称角色组),id 为角色标签 ID,需携带 `corpId`
|
||||
>
|
||||
> **重要约束**:
|
||||
> - OWNER 角色不可通过此接口变更。
|
||||
> - 同一成员在同一节点只能拥有一个角色,变更后旧角色自动替换。
|
||||
> - 若成员的角色来自父节点的权限继承(PASS_ON),且继承角色高于目标角色,接口会拒绝操作。
|
||||
> - 操作者须满足该节点配置的权限管理最低角色要求(默认 MANAGER,可配置为 EDITOR 等),权限不足返回 `forbidden.accessDenied`。
|
||||
> - 单次请求最多 30 个成员,超出请分批调用。
|
||||
> - `--notify` 仅在新格式(`--members`)时生效,仅对 USER 和 CONVERSATION 类型成员发送通知(DEPT 和 TAG 不通知),默认 false。
|
||||
> - 仅可更新已存在协作关系的成员,新增协作者请使用 `dws doc permission add`。
|
||||
|
||||
### 列出文档权限(节点级)
|
||||
```
|
||||
Usage:
|
||||
@@ -556,14 +594,16 @@ Example:
|
||||
dws doc permission list --node <DOC_ID>
|
||||
dws doc permission list --node <DOC_ID> --limit 50
|
||||
dws doc permission list --node <DOC_ID> --filter-role EDITOR
|
||||
dws doc permission list --node <DOC_ID> --next-token <上次返回的 nextToken>
|
||||
Flags:
|
||||
--node string 目标文档/文件夹的 ID 或 URL (必填)
|
||||
--workspace string 所属知识库 ID (选填)
|
||||
--limit int 返回数量上限,最大 200 (默认 50)
|
||||
--limit int 返回数量上限,最大 50 (默认 30)
|
||||
--filter-role string 按角色过滤: MANAGER / EDITOR / DOWNLOADER / READER (选填)
|
||||
--next-token string 分页游标,首次不传,后续传入上一次返回的 nextToken (选填)
|
||||
```
|
||||
|
||||
> 接口不支持游标分页,使用 `--limit` 一次性拉取。
|
||||
> 底层一次性返回全量成员后在内存中按 pageSize 分页;当 `hasMore` 为 true 时,传入 `--next-token` 即可获取下一页。
|
||||
|
||||
### 导出在线文档为 docx(一体化命令)
|
||||
```
|
||||
@@ -716,9 +756,17 @@ Flags:
|
||||
- 删除 → `block delete`
|
||||
|
||||
用户说"给某人开权限/分享给某人/授权某文档/把这篇文档给 xxx 看":
|
||||
- 新增权限 → `permission add`(需 `--node` + `--user` + `--role`)
|
||||
- 新增权限 → `permission add`(需 `--node` + `--user` + `--role`,或 `--node` + `--members`)
|
||||
- 修改权限 → `permission update`
|
||||
- 查看谁有权限 → `permission list`
|
||||
- 授权后要告知对方("并通知他/告知一下/提醒对方/让他知道")→ 走 `--members` 并追加 `--notify`
|
||||
|
||||
> **通知意图 → `--notify`**(默认不通知,省略时 CLI 不向服务端发送该字段):
|
||||
> - 用户明确要求“通知 / 告知 / 提醒对方 / 让他知道” → 追加 `--notify`
|
||||
> - 用户明确要求“不要通知 / 别提醒 / 悄悄加 / 不要打扰” → 追加 `--notify=false`
|
||||
> - 用户没提通知需求 → **不传该 flag**,保持不通知;不要自行补上 `--notify`
|
||||
> - `--notify` 仅在 `--members` 新格式下生效;旧格式 `--users` 下传了也不会生效,有通知需求必须改用 `--members`
|
||||
> - 仅 USER 和 CONVERSATION 类型成员会收到通知;被授权对象是 DEPT / TAG 时通知不会送达,**需主动向用户说明这一点**,不要默不作声
|
||||
|
||||
> **关键区分**:
|
||||
> - "把**某篇文档**授权给某人" → `doc permission add`(节点级,包括「我的文档」下的文档都支持)
|
||||
|
||||
@@ -594,6 +594,7 @@ Flags:
|
||||
用户说"回收站/查看回收站/回收站列表/回收站里有什么" → `recycle list`
|
||||
用户说"恢复文件/还原删除的文件/从回收站恢复/还原回收站文件" → `recycle restore`
|
||||
用户说"给文档授权/分享权限" → `permission add`
|
||||
用户说"授权并通知对方/加权限后告知他/通知一下被授权的人" → `permission add --members ... --notify`(未提通知需求时不传 `--notify`)
|
||||
用户说"公开文件/互联网公开/设置公开/让互联网所有人可访问" → `publish set`
|
||||
用户说"关闭公开/取消公开/取消互联网访问" → `publish unset`
|
||||
用户说"查看公开状态/是否公开/发布状态" → `publish get`
|
||||
@@ -610,6 +611,13 @@ Flags:
|
||||
|
||||
**drive permission vs wiki member**: "给某篇文档/文件授权" → `drive permission add`(节点级);"给某个知识库整体加成员" → `wiki member add`(空间级)
|
||||
|
||||
**通知意图 → `--notify`**(默认不通知,省略时 CLI 不向服务端发送该字段):
|
||||
- 用户明确要求“通知 / 告知 / 提醒对方 / 让他知道” → 追加 `--notify`
|
||||
- 用户明确要求“不要通知 / 别提醒 / 悄悄加 / 不要打扰” → 追加 `--notify=false`
|
||||
- 用户没提通知需求 → **不传该 flag**,保持不通知;不要自行补上 `--notify`
|
||||
- `--notify` 仅在 `--members` 新格式下生效;旧格式 `--users` 下传了也不会生效,有通知需求必须改用 `--members`
|
||||
- 仅 USER 和 CONVERSATION 类型成员会收到通知;被授权对象是 DEPT / TAG 时通知不会送达,**需主动向用户说明这一点**,不要默不作声
|
||||
|
||||
**创建在线文档/表格/脑图**: drive 不支持创建文件,需走 `wiki node create --type <type>`(创建空节点)或 `doc create`(创建并写入内容)。
|
||||
|
||||
**导出文档/导出为Word**: 导出是内容层操作,走 `doc export`,不属于 drive。
|
||||
@@ -710,18 +718,42 @@ dws wiki node create --type folder --name "文件夹名" --workspace <WORKSPACE_
|
||||
```
|
||||
Usage:
|
||||
dws drive permission add --node <ID> --users uid1,uid2 --role READER
|
||||
dws drive permission add --node <ID> --members '[{"type":"USER","id":"uid1","roleId":"READER","corpId":"xxx"},{"type":"TAG","id":"tagId1","roleId":"EDITOR","corpId":"xxx"}]' --notify
|
||||
dws drive permission update --node <ID> --users uid1 --role EDITOR
|
||||
dws drive permission update --node <ID> --members '[{"type":"USER","id":"uid1","roleId":"EDITOR","corpId":"xxx"}]' --notify=false
|
||||
dws drive permission update --node <ID> --members '[{"type":"CONVERSATION","id":"cidXXX","roleId":"READER"}]'
|
||||
dws drive permission list --node <ID>
|
||||
dws drive permission list --node <ID> --limit 50 --next-token <上次返回的 nextToken>
|
||||
dws drive permission remove --node <ID> --users uid1
|
||||
dws drive permission remove --node <ID> --members '[{"type":"USER","id":"uid1","corpId":"xxx"},{"type":"DEPT","id":"deptId1","corpId":"xxx"}]'
|
||||
Flags:
|
||||
--node string 目标节点 ID 或 URL (必填)
|
||||
--users string 用户 userId 列表,逗号分隔 (add/update/remove 必填)
|
||||
--role string 角色: MANAGER / EDITOR / DOWNLOADER / READER (add/update 必填)
|
||||
--workspace string 知识库 ID (选填)
|
||||
--limit int 返回成员数上限 (仅 list,默认 30,最大 200)
|
||||
--filter-role string 按角色过滤: OWNER / MANAGER / EDITOR / DOWNLOADER / READER (仅 list)
|
||||
--node string 目标节点 ID 或 URL (必填)
|
||||
--users string 用户 userId 列表,逗号分隔 (旧格式)
|
||||
--role string 角色: MANAGER / EDITOR / DOWNLOADER / READER (旧格式必填)
|
||||
--members string 成员列表 JSON 数组(新格式),支持 USER/DEPT/CONVERSATION/TAG 类型(TAG=角色组),与 --users 互斥
|
||||
--notify bool 是否通知被添加/变更的成员 (仅 --members 新格式时生效,add / update 均默认 false)
|
||||
--limit int 返回成员数上限 (仅 list,默认 30,最大 50)
|
||||
--filter-role string 按角色过滤 (仅 list)
|
||||
--next-token string 分页游标,首次不传,后续传入上一次返回的 nextToken (仅 list)
|
||||
--workspace string 知识库 ID (选填)
|
||||
```
|
||||
|
||||
> **add / update / remove 支持两种传参方式(互斥)**:
|
||||
> - 旧格式:`--users` 传入逗号分隔的 userId 列表 + `--role` 指定统一角色(仅 USER 类型)
|
||||
> - 新格式:`--members` 传入 JSON 数组,支持 USER/DEPT/CONVERSATION/TAG 四种成员类型,每个 member 携带独立 `roleId`(remove 只需 type 和 id,但 USER/DEPT/TAG 仍需 corpId)
|
||||
>
|
||||
> **成员类型说明**:
|
||||
> - `USER` — 用户,id 为用户 userId,需携带 `corpId`(标识用户所属组织)
|
||||
> - `DEPT` — 部门,id 为部门 ID,需携带 `corpId`(标识部门所属组织)
|
||||
> - `CONVERSATION` — 群聊,id 为群聊 conversationId(cid 开头),无需 `corpId`
|
||||
> - `TAG` — 角色标签(也称角色组),id 为角色标签 ID,需携带 `corpId`。当用户要求"添加角色组"或"添加角色标签"时使用此类型
|
||||
>
|
||||
> **重要约束**:
|
||||
> - `--notify` 仅在新格式时生效,仅对 USER 和 CONVERSATION 类型成员发送通知(DEPT 和 TAG 不通知),add / update 均默认 false;省略时不会向服务端发送该字段,需要通知请显式传 `--notify`
|
||||
> - 操作者须满足该节点配置的权限管理最低角色要求(默认 MANAGER,可配置为 EDITOR 等),权限不足返回 `forbidden.accessDenied`
|
||||
> - 单次请求最多 30 个成员,超出请分批调用
|
||||
> - list 命令底层一次性返回全量成员后在内存中按 pageSize 分页,当 `hasMore` 为 true 时,传入 `--next-token` 即可获取下一页
|
||||
|
||||
### 文件互联网公开发布
|
||||
|
||||
管理文件的互联网公开发布状态。公开后任何人通过链接即可访问,无需登录钉钉。操作者需要是文件的管理员或拥有者。
|
||||
|
||||
@@ -120,16 +120,31 @@ Example:
|
||||
dws wiki member add --workspace <WS_ID> --users uid1 --role READER
|
||||
dws wiki member add --workspace <WS_ID> --users uid1,uid2 --role EDITOR
|
||||
dws wiki member add --workspace "https://alidocs.dingtalk.com/i/spaces/<WS_ID>/overview" --users uid1 --role MANAGER
|
||||
dws wiki member add --workspace <WS_ID> --members '[{"type":"USER","id":"uid1","roleId":"READER","corpId":"xxx"},{"type":"DEPT","id":"deptId1","roleId":"EDITOR","corpId":"xxx"}]' --notify
|
||||
dws wiki member add --workspace <WS_ID> --members '[{"type":"CONVERSATION","id":"cidXXX","roleId":"READER"},{"type":"TAG","id":"tagId1","roleId":"EDITOR","corpId":"xxx"}]'
|
||||
Flags:
|
||||
--workspace string 目标知识库 ID 或 URL (必填)
|
||||
--users string 被加入的用户 userId 列表,逗号分隔 (必填,单次最多 30 个)
|
||||
--role string 授予的角色 (必填,大小写不敏感): MANAGER (管理者) / EDITOR (可编辑) / DOWNLOADER (可下载) / READER (可阅读)
|
||||
--users string 被加入的用户 userId 列表,逗号分隔 (旧格式,单次最多 30 个)
|
||||
--role string 授予的角色 (旧格式必填,大小写不敏感): MANAGER (管理者) / EDITOR (可编辑) / DOWNLOADER (可下载) / READER (可阅读)
|
||||
--members string 成员列表 JSON 数组(新格式),支持 USER/DEPT/CONVERSATION/TAG 类型(TAG=角色组),与 --users 互斥
|
||||
--notify bool 是否通知被添加的成员 (仅 --members 新格式时生效,默认 false)
|
||||
```
|
||||
|
||||
> **两种传参方式(互斥)**:
|
||||
> - 旧格式:`--users` 传入逗号分隔的 userId 列表 + `--role` 指定统一角色(仅 USER 类型)
|
||||
> - 新格式:`--members` 传入 JSON 数组,每个成员包含 `type`(USER/DEPT/CONVERSATION/TAG)、`id`、`roleId`(建议全大写),支持混合类型
|
||||
>
|
||||
> **成员类型说明**:
|
||||
> - `USER` — 用户,id 为用户 userId,需携带 `corpId`(标识用户所属组织)
|
||||
> - `DEPT` — 部门,id 为部门 ID,需携带 `corpId`(标识部门所属组织)
|
||||
> - `CONVERSATION` — 群聊,id 为群聊 conversationId(cid 开头),无需 `corpId`
|
||||
> - `TAG` — 角色标签(也称角色组),id 为角色标签 ID,需携带 `corpId`。当用户要求"添加角色组"或"添加角色标签"时使用此类型
|
||||
>
|
||||
> **❗ 重要约束**:
|
||||
> - 仅支持 USER 类型。
|
||||
> - 角色枚举:MANAGER / EDITOR / DOWNLOADER / READER(OWNER 不可通过此接口添加,知识库创建者默认为所有者)。
|
||||
> - 操作者需具备知识库的 OWNER 或 MANAGER 权限。
|
||||
> - 操作者须满足该知识库配置的权限管理最低角色要求(默认 MANAGER,可配置为 EDITOR 等),权限不足返回 `forbidden.accessDenied`。
|
||||
> - 单次请求最多 30 个成员,超出请分批调用。
|
||||
> - `--notify` 仅在新格式(`--members`)时生效,仅对 USER 和 CONVERSATION 类型成员发送通知(DEPT 和 TAG 不通知),默认 false;省略时不会向服务端发送该字段,需要通知请显式传 `--notify`。旧格式始终通知。
|
||||
> - 「我的文档」(myWikiSpace) 是个人空间,**不支持容器级成员管理**;后端会直接拒绝。如果你的目标只是把某篇文档分享给别人,请改用 `dws drive permission add` 在节点级别授权。
|
||||
|
||||
### 移除知识库成员
|
||||
@@ -139,15 +154,28 @@ Usage:
|
||||
Example:
|
||||
dws wiki member remove --workspace <WS_ID> --users uid1
|
||||
dws wiki member remove --workspace <WS_ID> --users uid1,uid2
|
||||
dws wiki member remove --workspace <WS_ID> --members '[{"type":"USER","id":"uid1","corpId":"xxx"},{"type":"DEPT","id":"deptId1","corpId":"xxx"}]'
|
||||
Flags:
|
||||
--workspace string 目标知识库 ID 或 URL (必填)
|
||||
--users string 被移除的用户 userId 列表,逗号分隔 (必填,单次最多 30 个)
|
||||
--users string 被移除的用户 userId 列表,逗号分隔 (旧格式,单次最多 30 个)
|
||||
--members string 成员列表 JSON 数组(新格式),只需 type 和 id(USER/DEPT/TAG 还需 corpId),与 --users 互斥
|
||||
```
|
||||
|
||||
> **两种传参方式(互斥)**:
|
||||
> - 旧格式:`--users` 传入逗号分隔的 userId 列表(仅 USER 类型)
|
||||
> - 新格式:`--members` 传入 JSON 数组,只需 `type` 和 `id`(USER/DEPT/TAG 还需 `corpId`),支持 USER/DEPT/CONVERSATION/TAG
|
||||
>
|
||||
> **成员类型说明**:
|
||||
> - `USER` — 用户,id 为用户 userId,需携带 `corpId`
|
||||
> - `DEPT` — 部门,id 为部门 ID,需携带 `corpId`
|
||||
> - `CONVERSATION` — 群聊,id 为群聊 conversationId(cid 开头),无需 `corpId`
|
||||
> - `TAG` — 角色标签(也称角色组),id 为角色标签 ID,需携带 `corpId`
|
||||
>
|
||||
> **重要约束**:
|
||||
> - OWNER 角色不可通过此接口移除。
|
||||
> - 操作者需具备知识库的 OWNER 或 MANAGER 权限。
|
||||
> - 移除后相关用户将无法访问该知识库下的内容(除非通过节点级权限另行授权)。
|
||||
> - 操作者须满足该知识库配置的权限管理最低角色要求(默认 MANAGER,可配置为 EDITOR 等),权限不足返回 `forbidden.accessDenied`。
|
||||
> - 单次请求最多 30 个成员,超出请分批调用。
|
||||
> - 移除后相关成员将无法访问该知识库下的内容(除非通过节点级权限另行授权)。
|
||||
> - 「我的文档」(myWikiSpace) 是个人空间,**不支持容器级成员管理**。
|
||||
|
||||
### 修改知识库成员角色
|
||||
@@ -157,27 +185,50 @@ Usage:
|
||||
Example:
|
||||
dws wiki member update --workspace <WS_ID> --users uid1 --role EDITOR
|
||||
dws wiki member update --workspace <WS_ID> --users uid1,uid2 --role READER
|
||||
dws wiki member update --workspace <WS_ID> --members '[{"type":"USER","id":"uid1","roleId":"EDITOR","corpId":"xxx"}]' --notify=false
|
||||
dws wiki member update --workspace <WS_ID> --members '[{"type":"TAG","id":"tagId1","roleId":"READER","corpId":"xxx"}]'
|
||||
Flags:
|
||||
--workspace string 目标知识库 ID 或 URL (必填)
|
||||
--users string 目标用户 userId 列表,逗号分隔 (必填,单次最多 30 个)
|
||||
--role string 新角色 (必填,大小写不敏感): MANAGER / EDITOR / DOWNLOADER / READER
|
||||
--users string 目标用户 userId 列表,逗号分隔 (旧格式,单次最多 30 个)
|
||||
--role string 新角色 (旧格式必填,大小写不敏感): MANAGER / EDITOR / DOWNLOADER / READER
|
||||
--members string 成员列表 JSON 数组(新格式),支持 USER/DEPT/CONVERSATION/TAG 类型(TAG=角色组),与 --users 互斥
|
||||
--notify bool 是否通知被变更的成员 (仅 --members 新格式时生效,默认 false)
|
||||
```
|
||||
|
||||
> **两种传参方式(互斥)**:
|
||||
> - 旧格式:`--users` 传入逗号分隔的 userId 列表 + `--role` 指定统一角色(仅 USER 类型)
|
||||
> - 新格式:`--members` 传入 JSON 数组,每个成员携带独立 `roleId`
|
||||
>
|
||||
> **成员类型说明**:
|
||||
> - `USER` — 用户,id 为用户 userId,需携带 `corpId`
|
||||
> - `DEPT` — 部门,id 为部门 ID,需携带 `corpId`
|
||||
> - `CONVERSATION` — 群聊,id 为群聊 conversationId(cid 开头),无需 `corpId`
|
||||
> - `TAG` — 角色标签(也称角色组),id 为角色标签 ID,需携带 `corpId`
|
||||
>
|
||||
> **重要约束**:
|
||||
> - OWNER 角色不可通过此接口变更。
|
||||
> - 操作者须满足该知识库配置的权限管理最低角色要求(默认 MANAGER,可配置为 EDITOR 等),权限不足返回 `forbidden.accessDenied`。
|
||||
> - 单次请求最多 30 个成员,超出请分批调用。
|
||||
> - `--notify` 仅在新格式(`--members`)时生效,仅对 USER 和 CONVERSATION 类型成员发送通知(DEPT 和 TAG 不通知),默认 false。
|
||||
|
||||
### 列出知识库成员
|
||||
```
|
||||
Usage:
|
||||
dws wiki member list [flags]
|
||||
Example:
|
||||
dws wiki member list --workspace <WS_ID>
|
||||
dws wiki member list --workspace <WS_ID> --limit 100
|
||||
dws wiki member list --workspace <WS_ID> --limit 50
|
||||
dws wiki member list --workspace <WS_ID> --filter-role EDITOR
|
||||
dws wiki member list --workspace <WS_ID> --next-token <上次返回的 nextToken>
|
||||
Flags:
|
||||
--workspace string 目标知识库 ID 或 URL (必填)
|
||||
--limit int 返回成员数上限,默认 30,最大 200
|
||||
--limit int 返回成员数上限,默认 30,最大 50
|
||||
--filter-role string 按角色过滤,逗号分隔: OWNER / MANAGER / EDITOR / DOWNLOADER / READER (选填)
|
||||
--next-token string 分页游标,首次不传,后续传入上一次返回的 nextToken (选填)
|
||||
```
|
||||
|
||||
> 接口不支持游标分页,使用 `--limit` 一次性拉取。
|
||||
> 底层一次性返回全量成员后在内存中按 pageSize 分页;当 `hasMore` 为 true 时,传入 `--next-token` 即可获取下一页。
|
||||
> 操作者须满足该知识库配置的权限管理最低角色要求,权限不足返回 `forbidden.accessDenied`。
|
||||
|
||||
> ⚠️ **返回字段限制**:`member list` 每条只返回 `name` / `role` / `type` 三个字段,**不含 userId**(服务端不返回)。因此**无法**从 `member list` 拿到 userId 再去串联 `member update` / `member remove`。要对某人改角色 / 移除,需另行拿到其 userId(例如用 `dws contact user search --query "<姓名>"` 按姓名反查)。
|
||||
|
||||
@@ -338,7 +389,8 @@ Flags:
|
||||
- 用户说"复制知识库里的文档" → `node copy`(需 `--workspace` + `--node`)
|
||||
- 用户说"移动知识库里的文档" → `node move`(需 `--workspace` + `--node`)
|
||||
- 用户说"删除知识库里的文档/节点" → `node delete`(需 `--workspace` + `--node`)
|
||||
- 用户说"把知识库分享给某人/给某人加入知识库/邀请进知识库" → `member add`(需 `--workspace` + `--users` + `--role`)
|
||||
- 用户说"把知识库分享给某人/给某人加入知识库/邀请进知识库" → `member add`(需 `--workspace` + `--users` + `--role`,或 `--workspace` + `--members`)
|
||||
- 用户说"加成员并通知他/邀请后告知对方" → `member add --members ... --notify`;用户说"加进去但不要通知/悄悄加" → `member add --members ... --notify=false`
|
||||
- 用户说"修改某人在知识库的权限/调整成员角色" → `member update`
|
||||
- 用户说"移除知识库成员/把某人从知识库移除/删除知识库成员" → `member remove`(需 `--workspace` + `--users`)
|
||||
- 用户说"知识库有哪些成员/查看知识库成员" → `member list`
|
||||
@@ -350,6 +402,12 @@ Flags:
|
||||
> **重要 — `--exclude-file` 使用规则**:
|
||||
> 当用户意图排除文件类动态(上传文件、更新 office 文件等)或只看文档操作时,**必须在命令中带上 `--exclude-file`**,由后端完成过滤。禁止先拉全量数据再用 Python/jq 等工具在客户端过滤,那样既浪费带宽又可能因分页遗漏数据。
|
||||
|
||||
> **通知意图 → `--notify`**(`--members` 新格式默认不通知,省略时 CLI 不向服务端发送该字段):
|
||||
> - 用户明确要求“通知 / 告知 / 提醒对方 / 让他知道” → 追加 `--notify`
|
||||
> - 用户明确要求“不要通知 / 别提醒 / 悄悄加 / 不要打扰” → **必须改用 `--members` 并传 `--notify=false`**;旧格式 `--users` 始终通知且无法关闭,此时继续用旧格式会违背用户意图
|
||||
> - 用户没提通知需求 → 走 `--members` 时**不传该 flag**,保持不通知;不要自行补上 `--notify`
|
||||
> - 仅 USER 和 CONVERSATION 类型成员会收到通知;被授权对象是 DEPT / TAG 时通知不会送达,**需主动向用户说明这一点**,不要默不作声
|
||||
|
||||
> **跨产品路由说明**:知识库节点的**内容操作**(读取/编辑/块级操作)仍由 `dws doc` 承担:
|
||||
>- 用户说"读某个知识库里的某篇文档" → 先 `node list` 拿到 nodeId,再走 **`dws doc read --node <nodeId>`**
|
||||
>- 用户说"搜文件"(不指定空间) → 走 **`dws drive search`**(全局聚合搜索)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: dingtalk-aitable
|
||||
description: 钉钉 AI 表格(多维表)。Use when 用户说 AI表格/多维表/数据表/base/table/建表/查记录/写数据/字段/记录增删改查/筛选/排序/公式/模板搜索/批量导入CSV或JSON/导出/仪表盘/图表/上传附件到表格/按字段类型建表。不做电子表格单元格读写(走 dingtalk-misc)、文档编辑(走 dingtalk-doc);听记待办入表先用 dingtalk-minutes 提取,再由本 skill 写入。命令前缀:dws aitable。
|
||||
description: 钉钉 AI 表格(多维表)。Use when 用户说 AI表格/多维表/数据表/base/table/建表/查记录/写数据/字段/记录增删改查/筛选/排序/公式/模板搜索/批量导入CSV或JSON/导出/仪表盘/图表/上传附件到表格/按字段类型建表/数据源/创建数据源/更新数据源配置/触发数据源同步/按任务 ID 查询同步状态/获取数据源配置/列出数据源可用来源/获取数据源可同步字段/审批数据同步。不做电子表格单元格读写(走 dingtalk-misc)、文档编辑(走 dingtalk-doc);听记待办入表先用 dingtalk-minutes 提取,再由本 skill 写入。命令前缀:dws aitable。
|
||||
metadata:
|
||||
cli_version: ">=0.2.14"
|
||||
category: product
|
||||
@@ -28,7 +28,7 @@ metadata:
|
||||
<!-- VISIBLE_SHORTCUTS_START -->
|
||||
## Shortcut 发现(按需)
|
||||
|
||||
`aitable` 当前有 93 条公开 shortcut,完整清单保留在 Runtime Catalog 与 Schema,不在高频产品根 Skill 中重复展开。已知意图直接使用下方的优先路由、意图表或任务 reference;命令已选中时直接执行,只在参数/安全语义不确定时读取 leaf Schema,在当前 Cobra flags 不确定时读取 leaf Help。
|
||||
`aitable` 当前有 100 条公开 shortcut,完整清单保留在 Runtime Catalog 与 Schema,不在高频产品根 Skill 中重复展开。已知意图直接使用下方的优先路由、意图表或任务 reference;命令已选中时直接执行,只在参数/安全语义不确定时读取 leaf Schema,在当前 Cobra flags 不确定时读取 leaf Help。
|
||||
|
||||
仅当现有路由和 reference 都无法定位低频能力时,才执行 `dws shortcut list --service aitable --format json` 做最后回退;不要为已知高频意图加载完整 Shortcut Catalog 或产品级 Schema。
|
||||
<!-- VISIBLE_SHORTCUTS_END -->
|
||||
@@ -60,10 +60,11 @@ metadata:
|
||||
| 调整视图列顺序 | `dws aitable view update visible-fields --base-id <ID> --table-id <ID> --view-id <ID> --field-ids <完整有序IDs>` | 先读取字段和当前完整列数组,固定主字段在首位,写后回读精确校验 |
|
||||
| 创建/修改图表前取配置 | `dws aitable +chart-widgets-example` | 命令返回所有图表类型示例;已有合法 config 时直接 create/update |
|
||||
| Base 内 Section/节点移动 | `dws aitable +section-*` | Table/Dashboard/Section 是 Base 内 nsheet 节点,不是独立 Drive 节点 |
|
||||
| 接入外部数据源(审批等) | `dws aitable +datasource-list-sources --base-id <ID> --datasource-type OA` → 解析 result 构造 sourceConfig → `dws aitable +datasource-create --base-id <ID> --datasource-type OA --source-config '<JSON>'` | 当前仅支持 OA 审批;sourceConfig 中 processCode/name/iconUrl/url 须从 list-sources 原样透传;创建后用 `+datasource-sync-status` 查同步结果 |
|
||||
|
||||
### 常用 leaf 直达
|
||||
|
||||
参数已知时直接执行,不探测 Help/Catalog:Base 查看/改名用 `+base-get` / `+base-update`;模板搜索用 `+template-search`,再把真实 templateId 交给 `base create --template-id`;Table 查看/更新用 `+table-get` / `+table-update`;视图创建/复制用 `view create` / `+view-duplicate`;仪表盘创建/更新/读回用 `dashboard create` / `+dashboard-update` / `+dashboard-get`;表单分享用 `+form-share-update` / `+form-share-get`;查看自动化用 `+workflow-list`。
|
||||
参数已知时直接执行,不探测 Help/Catalog:Base 查看/改名用 `+base-get` / `+base-update`;模板搜索用 `+template-search`,再把真实 templateId 交给 `base create --template-id`;Table 查看/更新用 `+table-get` / `+table-update`;视图创建/复制用 `view create` / `+view-duplicate`;仪表盘创建/更新/读回用 `dashboard create` / `+dashboard-update` / `+dashboard-get`;表单分享用 `+form-share-update` / `+form-share-get`;查看自动化用 `+workflow-list`;数据源查看来源用 `+datasource-list-sources`,获取字段用 `+datasource-get-fields`,创建/更新/同步/查状态/查配置用 `+datasource-create` / `+datasource-update` / `+datasource-sync` / `+datasource-sync-status` / `+datasource-get-config`。
|
||||
|
||||
### 低频入口
|
||||
|
||||
@@ -77,6 +78,7 @@ metadata:
|
||||
- 单产品线性任务直接执行,不创建 TodoWrite;只有跨产品或多个独立分支的长任务才建计划,并且只在阶段切换时更新,不在每条 CLI 后刷新状态。
|
||||
- 用户要求资源名带当前时间戳时只取一次并在 Base、Table、Dashboard 等名称中复用同一值;不要为每个资源分别取时间。
|
||||
- JSON 已返回所需字段时立即复用;不得为寻找同一字段改用 `--verbose`、`raw`、`pretty` 重复请求。
|
||||
- 数据源创建前必须先 `+datasource-list-sources` 获取 processCode 等透传字段,不要凭记忆或猜测构造 sourceConfig。
|
||||
|
||||
## 记录输入与结果
|
||||
|
||||
@@ -91,6 +93,7 @@ metadata:
|
||||
## 安全边界
|
||||
|
||||
- 删除不可逆,按 Runtime confirmation 核对真实目标;`base list` 只是最近访问。字段零/多候选、类型不明时停止;多批写保留已完成批次和续跑位置。
|
||||
- 数据源 `+datasource-create` / `+datasource-update` 会触发真实数据同步(全量),执行前确认目标 Base 和 sourceConfig 无误。`+datasource-sync` 同理,单次最多 5 张表。
|
||||
|
||||
## 按需加载
|
||||
|
||||
@@ -107,6 +110,7 @@ metadata:
|
||||
| Base 内 Section/节点移动或清理 | [section](references/aitable-section.md) |
|
||||
| 图表配置 | [dashboard-chart](references/aitable/aitable-dashboard-chart.md) |
|
||||
| 附件、表单、工作流 | 读取 `references/aitable/` 下对应的一个精确文件 |
|
||||
| 数据源接入、同步管理、sourceConfig 构造、同步审批数据到 AI 表格 | [datasource](references/aitable/aitable-datasource.md) |
|
||||
| 产品边界不明确 | [intent-guide](references/intent-guide.md) |
|
||||
|
||||
通用 `references/aitable.md` 仅保留为兼容索引,不是默认入口;正常 Case 不预读。低频能力按意图选择一个最精确的 Reference,禁止连读。
|
||||
@@ -116,6 +120,7 @@ metadata:
|
||||
1. 零/多候选、字段歧义或分页不完整:停止并返回证据;需要后续页时只透传真实 `nextCursor`。
|
||||
2. 类型错误只复核目标字段,不删字段或丢输入;`partial_success` 从 checkpoint 续跑,未知写入先回读。
|
||||
3. 错误包含 `actions` / `available_flags` 时只执行其中的 `next_command`;同一操作最多做一次有证据的参数修正。`retryable=false` 或目标 ID 类型不符时停止,不把 Drive/Wiki/Space/子节点 ID 轮流代入试错。
|
||||
4. 数据源同步 `errorCode=4014` 为幂等冲突(同步运行中重复触发),标记 FAILED 但可稍后重试;非数据源表(sync=false)触发 sync 会返回参数错误,先用 `+base-get` 确认 sync=true。
|
||||
|
||||
## 跨产品边界
|
||||
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
# datasource — 数据源同步管理
|
||||
|
||||
将外部数据源(当前仅支持 OA 审批)同步到 AI 表格。完整链路:list-sources → (OA) 选择模板 → get-fields → create → sync-status → sync / update / get-config。
|
||||
|
||||
## 命令一览
|
||||
|
||||
| 命令 | 用途 | 读/写 |
|
||||
|------|------|-------|
|
||||
| `+datasource-list-sources` | 列出可用数据源条目,获取 processCode/name/iconUrl/url | 读 |
|
||||
| `+datasource-get-fields` | 获取可同步字段列表,用于决定 field-ids | 读 |
|
||||
| `+datasource-create` | 创建数据源表并触发首次全量同步 | 写 |
|
||||
| `+datasource-update` | 更新已有数据源表的同步配置 | 写 |
|
||||
| `+datasource-sync` | 手动触发一次同步(最多 5 张表) | 写 |
|
||||
| `+datasource-sync-status` | 查询同步任务状态(RUNNING/FINISHED/FAILED) | 读 |
|
||||
| `+datasource-get-config` | 获取数据源表当前同步配置 | 读 |
|
||||
|
||||
## 典型工作流
|
||||
|
||||
```
|
||||
Step 0 列出可用来源 +datasource-list-sources --base-id <B> --datasource-type OA
|
||||
→ 返回 approvals 数组(通常含多个审批模板)
|
||||
|
||||
Step 0.5 (OA 审批) 选择模板 list-sources 返回的 approvals 数组通常含多个审批模板,需确定目标:
|
||||
- 用户已指定名称(如"采购申请")→ 按 name 精确/模糊匹配
|
||||
· 唯一命中 → 提取该条的 processCode/name/iconUrl/url,继续
|
||||
· 多候选 → 列出匹配项让用户消歧
|
||||
· 零命中 → 停止,提示用户确认名称或从完整列表选择
|
||||
- 用户未指定 → 列出候选模板清单(name + processCode),等用户选择
|
||||
- 禁止:未匹配直接选第一项、或凭记忆猜 processCode
|
||||
注:此步骤针对 OA 审批数据源(多模板场景);其他数据源类型的
|
||||
list-sources 返回结构可能不同,按实际 result 解析即可,
|
||||
不一定需要选择步骤。
|
||||
|
||||
Step 1 (可选) 获取可同步字段 +datasource-get-fields --base-id <B> --datasource-type OA --source-config '<JSON>'
|
||||
→ 决定需要同步哪些字段,得到 field-ids
|
||||
|
||||
Step 2 创建数据源 +datasource-create --base-id <B> --datasource-type OA --source-config '<JSON>'
|
||||
→ sourceConfig 中的 processCode/name/iconUrl/url 来自 Step 0.5 选中的模板
|
||||
→ 返回 tableId + taskId
|
||||
|
||||
Step 3 查询同步结果 +datasource-sync-status --base-id <B> --table-id <T> --task-ids <TASK_ID>
|
||||
→ FINISHED=完成,FAILED=看 errorCode 排查,RUNNING=轮询
|
||||
|
||||
Step 4 (后续) 手动触发同步 +datasource-sync --base-id <B> --table-ids <T1>,<T2>
|
||||
→ 返回新 taskId,再用 sync-status 查结果
|
||||
|
||||
Step 5 (后续) 更新配置 +datasource-update --base-id <B> --table-id <T> --source-config '<JSON>' --auto
|
||||
→ 更新后自动触发一次同步
|
||||
|
||||
查看当前配置 +datasource-get-config --base-id <B> --table-id <T>
|
||||
```
|
||||
|
||||
## sourceConfig 字段协议
|
||||
|
||||
以下字段协议仅适用于 OA 审批数据源(datasourceType=OA),其他数据源类型待后续开放。
|
||||
|
||||
### 须从 list-sources 原样透传(必填)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| processCode | String | OA 审批流程编码 |
|
||||
| name | String | 展示名称 |
|
||||
| iconUrl | String | 图标 URL |
|
||||
| url | String | 跳转链接 |
|
||||
|
||||
### 调用方自行设置
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| dataType | String | 是 | 数据范围类型:`time_range` / `start_time` / `recent_time` |
|
||||
| recentDays | String | 条件 | dataType=recent_time 时有效,取值 `7d`/`30d`/`1y`,默认 `30d` |
|
||||
| startDate | String | 条件 | dataType=time_range 或 start_time 时有效,`yyyy-MM-dd`,默认 30 天前 |
|
||||
| endDate | String | 条件 | dataType=time_range 时有效,`yyyy-MM-dd`,默认当天 |
|
||||
| keepRemovedFields | Boolean | 否 | 是否保留已删除字段,默认 false |
|
||||
| splitParentTableField | Boolean | 否 | 是否拆分父表字段 |
|
||||
|
||||
> list-sources 返回的 keepRemovedFields / splitParentTableField / enableDataSyncOaDetailList 不要透传,由调用方按需设置。
|
||||
|
||||
### 三种 dataType 最小示例
|
||||
|
||||
```json
|
||||
// recent_time — 最近一段时间
|
||||
{"processCode":"PROC-xxxx","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"...","url":"..."}
|
||||
|
||||
// time_range — 指定起止日期
|
||||
{"processCode":"PROC-xxxx","name":"采购申请","dataType":"time_range","startDate":"2025-01-01","endDate":"2025-12-31","iconUrl":"...","url":"..."}
|
||||
|
||||
// start_time — 从某日期至今
|
||||
{"processCode":"PROC-xxxx","name":"采购申请","dataType":"start_time","startDate":"2025-06-01","iconUrl":"...","url":"..."}
|
||||
```
|
||||
|
||||
## autoSyncSetting 频率配置
|
||||
|
||||
仅在 `--auto=true` 时生效。不传时使用下游默认自动同步策略。
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| syncType | 是 | `hourly`(按小时间隔)/ `scheduled`(定时触发) |
|
||||
| hourlyInterval | hourly 时 | 正整数,小时间隔 |
|
||||
| scheduleType | scheduled 时 | `daily` / `weekly` / `monthly` |
|
||||
| timeValue | scheduled 时 | `HH:mm` 触发时间 |
|
||||
| selectedMonthDays | monthly 时必填 | 每月几号触发,1-31 |
|
||||
| selectedWeekdays | weekly 时必填 | 每周哪几天触发,1=周一…7=周日 |
|
||||
| skipNonWorkingDay | 否 | 是否跳过非工作日,默认 false |
|
||||
|
||||
示例:`{"syncType":"scheduled","scheduleType":"daily","timeValue":"09:00"}`
|
||||
|
||||
## 命令详情
|
||||
|
||||
### +datasource-list-sources — 列出可用数据源条目
|
||||
|
||||
```bash
|
||||
dws aitable +datasource-list-sources --base-id BASE_ID --datasource-type OA --format json
|
||||
```
|
||||
|
||||
| flag | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--base-id` | 是 | 目标 Base ID |
|
||||
| `--datasource-type` | 是 | 数据源类型,当前仅支持 `OA` |
|
||||
|
||||
返回每条条目包含 `result`(下游原始 JSON 字符串)和 `sourceType`(OA 审批对应 2)。OA 审批场景下 result 为 approvals 数组:
|
||||
|
||||
```json
|
||||
{
|
||||
"approvals": [
|
||||
{
|
||||
"processCode": "PROC-xxxx",
|
||||
"name": "采购申请",
|
||||
"iconUrl": "https://...",
|
||||
"url": "https://...",
|
||||
"keepRemovedFields": false,
|
||||
"splitParentTableField": false,
|
||||
"enableDataSyncOaDetailList": false
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
调用方应自行解析 result,提取目标模板字段后构造 sourceConfig。
|
||||
|
||||
### +datasource-get-fields — 获取可同步字段列表
|
||||
|
||||
```bash
|
||||
dws aitable +datasource-get-fields --base-id BASE_ID --datasource-type OA \
|
||||
--source-config '{"processCode":"PROC-xxxx","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"...","url":"..."}' \
|
||||
--format json
|
||||
```
|
||||
|
||||
| flag | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--base-id` | 是 | 目标 Base ID |
|
||||
| `--datasource-type` | 是 | 数据源类型,当前仅支持 `OA` |
|
||||
| `--source-config` | 是 | 源配置 JSON 字符串,结构同 create 的 --source-config |
|
||||
|
||||
返回字段列表(字段 ID、名称、类型等),用于在 create/update 中指定 `--field-ids`。
|
||||
|
||||
### +datasource-create — 创建数据源表
|
||||
|
||||
```bash
|
||||
dws aitable +datasource-create --base-id BASE_ID --datasource-type OA \
|
||||
--source-config '{"processCode":"PROC-xxxx","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"...","url":"..."}' \
|
||||
--format json
|
||||
|
||||
# 开启自动同步 + 自定义频率
|
||||
dws aitable +datasource-create --base-id BASE_ID --datasource-type OA \
|
||||
--source-config '...' --auto \
|
||||
--auto-sync-setting '{"syncType":"scheduled","scheduleType":"daily","timeValue":"09:00"}' \
|
||||
--format json
|
||||
```
|
||||
|
||||
| flag | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--base-id` | 是 | 目标 Base ID |
|
||||
| `--datasource-type` | 是 | 数据源类型,当前仅支持 `OA` |
|
||||
| `--source-config` | 是 | 源配置 JSON 字符串(见上方字段协议) |
|
||||
| `--auto` | 否 | 是否开启自动同步,默认 false;无论是否传入,CLI 都会把该字段下发给下游 |
|
||||
| `--auto-sync-setting` | 否 | 自动同步频率配置 JSON 字符串,仅 --auto=true 时生效 |
|
||||
| `--field-ids` | 否 | 需要同步的字段 ID 列表,不传时同步全部字段 |
|
||||
|
||||
返回新建数据源表 tableId 和同步任务 taskId。创建后自动触发一次全量同步,需用 `+datasource-sync-status` 查最终结果。
|
||||
|
||||
### +datasource-update — 更新数据源配置
|
||||
|
||||
```bash
|
||||
# 仅开启自动同步
|
||||
dws aitable +datasource-update --base-id BASE_ID --table-id TABLE_ID --auto --format json
|
||||
|
||||
# 更新源配置
|
||||
dws aitable +datasource-update --base-id BASE_ID --table-id TABLE_ID \
|
||||
--source-config '{"processCode":"PROC-yyyy","name":"出差申请","dataType":"recent_time","recentDays":"30d","iconUrl":"...","url":"..."}' \
|
||||
--format json
|
||||
```
|
||||
|
||||
| flag | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--base-id` | 是 | 目标 Base ID |
|
||||
| `--table-id` | 是 | 已有数据源表 ID(sync=true) |
|
||||
| `--source-config` | 否 | 新的源配置 JSON 字符串,不传时保持原配置;传入时整体覆盖 |
|
||||
| `--auto` | 否 | 是否开启自动同步,不传时保持原设置 |
|
||||
| `--auto-sync-setting` | 否 | 自动同步频率配置 JSON 字符串,仅 --auto=true 时生效;不传时保持原频率配置 |
|
||||
| `--field-ids` | 否 | 需要同步的字段 ID 列表,不传时保持现有字段配置 |
|
||||
|
||||
更新后自动触发一次全量同步,返回新 taskId。
|
||||
|
||||
### +datasource-sync — 手动触发同步
|
||||
|
||||
```bash
|
||||
dws aitable +datasource-sync --base-id BASE_ID --table-ids TBL1,TBL2 --format json
|
||||
```
|
||||
|
||||
| flag | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--base-id` | 是 | 目标 Base ID |
|
||||
| `--table-ids` | 是 | 待同步的数据源表 ID 列表(sync=true),1-5 个 |
|
||||
|
||||
返回结果包含文档链接,可打开查看同步进度。每张表独立提交,部分失败不影响其他表。
|
||||
|
||||
### +datasource-sync-status — 按任务 ID 查询同步状态
|
||||
|
||||
```bash
|
||||
dws aitable +datasource-sync-status --base-id BASE_ID --table-id TABLE_ID --task-ids TASK1,TASK2 --format json
|
||||
```
|
||||
|
||||
| flag | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--base-id` | 是 | 目标 Base ID |
|
||||
| `--table-id` | 是 | 数据源表 ID(sync=true) |
|
||||
| `--task-ids` | 是 | 同步任务 ID 列表(由 create/update/sync 返回),1-5 个 |
|
||||
|
||||
任务状态:`RUNNING`(进行中)、`FINISHED`(完成)、`FAILED`(失败,含 errorCode + errorMessage)。
|
||||
|
||||
### +datasource-get-config — 获取数据源配置
|
||||
|
||||
```bash
|
||||
dws aitable +datasource-get-config --base-id BASE_ID --table-id TABLE_ID --format json
|
||||
```
|
||||
|
||||
| flag | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--base-id` | 是 | 目标 Base ID |
|
||||
| `--table-id` | 是 | 数据源表 ID(sync=true) |
|
||||
|
||||
返回当前同步配置详情(sourceConfig、是否自动同步、同步状态等)。仅适用于数据源表,普通表会报错。
|
||||
|
||||
## 错误码与排查
|
||||
|
||||
| 场景 | 表现 | 排查 |
|
||||
|------|------|------|
|
||||
| 同步运行中重复触发 | errorCode=4014,status=FAILED | 幂等冲突,稍后重试即可 |
|
||||
| 非数据源表触发 sync | 参数错误返回 | 确认 table 的 sync=true,用 `+base-get` / `+table-list` 检查 |
|
||||
| sourceConfig 缺必填字段 | 创建/更新失败 | 检查 processCode/name/iconUrl/url 是否从 list-sources 原样透传 |
|
||||
| dataType 与时间字段不匹配 | 创建失败 | recent_time 需 recentDays;time_range 需 startDate+endDate;start_time 需 startDate |
|
||||
|
||||
## 能力边界
|
||||
|
||||
| 能力 | 状态 |
|
||||
|------|------|
|
||||
| OA 审批数据源 | 已支持 |
|
||||
| 其他数据源类型 | 待后续开放 |
|
||||
| 全量同步 | 已支持 |
|
||||
| 增量同步 | 待后续开放 |
|
||||
| 自动同步 | 已支持(--auto + autoSyncSetting) |
|
||||
| 删除数据源表 | 走普通表删除,不走 datasource 命令 |
|
||||
|
||||
## 注意事项
|
||||
|
||||
- sourceConfig 是 **JSON 字符串**(不是 JSON 对象),CLI flag 传入时需要用单引号包裹
|
||||
- list-sources 返回的 keepRemovedFields / splitParentTableField / enableDataSyncOaDetailList 不要透传,由调用方按需设置
|
||||
- create/update 后自动触发一次同步,返回 taskId;用 sync-status 查最终结果
|
||||
- sync 单次最多 5 张表,超出拆分多次调用
|
||||
- sync-status 单次最多 5 个 taskId,超出拆分多次调用
|
||||
- get-config 仅适用于数据源表(sync=true),普通表会报错
|
||||
@@ -4,6 +4,16 @@
|
||||
|
||||
`dws pat` 管理 Agent 的行为授权。它不管理开放平台应用权限;应用权限使用 `dws dev app permission`(见 [devapp.md](./devapp.md))。
|
||||
|
||||
<!-- VISIBLE_SHORTCUTS_START -->
|
||||
## Shortcuts(无专用脚本/recipe 时优先)
|
||||
|
||||
以下 shortcut 同时进入公开 catalog 与 Runtime Schema。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。命令已选中时直接执行;只在参数或安全语义不确定时读取 Agent leaf Schema(例如 `dws schema --cli-path "pat +<shortcut>" --compact --format json`),在当前 Cobra flags 不确定时读取 `dws pat <shortcut> --help`。只有参数映射、接口绑定或 provenance 审计才省略 `--compact`。仅当现有路由和 reference 都无法定位低频能力时,才用 `dws shortcut list --service pat --format json` 批量发现。
|
||||
|
||||
| Shortcut | 风险 | 适用场景 |
|
||||
|---|---|---|
|
||||
| `dws pat +browser-policy` | write | 安全配置 PAT 授权时是否允许打开本地浏览器 |
|
||||
<!-- VISIBLE_SHORTCUTS_END -->
|
||||
|
||||
## 命令总览
|
||||
|
||||
### 配置浏览器策略
|
||||
|
||||
@@ -18,7 +18,12 @@ dws wiki +member-remove --workspace <workspaceId> --users <userId> --format json
|
||||
|
||||
## 列表完整性
|
||||
|
||||
`+member-list` 的服务端单次上限为 50,且没有可续游标。结果能证明“本次返回的成员”,不能据此断言大型知识库的完整成员全集。不要伪造 `--page-all` 或不断提高 limit。
|
||||
成员列表服务端单次上限为 50(`--limit` / pageSize 最大 50)。快捷命令 `+member-list` 适合单页列表,但**不支持游标翻页**;超过 50 人必须改用原生 `dws wiki member list` 的 `--next-token` 翻页:首次调用不传,后续把上一次响应中的 `nextToken` 传入 `--next-token`,直到 `hasMore` 为 false。出参 `totalCount` 为全量成员总数,可用于核对是否拉全。不要伪造 `--page-all` 或不断提高 limit 超过 50。
|
||||
|
||||
```bash
|
||||
dws wiki member list --workspace <workspaceId> --limit 50 --format json
|
||||
dws wiki member list --workspace <workspaceId> --next-token <上次返回的 nextToken> --format json
|
||||
```
|
||||
|
||||
## 写入验证
|
||||
|
||||
|
||||
Reference in New Issue
Block a user