Compare commits

...
Author SHA1 Message Date
玉澜 8802dcf4dc fix(corecmd): support strongly-typed DTOs in event/auto wait modes
waitResource previously asserted result.Data() to map[string]any, which
caused event mode and auto mode to fail with 'data is not an object'
when commands returned strongly-typed DTOs (struct or struct pointer).

Now normalizes via JSON round-trip for non-map types, preserving the
fast path for map[string]any. Added regression tests covering struct
values, struct pointers, nested dotted queries, and error cases.
2026-08-19 15:14:22 +08:00
玉澜 28bb377e66 fix: remove stale allowlist entry for drive_tree_list.py
The file only exists in skills/mono/scripts/, not in skills/multi/,
so the allowlist entry was causing TestMonoMultiSkillContentG4Drift
to fail. The script is already referenced in both mono and multi
documentation, so no allowlist entry is needed.
2026-08-19 15:02:54 +08:00
玉澜 a0fe8d70c2 Merge remote-tracking branch 'upstream/main' into feat/wait-framework 2026-08-19 14:29:57 +08:00
github-actions[bot] 13d0ae66a6 Merge pull request #1044 from DingTalk-Real-AI/fix/param-hallucination
feat(calendar): expand reviewed parameter alias coverage
2026-08-19 14:27:17 +08:00
玉澜 4770e5a8e6 Merge remote-tracking branch 'upstream/main' into feat/wait-framework 2026-08-19 14:22:29 +08:00
玉澜 0bcc2f27c6 fix(corecmd): sync operation.state on terminal wait close and canonicalize wait statuses
Terminal success/failure closes kept the acceptance-phase operation state,
emitting self-contradicting envelopes (outcome=success with state=processing).
WithOperationTerminalState now closes the envelope at the observed terminal
status and clears timed_out.

WaitSpec.Validate trimmed status values only for checking and never wrote them
back, so padded declarations passed validation, published a padded Schema, and
then fail-closed at runtime as unknown statuses. NormalizeWaitSpec is now the
single canonical form shared by declaration (corecmd.New / AttachContract),
ToolSpec, and validation: trimmed values, duplicate/conflict rejection,
defensive copy.

Also covers the waitTimeoutDuration secs<=0 branch flagged by the coverage
gate.
2026-08-19 14:17:11 +08:00
克谨 f3b0fcdc4c test(calendar): verify aliases preserve confirmation 2026-08-19 13:53:59 +08:00
克谨 f10d552fd7 Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-08-19 12:28:10 +08:00
github-actions[bot] 8b8756b00e Merge pull request #999 from wxianfeng/feat/oa-approval-instance-cc
feat(event): support OA approval CC events
2026-08-19 04:23:05 +00:00
克谨 ec59cf8065 test(calendar): run alias payloads in platform gate 2026-08-19 12:14:05 +08:00
炳昱 2ffddbd5a0 feat(event): support OA approval CC events 2026-08-19 12:08:35 +08:00
john 0b3abfad4b Merge branch 'main' into feat/wait-framework 2026-08-19 11:21:16 +08:00
克谨 1d3c56f9fa Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-08-19 10:51:30 +08:00
克谨 502317db68 test(calendar): cover suggestion time aliases 2026-08-19 10:47:50 +08:00
github-actions[bot] 66516755e6 chore: update beta formula for v1.0.59-beta.3 [skip ci] 2026-08-19 02:39:35 +00:00
克谨 6e3f528f48 Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-08-19 10:09:47 +08:00
克谨 d70e6b85b6 test(calendar): isolate exhaustive alias payload coverage 2026-08-19 10:09:37 +08:00
赤川 5e71a4ea52 Merge pull request #1048 from DingTalk-Real-AI/codex/changelog-v1.0.59-beta.3
docs: seal changelog for v1.0.59-beta.3
2026-08-19 10:01:03 +08:00
chichuan 0793238d47 docs: seal changelog for v1.0.59-beta.3 2026-08-19 09:58:00 +08:00
克谨 c8f83533fb Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-08-19 09:21:44 +08:00
github-actions[bot] 08e80bcb89 Merge pull request #1038 from pengzhihan47-star/codex/aitable_opt_pr
feat(aitable): streamline agent routes and table setup
2026-08-18 23:18:05 +08:00
柏智 39d9a65616 test(aitable): cover platform recovery behavior 2026-08-18 23:03:05 +08:00
柏智 a325ca80d8 fix(aitable): harden recovery and retry cancellation 2026-08-18 22:43:42 +08:00
克谨 75f08da197 feat(calendar): expand parameter alias normalization 2026-08-18 22:10:23 +08:00
柏智 b246b7d83b Merge remote-tracking branch 'upstream/main' into codex/aitable_opt_pr 2026-08-18 21:07:09 +08:00
柏智 f4cb8aa282 fix(aitable): harden agent routes and composite contracts 2026-08-18 20:59:39 +08:00
github-actions[bot] c15480c452 Merge pull request #1039 from pengzhihan47-star/codex/pr1035-drive-tree-orphan-fix
fix(skills): remove obsolete drive tree helper
2026-08-18 12:48:27 +00:00
pengzhihan47-star c0b013afa9 Merge branch 'main' into codex/pr1035-drive-tree-orphan-fix 2026-08-18 20:31:10 +08:00
github-actions[bot] 34d33e0492 Merge pull request #1036 from DingTalk-Real-AI/codex/remove-calendar-todo-review-html
docs: remove Calendar/Todo shortcut review HTML
2026-08-18 12:26:50 +00:00
柏智 ede8e3c555 fix(skills): remove stale drive orphan allowlist 2026-08-18 20:04:59 +08:00
pengzhihan47-star 7a1b85ab62 Merge branch 'main' into codex/aitable_opt_pr 2026-08-18 19:28:19 +08:00
pengzhihan47-star 490818dfe9 Merge branch 'main' into codex/pr1035-drive-tree-orphan-fix 2026-08-18 19:27:53 +08:00
pengzhihan47-star b3991d473e Merge branch 'main' into codex/pr1035-drive-tree-orphan-fix 2026-08-18 19:21:28 +08:00
pengzhihan47-star c8490da527 Merge branch 'main' into codex/aitable_opt_pr 2026-08-18 18:50:32 +08:00
柏智 176a556355 fix(aitable): verify declared field structures 2026-08-18 17:12:15 +08:00
玉澜 e625da4c27 fix(corecmd): reject overflowing --wait-timeout seconds
int(secs)*time.Second can wrap a pflag-legal MaxInt64 into a non-positive
duration, which skipped the wait deadline and waited forever. Convert with
an overflow check and return a validation error instead.
2026-08-18 16:59:28 +08:00
玉澜 c68603fea0 fix(corecmd): wait only on pending and honor wait-timeout on leaf I/O
Only a pending ResultInvoke envelope enters the wait phase, so success,
failure, and partial results are returned unchanged. WaitPoll/WaitEvents
now receive the --wait-timeout deadline (and Command().Context() is bound
to the same loop context) so a blocked poll or subscribe cannot hang past
the declared timeout.

Also allowlist the leftover multi drive_tree_list.py orphan that broke CI
after merging main.
2026-08-18 16:32:08 +08:00
柏智 f57d9a51f4 fix(aitable): secure recovery commands 2026-08-18 15:59:59 +08:00
john f118a369b0 Merge branch 'main' into feat/wait-framework 2026-08-18 15:34:12 +08:00
柏智 9dc7f64b87 test(aitable): cover table bootstrap confirmation 2026-08-18 15:18:29 +08:00
柏智 5aaf22782c fix(skills): remove obsolete drive tree helper 2026-08-18 15:04:22 +08:00
柏智 089c5491ec feat(aitable): streamline agent routes and table setup 2026-08-18 14:41:37 +08:00
玉澜 b7aa6bddf5 feat(corecmd): implement event and auto wait modes
Completes the wait capability per review guidance ("add corresponding
execution hooks and fallback tests for the modes"):

- contract.WaitSpec restores event/auto modes with event_key,
  match_field, and a new resource_query (dotted path into the accepted
  result data yielding the identifier events correlate against);
  per-mode validation of required fields
- internal/wait adds EventStream (leaf-owned transport) and RunEvent:
  correlated-event filtering, the same terminal/pending/unknown mapping
  as polling, timed-out pending on deadline during consumption, and an
  ErrEventStreamEnded sentinel distinguishing stream termination from
  fail-closed status errors
- Spec.WaitEvents hook; validateWaitDecl pairs mode with hooks
  (poll<->WaitPoll, event<->WaitEvents, auto<->both; surplus hooks
  rejected too)
- the wait phase runs event-first in auto mode and falls back to polling
  when the stream ends or the subscription fails, under one deadline
  spanning both phases; strict event mode surfaces stream errors
- output.CommandResult gains Data() (deep copy) so the framework can
  resolve the resource identifier without exposing mutable state

Changed-code coverage re-verified at 100% (CI cross-package recipe).
2026-08-15 19:29:22 +08:00
玉澜 64ad5f22b0 test(cli): cover Wait capability projection (validate/normalize/payload) 2026-08-15 18:53:48 +08:00
玉澜 c68207ad4b fix(corecmd): CR feedback — poll-only wait, deadline-safe loop, ResultInvoke pairing
Addresses the three P1 findings from review 4942891040:

1. event/auto modes were declared but always executed polls. WaitSpec now
   accepts poll only (event/auto fail validation with a not-implemented
   message); event_key/match_field dead fields removed. Event waiting will
   land with its own execution path and mode constant.
2. a deadline reached during the between-poll sleep re-polled with a
   cancelled context, so a context-aware poller surfaced its error as a poll
   failure instead of the contracted timed-out pending. The wait between
   polls now uses a timer + select on ctx.Done(), the deadline is checked
   before each poll, and a poll error on a cancelled context closes as
   timed-out pending with the last observed status.
3. legacy Invoke/Orchestrate/RunE commands declaring Wait observed a failure
   terminal while still exiting 0. validateWaitDecl now requires the
   ResultInvoke dispatcher (the only path whose unified envelope can be
   closed); the wait phase no longer wraps legacy paths.

Also: dropped the unreachable nonPendingTerminal branch, simplified
waitTimeoutSecs to the flag value (registration always seeds the reviewed
default), and raised changed-code coverage to 100% (new wait-engine edge
tests, output With* unit tests, contractfinal deep-copy coverage,
AttachContract invalid-Wait panic path).
2026-08-15 18:16:24 +08:00
玉澜 4f57967c56 Merge remote-tracking branch 'upstream/main' into feat/wait-framework
# Conflicts:
#	internal/corecmd/corecmd.go
2026-08-15 17:59:10 +08:00
玉澜 b49bc0ed14 feat(corecmd): add declarative Wait capability (Contract.Wait + wait phase)
Framework-only: adds the reviewed wait contract mirroring the DryRunSpec
pattern (types declaration -> ContractDecl -> ContractFinal -> ToolSpec ->
Schema wait key). No business command declares it yet.

- contract.WaitSpec (mode poll/event/auto, poll_command, status_query,
  terminal status->success/failure map, pending_values, event_key,
  match_field, default_timeout_secs) with closed-set Validate
- Spec.WaitPoll hook pairs with the declaration at construction time
  (declared without hook / hook without declaration both panic)
- declared leaves register --wait / --wait-timeout natively (never
  FlagSpec, so they cannot enter MCP toolArgs); undeclared leaves reject
  the flags as unknown instead of ignoring them
- internal/wait engine: immediate-first-poll, x1.5 backoff capped 30s,
  dotted status extraction, fail-closed on unknown status, timeout ->
  pending
- ResultInvoke path closes the unified envelope: success terminal ->
  success, failure terminal -> failure with new wire-stable
  error.type "wait" (exit code 8, additive like partial=7), timeout ->
  pending + meta.operation.timed_out with last observed state (exit 0)
- output.WithOutcome / WithErrorInfo / WithOperationTimedOut preserve
  envelope invariants (I2/I3, pending requires meta.operation)

Verified: go test ./... green; check-generated-drift.sh ok (schema
assembly deterministic, wire unchanged); check-schema-catalog.sh ok
(27 products, 1121 tools).
2026-08-15 12:41:46 +08:00
95 changed files with 5366 additions and 1064 deletions
+26
View File
@@ -0,0 +1,26 @@
---
category: Added
---
- **Wait framework capability** — adds the reviewed `Contract.Wait`
declaration (`contract.WaitSpec`) with three execution modes: `poll`
(cadence-poll the leaf's `WaitPoll` hook), `event` (consume the leaf's
`WaitEvents` push stream, correlate events to the accepted resource via
`match_field`/`resource_query`, apply the same terminal map), and `auto`
(event first, fall back to polling when the stream ends or the
subscription fails — one deadline spans both phases). Declared commands
must use the `ResultInvoke` dispatcher; mode and hooks are paired at
construction (poll↔WaitPoll, event↔WaitEvents, auto↔both; surplus hooks
are rejected too). Declared commands register `--wait` /
`--wait-timeout` (framework-owned flags that never enter MCP toolArgs);
undeclared commands reject the flags as unknown. The wait phase closes
the unified envelope exactly once: terminal success → `success`,
terminal failure → `failure` with new wire-stable `error.type: "wait"`
(exit code 8), timeout → `pending` with `meta.operation.timed_out: true`
and the last observed state (exit 0). Deadline exhaustion during a poll,
during event consumption, or between polls always closes as timed-out
pending, never as a poll/stream failure; a correlated event with an
unknown status fails closed exactly like a poll. The capability is
projected into the Schema catalog (`wait` key) alongside `dry_run`. No
business command declares it yet; approval/export/batch adoption lands
separately.
+55
View File
@@ -6,6 +6,61 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
## [1.0.59-beta.3] - 2026-08-19
### Added
- **Robot group reference replies** (#928) — `chat message send-by-bot` supports paired `--reply` and `--ref-sender` flags for Markdown replies that quote an existing group message.
- **AI Table server-side statistics** — adds `dws aitable record stats` for
ungrouped record-set metrics through `query_records_stats`, plus `dws aitable
record group-stats` for grouped, distinct, and advanced aggregation through
`query_stats`; both commands validate their JSON aggregation contracts before
dispatch.
- **Calendar event share-info** (#980) — adds `dws calendar event share-info` to fetch a calendar event's share info (title, organizer, location, join info) for sharing with others; supports `--calendar-id` and `--language`.
- **Calendar and To-do Shortcut workflows** — aligns 47 public task-oriented
entries with lark-cli where the DingTalk backend supports equivalent
semantics, rejects malformed or missing collections instead of returning
false empty success, preserves truthful pagination, and requires stable
identifiers plus read-back or explicit terminal receipts for writes. Adds
deterministic contract coverage, a PII-safe live E2E runner, and a sanitized
capability review with documented platform boundaries.
- **Doc and Sheet comment lifecycle commands** — adds `comment batch-query`,
`comment resolve`, `comment restore`, and the lightweight
`comment react-reply` to both `dws doc` and `dws sheet`. The two domains share
the same `doc-comment` MCP capabilities; batch queries preserve input order
for repeated `topicId:commentKey` references, while reaction replies require
DingTalk reaction names such as `憨笑` or `鼓掌` rather than raw Unicode emoji.
- **Sheet SourceRange dropdowns** — supports range-backed dropdowns across direct, cell, and batch write paths, with structured readback for valid and invalid references. Batch `set-dropdown` now rejects unsupported top-level `colors` / `source-colors`; Inline colors belong in `options[].color`, while SourceRange color writes remain unsupported.
- **Sheet read completion metadata** — documents and preserves returned ranges, truncation reasons, and partial-read status for large range and CSV reads.
### Changed
- **AI Table parameter aliases** — accepts reviewed equivalent spellings for Base, table, workflow, search, pagination, and description parameters while keeping role-changing or semantically ambiguous inputs blocked.
- **Doc/drive description scope** — restates the `dingtalk-doc` description as document-entity-and-content operations with an explicit exclusion list, and narrows `dingtalk-drive` to file-level management of DingTalk documents, so first-round Agent selection separates content work from file management without changing CLI behavior.
### Fixed
- **Aitable pagination and Minutes unshare verification** (#1006) — keeps
record queries on the service's 20-record page boundary so multi-page reads
and mutation readbacks no longer report false retryable failures, preserves
`totalCount` when supplied, validates `--dry-run` plans before transport,
follows active deletion readback continuations before proving absence, and
rejects Minutes unshare success until the listening note exists and the
service acknowledges the exact task and member targets.
- **Document write verification** (#960) — avoids false partial-success results when normalized Markdown, paginated blocks, inline images, or version reverts are confirmed by server readback. Document reverts and media inserts now require explicit readback evidence and report partial success when the server cannot prove the requested result.
- **Chat sender identity guards** — preserves unverified mixed sender inputs after exact message `senderId` matches and aligns `--sender-query` Skill guidance with fail-closed Runtime behavior.
- **Windows event bus lifecycle** — start event consumers without unsupported inherited file descriptors, stop buses through local IPC with a termination fallback, and preserve subscription cleanup when startup fails.
## [1.0.59-beta.2] - 2026-08-17
### Added
+11 -11
View File
@@ -1,33 +1,33 @@
class DingtalkWorkspaceCliBeta < Formula
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
version "1.0.59-beta.2"
version "1.0.59-beta.3"
license "Apache-2.0"
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.2/dws-darwin-arm64.tar.gz"
sha256 "7f11218d3222f3e93c3b1e94b3a004c061eb0a297b206447ea95fe6b2b1ec674"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-darwin-arm64.tar.gz"
sha256 "9c99adcefd9104368eb443f0a1b4af8e7aceaa1ffdd4462e486854c1692bb6ce"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.2/dws-darwin-amd64.tar.gz"
sha256 "72f06a334cf29d23123639fabe13bf2951765066136e47ccc6453667c382f8c2"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-darwin-amd64.tar.gz"
sha256 "f5cc8efb1f982d68ae549190fd683292359c2ab542b532fa52bb35e6b5c049af"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.2/dws-linux-arm64.tar.gz"
sha256 "3edbfabb7718b53914a2d9efe7b1152e9ebd70765ff0b6f19ad3125e4a2458ed"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-linux-arm64.tar.gz"
sha256 "7a4efd04b417ce8013b1e431274b396179958da244164f59974358ba327ff093"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.2/dws-linux-amd64.tar.gz"
sha256 "e1c610070a9c1b3763656cbd53c818290f199097adc07cb9fe629ed765c5e1e0"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-linux-amd64.tar.gz"
sha256 "90181e8f2e9010c1943a5773c3d45d7d3ac85d6bc93e18a9aaa7c69909e553d7"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.2/dws-skills.zip"
sha256 "aa2854651eaa2c857b526aaec73fd1358d31b11999fe7fd3e99e5060b2522a1f"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-skills.zip"
sha256 "e7028914a4a826af9b18ed4922d68fa8f279473817fed4f305465bc8a7aad363"
end
def install
+3 -2
View File
@@ -482,7 +482,7 @@ Env vars: `DWS_SKILL_MODE=mono|multi` (also honored by `install.sh` / `install.p
<details>
<summary><strong>Personal Event Subscription</strong> — real-time DingTalk messages for event-driven agents</summary>
`dws event consume` subscribes as the currently logged-in user over a managed Stream WebSocket and emits each event as one NDJSON line on stdout. The public catalog covers scoped and all one-to-one/group messages, specified senders, read/recall/reaction events, group lifecycle events, and six OA approval task/instance events.
`dws event consume` subscribes as the currently logged-in user over a managed Stream WebSocket and emits each event as one NDJSON line on stdout. The public catalog covers scoped and all one-to-one/group messages, specified senders, read/recall/reaction events, group lifecycle events, and seven OA approval task/instance events.
The default `ndjson`, `json`, and `pretty` output preserves the transport envelope (`type`, `event_type`, string `data`, and `headers`) for existing scripts; `compact` retains its existing processor. Add `--flatten` to emit the stable top-level business fields used by Agent workflows. `--format` controls JSON serialization; `--flatten` controls the data structure and cannot be combined with `-f raw` or `--debug-raw-events`.
@@ -530,12 +530,13 @@ dws event consume user_im_group_disbanded --group <openConversationId> --flatten
dws event +listen-im --kind sender --user <userId> \
--events message,read,recall -f ndjson
# Listen for all six public OA approval events in one process
# Listen for all seven public OA approval events in one process
dws event consume \
user_oa_approval_task_created \
user_oa_approval_task_finished \
user_oa_approval_task_redirected \
user_oa_approval_instance_started \
user_oa_approval_instance_cc \
user_oa_approval_instance_terminated \
user_oa_approval_instance_finished \
--flatten -f ndjson
+3 -2
View File
@@ -476,7 +476,7 @@ multi setup 或 upgrade 后,DWS 会把官方 bundle 快照和统一所有权
<details>
<summary><strong>个人事件订阅</strong> — 实时接收钉钉消息,驱动事件触发的 Agent</summary>
`dws event consume` 使用当前 OAuth 登录用户建立托管的 Stream WebSocket 长连接,并把每条事件以 NDJSON 一行输出到 stdout。当前公开目录覆盖指定范围和全量单聊/群消息、指定发送人、已读/撤回/表情回应、群生命周期,以及六个 OA 审批任务/实例事件。
`dws event consume` 使用当前 OAuth 登录用户建立托管的 Stream WebSocket 长连接,并把每条事件以 NDJSON 一行输出到 stdout。当前公开目录覆盖指定范围和全量单聊/群消息、指定发送人、已读/撤回/表情回应、群生命周期,以及七个 OA 审批任务/实例事件。
默认 `ndjson`、`json`、`pretty` 输出保留兼容 transport envelope(`type`、`event_type`、字符串 `data`、`headers`),`compact` 继续沿用原 processor。Agent 或新脚本显式加 `--flatten` 后,输出稳定的顶层业务字段。`--format` 控制 JSON 序列化,`--flatten` 控制数据结构,且不能与 `-f raw` 或 `--debug-raw-events` 同时使用。
@@ -524,12 +524,13 @@ dws event consume user_im_group_disbanded --group <openConversationId> --flatten
dws event +listen-im --kind sender --user <userId> \
--events message,read,recall -f ndjson
# 一个进程监听全部六个公开 OA 审批事件
# 一个进程监听全部七个公开 OA 审批事件
dws event consume \
user_oa_approval_task_created \
user_oa_approval_task_finished \
user_oa_approval_task_redirected \
user_oa_approval_instance_started \
user_oa_approval_instance_cc \
user_oa_approval_instance_terminated \
user_oa_approval_instance_finished \
--flatten -f ndjson
@@ -360,6 +360,7 @@ Definition(仅声明;不可编译)
| | `idempotency` | 评审源(或未来 Contract) | reviewed metadata | 今日非框架声明;不得推断 |
| | `effect_source` / provenance | 组装派生物 | resolver 写入 `FieldProvenance` | 派生,不手写 |
| **DryRun** | `preview_kind`, `remote_reads` | 评审源 | `schema_dry_run_capabilities`(正能力声明) | 否;无条目 ≠ 推断「不支持」之外的假能力 |
| **Wait** | `mode`(`poll`/`event`/`auto`), `poll_command`, `status_query`, `terminal`(状态→success/failure), `pending_values`, `event_key`/`match_field`/`resource_query`(event/auto), `default_timeout_secs` | **声明**(`ContractDecl.Wait` 正能力声明,且必须搭配 ResultInvoke dispatcher + 按模式的 hook:poll↔`WaitPoll`、event↔`WaitEvents`、auto↔两者,构造期配对校验,多余 hook 同样拒绝) | 声明后注册 `--wait`/`--wait-timeout`(框架 flag,不进 toolArgs);Schema 投影 `wait` 键;auto = 事件优先、流终止/订阅失败回退轮询,一个 deadline 覆盖两阶段并传入 `WaitPoll`/`WaitEvents`(及 `Command().Context()`);仅 pending 初始结果进入等待,success/failure/partial 原样返回 | 否;未声明命令传 `--wait` = unknown flag。终态失败经统一信封 `error.type: "wait"`(rc=8),超时保持 pending + `meta.operation.timed_out`(rc=0);轮询间/轮询中/事件消费中超时一律按 pending 关闭 |
| **Interface** | `interface_mode`, `interface_ref`, `availability`, `reason` | 评审源 | MCP meta + agent metadata 解析 | 否;与 CLI Identity 分离 |
| **Selection** | `agent_summary`, `use_when`, `avoid_when`, `examples`, `prerequisites`, `tips`, `workflow_refs`, … | 声明(`ContractDecl.Selection` / `ProductDecl`) | `ContractDecl` / `ProductDecl`(`schema_hints/` 已退役) | 可声明;声明载荷**不得携带** `Reviewed`(旧路径专用),携带即组装报错 |
| **FieldProvenance** | 各字段 winner / candidates | 组装派生物 | Schema 组装器 | 派生;须与 delivered value 一致 |
+13 -3
View File
@@ -1,6 +1,6 @@
{
"generated_at": "2026-08-17T15:52:13.675461",
"count": 435,
"generated_at": "2026-08-18T17:38:50.904696",
"count": 436,
"results": [
{
"suite": "semantic",
@@ -489,7 +489,7 @@
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一 ID、筛选、关键词和分页查询,并投影稳定的 records/count/cursor 结果。",
"semantic_delta": "统一 ID、筛选、关键词、字段投影和分页查询;fieldIds 只返回用户要求的列,并投影稳定的 records/count/cursor 结果。",
"availability": "available"
},
{
@@ -692,6 +692,16 @@
"semantic_delta": "按当前父目录语义调整文件夹展示顺序。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+table-bootstrap",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "在已有 Base 中声明式创建一张表和字段,字段自动分片,逐层读回验证并发布可恢复检查点。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
+1 -1
View File
@@ -403,7 +403,7 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
Selection: contract.SelectionSpec{
AgentSummary: "消费 OA、群生命周期或需要底层控制的个人事件流;Agent 通常使用 --flatten 输出 NDJSON",
UseWhen: []string{
"需要监听六个公开 OA 审批任务/实例 EventKey 中的一个或多个事件",
"需要监听七个公开 OA 审批任务/实例 EventKey 中的一个或多个事件",
"需要监听指定群的标题变更、成员进退群或群解散事件",
"用户显式给出原始 EventKey、Filter DSL、subscribe_id,要求原始 transport envelope,或需要普通 IM facade 不提供的高级多事件控制",
},
+2 -2
View File
@@ -152,8 +152,8 @@ func TestCrossPlatformCoveragePersonalSubscriptionProtectionCoversAllPublicEvent
}
}
if publicCount != 22 {
t.Fatalf("public personal events = %d, want 22 (16 IM + 6 OA)", publicCount)
if publicCount != 23 {
t.Fatalf("public personal events = %d, want 23 (16 IM + 7 OA)", publicCount)
}
for _, ruleType := range []string{"at", "all", "singleChat", "sender", "group"} {
if !ruleTypes[ruleType] {
+9
View File
@@ -61,6 +61,13 @@ func TestPersonalOAEventListAndSchemaCommands(t *testing.T) {
"process_code", "title", "status", "create_time", "event_time",
},
},
{
eventKey: personal.EventOAApprovalInstanceCC,
properties: []string{
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
"process_code", "title", "status", "create_time", "event_time",
},
},
{
eventKey: personal.EventOAApprovalInstanceTerminated,
properties: []string{
@@ -161,6 +168,7 @@ func TestPersonalOAEventConsumeDryRunAndValidation(t *testing.T) {
personal.EventOAApprovalTaskFinished,
personal.EventOAApprovalTaskRedirected,
personal.EventOAApprovalInstanceStarted,
personal.EventOAApprovalInstanceCC,
personal.EventOAApprovalInstanceTerminated,
personal.EventOAApprovalInstanceFinished,
}
@@ -414,6 +422,7 @@ func TestPersonalOAMultiConsumeCreatesIndependentAllSubscriptionsOnSharedBus(t *
personal.EventOAApprovalTaskFinished,
personal.EventOAApprovalTaskRedirected,
personal.EventOAApprovalInstanceStarted,
personal.EventOAApprovalInstanceCC,
personal.EventOAApprovalInstanceTerminated,
personal.EventOAApprovalInstanceFinished,
}
+67 -1
View File
@@ -51,7 +51,38 @@ func (c *paramAliasCaptureCaller) CallTool(_ context.Context, server, tool strin
func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string {
switch tool {
case "list_calendar_events":
return `{"result":{"events":[]}}`
return `{"success":true,"result":{"events":[],"hasMore":false,"nextCursor":""}}`
case "get_calendar_detail":
return c.paramAliasCalendarDetailResponse()
case "get_calendar_participants":
return `{"success":true,"result":{"participants":[{"userId":"fixture-user","displayName":"Fixture User"},{"userId":"user-2","displayName":"User Two"}]}}`
case "search_calendar":
return `{"success":true,"result":{"calendars":[]}}`
case "search_rooms":
return `{"success":true,"result":{"rooms":[]}}`
case "query_available_meeting_room":
return `{"success":true,"result":{"rooms":[],"hasMore":false}}`
case "list_meeting_room_groups":
return `{"success":true,"result":{"groups":[]}}`
case "query_busy_status":
return `{"success":true,"result":[]}`
case "list_suggested_event_times":
return `{"success":true,"result":{"recommendEventTimes":[]}}`
case "create_calendar_event":
return `{"success":true,"result":{"eventId":"event-1"}}`
case "update_calendar_event", "delete_calendar_event", "add_calendar_participant", "remove_calendar_participant":
return `{"success":true}`
case "respond":
status := "accepted"
if call := c.lastParamAliasCall(); call != nil {
if value, ok := call.args["responseStatus"].(string); ok && value != "" {
status = value
}
}
encoded, _ := json.Marshal(map[string]any{"success": true, "result": map[string]any{"responseStatus": status}})
return string(encoded)
case "get_current_user_profile":
return `{"success":true,"result":{"userId":"user-1","name":"Fixture Current User"}}`
case "query_records":
return `{"success":true,"status":"success","error":{},"data":{}}`
case "search_mail_users":
@@ -127,6 +158,41 @@ func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string
}
}
func (c *paramAliasCaptureCaller) lastParamAliasCall() *paramAliasToolCall {
if len(c.calls) == 0 {
return nil
}
return &c.calls[len(c.calls)-1]
}
func (c *paramAliasCaptureCaller) paramAliasCalendarDetailResponse() string {
event := map[string]any{
"eventId": "event-1",
"summary": "Fixture Meeting",
"description": "fixture description",
"startDateTime": "2026-03-10T09:00:00+08:00",
"endDateTime": "2026-03-10T10:00:00+08:00",
}
for _, call := range c.calls {
switch call.tool {
case "create_calendar_event", "update_calendar_event":
for _, key := range []string{"eventId", "summary", "description", "startDateTime", "endDateTime", "timeZone", "location", "freeBusy"} {
if value, ok := call.args[key]; ok {
event[key] = value
}
}
case "respond":
if value, ok := call.args["responseStatus"]; ok {
event["responseStatus"] = value
}
case "delete_calendar_event":
event["status"] = "cancelled"
}
}
encoded, _ := json.Marshal(map[string]any{"success": true, "result": event})
return string(encoded)
}
func (*paramAliasCaptureCaller) Format() string { return "json" }
func (*paramAliasCaptureCaller) DryRun() bool { return false }
func (*paramAliasCaptureCaller) Fields() string { return "" }
@@ -5,6 +5,8 @@ package app
import (
"errors"
"os"
"os/exec"
"reflect"
"strings"
"testing"
@@ -17,6 +19,8 @@ import (
const (
appFixtureCurrentDOpenID = "DAAAAAAAAAAAiE"
appFixtureCurrentDOpenID2 = "DAQEBAQEBAQEiE"
paramAliasCalendarPayloadChildEnv = "DWS_TEST_CALENDAR_PARAM_ALIAS_PAYLOAD_CHILD"
)
// paramAliasCompleteCommands is deliberately keyed by the exact reviewed
@@ -46,7 +50,37 @@ var paramAliasCompleteCommands = map[string][]string{
"aitable workflow run": {"aitable", "workflow", "run", "--base-id", "base-1", "--workflow-id", "workflow-1", "--table-id", "table-1", "--record-ids", "record-1", "--yes"},
"attendance check result": {"attendance", "check", "result", "--users", "user-1,user-2", "--start", "2026-03-01", "--end", "2026-03-02"},
"attendance +check-result": {"attendance", "+check-result", "--users", "user-1,user-2", "--start", "2026-03-01", "--end", "2026-03-02"},
"calendar +agenda": {"calendar", "+agenda", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--calendar-id", "primary", "--cursor", "cursor-1", "--limit", "7"},
"calendar +attendee-list": {"calendar", "+attendee-list", "--event", "event-1", "--calendar-id", "primary"},
"calendar +book": {"calendar", "+book", "--title", "Fixture Meeting", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T10:00:00+08:00", "--with", "Fixture User", "--yes"},
"calendar +book-search": {"calendar", "+book-search", "--query", "fixture"},
"calendar +cancel-event": {"calendar", "+cancel-event", "--event", "event-1", "--yes"},
"calendar +conflicts": {"calendar", "+conflicts", "--in-days", "1"},
"calendar +create": {"calendar", "+create", "--title", "Fixture Meeting", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T10:00:00+08:00", "--desc", "fixture description", "--attendees", "user-1,user-2", "--rooms", "room-1,room-2", "--calendar-id", "primary", "--yes"},
"calendar +free": {"calendar", "+free", "--who", "Fixture User", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00"},
"calendar +free-slots": {"calendar", "+free-slots", "--from", "9", "--to", "18", "--in-days", "1"},
"calendar +freebusy": {"calendar", "+freebusy", "--users", "user-1,user-2", "--rooms", "room-1,room-2", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00"},
"calendar +get": {"calendar", "+get", "--event", "event-1", "--calendar-id", "primary"},
"calendar +invite": {"calendar", "+invite", "--event", "event-1", "--with", "Fixture User", "--yes"},
"calendar +my-free": {"calendar", "+my-free", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00"},
"calendar +reschedule": {"calendar", "+reschedule", "--event", "event-1", "--start", "2026-03-10T10:00:00+08:00", "--end", "2026-03-10T11:00:00+08:00", "--yes"},
"calendar +room-find": {"calendar", "+room-find", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T10:00:00+08:00", "--room-name", "Fixture Room", "--group-id", "group-1", "--page", "1", "--limit", "7"},
"calendar +room-groups": {"calendar", "+room-groups", "--page", "1", "--limit", "7"},
"calendar +room-search": {"calendar", "+room-search", "--room-name", "Fixture Room"},
"calendar +rsvp": {"calendar", "+rsvp", "--event", "event-1", "--status", "accept", "--calendar-id", "primary", "--yes"},
"calendar +search-event": {"calendar", "+search-event", "--query", "fixture", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--calendar-id", "primary", "--cursor", "cursor-1", "--limit", "7"},
"calendar +suggest-time": {"calendar", "+suggest-time", "--with", "Fixture User", "--duration", "30", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00"},
"calendar +suggestion": {"calendar", "+suggestion", "--users", "user-1,user-2", "--duration", "30", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--timezone", "Asia/Shanghai"},
"calendar +update": {"calendar", "+update", "--event", "event-1", "--title", "Fixture Updated Meeting", "--desc", "fixture updated description", "--start", "2026-03-10T10:00:00+08:00", "--end", "2026-03-10T11:00:00+08:00", "--add-attendees", "user-2", "--remove-attendees", "user-1", "--yes"},
"calendar busy search": {"calendar", "busy", "search", "--users", "user-1,user-2", "--rooms", "room-1,room-2", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00"},
"calendar event create": {"calendar", "event", "create", "--title", "Fixture Meeting", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T10:00:00+08:00", "--remind-minutes", "15", "--timezone", "Asia/Shanghai", "--rooms", "room-1,room-2"},
"calendar event list": {"calendar", "event", "list", "--start", "2026-03-10T14:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--calendar-id", "primary", "--cursor", "cursor-1", "--limit", "7"},
"calendar event respond": {"calendar", "event", "respond", "--id", "event-1", "--status", "accepted"},
"calendar event suggest": {"calendar", "event", "suggest", "--users", "user-1,user-2", "--duration", "30", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--timezone", "Asia/Shanghai"},
"calendar event update": {"calendar", "event", "update", "--id", "event-1", "--timezone", "Asia/Shanghai"},
"calendar room add": {"calendar", "room", "add", "--event", "event-1", "--rooms", "room-1,room-2"},
"calendar room delete": {"calendar", "room", "delete", "--event", "event-1", "--rooms", "room-1,room-2"},
"calendar room search": {"calendar", "room", "search", "--room-name", "Fixture Room", "--group-id", "group-1", "--start", "2027-03-10T09:00:00+08:00", "--end", "2027-03-10T10:00:00+08:00", "--page", "1", "--limit", "7"},
"chat +chat-messages": {"chat", "+chat-messages", "--group", "fixture-conversation"},
"chat +chat-add-bot": {"chat", "+chat-add-bot", "--id", "fixture-conversation", "--robot-code", "robot-1", "--yes"},
"chat +chat-audit-join": {"chat", "+chat-audit-join", "--group", "fixture-conversation", "--record-id", "7", "--applicant", "user-1", "--inviter", "user-2", "--status", "AuditApprove", "--yes"},
@@ -566,6 +600,108 @@ var paramAliasRepresentativePayloadCases = map[string]bool{
paramAliasPayloadCaseKey("report list", "from-date"): true, // date-range concept alias
}
// paramAliasCalendarPayloadCases keeps the full reviewed Calendar expansion
// separate from the long-lived app-c race process. Each case still executes
// both canonical and alias argv through the real PreParse/Cobra path and
// compares the final captured transport calls; the owning top-level test runs
// these allocations in a short-lived race-instrumented subprocess so all Root
// registrations are released together when that process exits.
var paramAliasCalendarPayloadCases = map[string]bool{
paramAliasPayloadCaseKey("calendar +agenda", "from"): true,
paramAliasPayloadCaseKey("calendar +agenda", "to"): true,
paramAliasPayloadCaseKey("calendar +agenda", "max-results"): true,
paramAliasPayloadCaseKey("calendar +agenda", "next-cursor"): true,
paramAliasPayloadCaseKey("calendar +agenda", "calendar-book-id"): true,
paramAliasPayloadCaseKey("calendar +attendee-list", "event-id"): true,
paramAliasPayloadCaseKey("calendar +attendee-list", "calendar-book-id"): true,
paramAliasPayloadCaseKey("calendar +book", "summary"): true,
paramAliasPayloadCaseKey("calendar +book", "attendee-names"): true,
paramAliasPayloadCaseKey("calendar +book-search", "keyword"): true,
paramAliasPayloadCaseKey("calendar +book-search", "search"): true,
paramAliasPayloadCaseKey("calendar +book-search", "name"): true,
paramAliasPayloadCaseKey("calendar +cancel-event", "event-id"): true,
paramAliasPayloadCaseKey("calendar +cancel-event", "id"): true,
paramAliasPayloadCaseKey("calendar +free", "name"): true,
paramAliasPayloadCaseKey("calendar +free-slots", "start-hour"): true,
paramAliasPayloadCaseKey("calendar +free-slots", "end-hour"): true,
paramAliasPayloadCaseKey("calendar +free-slots", "day-offset"): true,
paramAliasPayloadCaseKey("calendar +freebusy", "user-ids"): true,
paramAliasPayloadCaseKey("calendar +freebusy", "room-ids"): true,
paramAliasPayloadCaseKey("calendar +freebusy", "room-id"): true,
paramAliasPayloadCaseKey("calendar +my-free", "from"): true,
paramAliasPayloadCaseKey("calendar +my-free", "to"): true,
paramAliasPayloadCaseKey("calendar +invite", "id"): true,
paramAliasPayloadCaseKey("calendar +invite", "participant-names"): true,
paramAliasPayloadCaseKey("calendar +reschedule", "id"): true,
paramAliasPayloadCaseKey("calendar +reschedule", "from"): true,
paramAliasPayloadCaseKey("calendar +reschedule", "to"): true,
paramAliasPayloadCaseKey("calendar +room-groups", "page-size"): true,
paramAliasPayloadCaseKey("calendar +room-groups", "page-index"): true,
paramAliasPayloadCaseKey("calendar +room-search", "query"): true,
paramAliasPayloadCaseKey("calendar +suggest-time", "duration-minutes"): true,
paramAliasPayloadCaseKey("calendar +suggest-time", "attendee-names"): true,
paramAliasPayloadCaseKey("calendar +conflicts", "day-offset"): true,
paramAliasPayloadCaseKey("calendar busy search", "room-id"): true,
paramAliasPayloadCaseKey("calendar event create", "reminder-minutes"): true,
paramAliasPayloadCaseKey("calendar event create", "tz"): true,
paramAliasPayloadCaseKey("calendar event create", "room-id"): true,
paramAliasPayloadCaseKey("calendar event respond", "response-status"): true,
paramAliasPayloadCaseKey("calendar event suggest", "duration-minutes"): true,
paramAliasPayloadCaseKey("calendar event update", "tz"): true,
paramAliasPayloadCaseKey("calendar room add", "room-id"): true,
paramAliasPayloadCaseKey("calendar room delete", "room-id"): true,
paramAliasPayloadCaseKey("calendar room search", "room-group-id"): true,
paramAliasPayloadCaseKey("calendar +create", "summary"): true,
paramAliasPayloadCaseKey("calendar +create", "description"): true,
paramAliasPayloadCaseKey("calendar +create", "user-ids"): true,
paramAliasPayloadCaseKey("calendar +create", "room-ids"): true,
paramAliasPayloadCaseKey("calendar +create", "room-id"): true,
paramAliasPayloadCaseKey("calendar +create", "calendar-book-id"): true,
paramAliasPayloadCaseKey("calendar +create", "to"): true,
paramAliasPayloadCaseKey("calendar +create", "from"): true,
paramAliasPayloadCaseKey("calendar +get", "event-id"): true,
paramAliasPayloadCaseKey("calendar +get", "calendar-book-id"): true,
paramAliasPayloadCaseKey("calendar +room-find", "from"): true,
paramAliasPayloadCaseKey("calendar +room-find", "to"): true,
paramAliasPayloadCaseKey("calendar +room-find", "page-size"): true,
paramAliasPayloadCaseKey("calendar +room-find", "page-index"): true,
paramAliasPayloadCaseKey("calendar +room-find", "room-group-id"): true,
paramAliasPayloadCaseKey("calendar +room-find", "query"): true,
paramAliasPayloadCaseKey("calendar +rsvp", "event-id"): true,
paramAliasPayloadCaseKey("calendar +rsvp", "response-status"): true,
paramAliasPayloadCaseKey("calendar +search-event", "keyword"): true,
paramAliasPayloadCaseKey("calendar +search-event", "from"): true,
paramAliasPayloadCaseKey("calendar +search-event", "to"): true,
paramAliasPayloadCaseKey("calendar +search-event", "next-cursor"): true,
paramAliasPayloadCaseKey("calendar +search-event", "max-results"): true,
paramAliasPayloadCaseKey("calendar +suggestion", "user-ids"): true,
paramAliasPayloadCaseKey("calendar +suggestion", "duration-minutes"): true,
paramAliasPayloadCaseKey("calendar +suggestion", "from"): true,
paramAliasPayloadCaseKey("calendar +suggestion", "to"): true,
paramAliasPayloadCaseKey("calendar +suggestion", "tz"): true,
paramAliasPayloadCaseKey("calendar +update", "event-id"): true,
paramAliasPayloadCaseKey("calendar +update", "from"): true,
paramAliasPayloadCaseKey("calendar +update", "summary"): true,
paramAliasPayloadCaseKey("calendar +update", "description"): true,
paramAliasPayloadCaseKey("calendar +update", "add-user-ids"): true,
paramAliasPayloadCaseKey("calendar +update", "remove-user-ids"): true,
}
// paramAliasCalendarConfirmationCases selects one newly reviewed alias for
// every Calendar Shortcut whose runtime contract requires user confirmation.
// The complete Calendar matrix proves confirmed canonical/alias payload
// equality; these representatives additionally prove semantic normalization
// cannot cross the confirmation boundary before the first transport call.
var paramAliasCalendarConfirmationCases = map[string]bool{
paramAliasPayloadCaseKey("calendar +book", "summary"): true,
paramAliasPayloadCaseKey("calendar +cancel-event", "event-id"): true,
paramAliasPayloadCaseKey("calendar +create", "summary"): true,
paramAliasPayloadCaseKey("calendar +invite", "id"): true,
paramAliasPayloadCaseKey("calendar +reschedule", "from"): true,
paramAliasPayloadCaseKey("calendar +rsvp", "response-status"): true,
paramAliasPayloadCaseKey("calendar +update", "event-id"): true,
}
func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepresentativeFinalPayloads(t *testing.T) {
concepts, err := cli.LoadParamConcepts()
if err != nil {
@@ -599,28 +735,7 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
}
executedRepresentatives[caseKey] = true
t.Run(fixture.Command+"/"+fixture.Emitted, func(t *testing.T) {
canonicalCaller := &paramAliasCaptureCaller{}
_, canonicalErr := executeParamAliasPayloadE2E(t, canonicalCaller, canonicalArgs...)
if canonicalErr != nil {
t.Fatalf("complete canonical command failed: %v\nargs=%v\ncalls=%#v", canonicalErr, canonicalArgs, canonicalCaller.calls)
}
if len(canonicalCaller.calls) == 0 {
t.Fatalf("complete canonical command reached no final transport payload: args=%v", canonicalArgs)
}
aliasCaller := &paramAliasCaptureCaller{}
ctx, aliasErr := executeParamAliasPayloadE2E(t, aliasCaller, aliasArgs...)
if aliasErr != nil {
t.Fatalf("complete alias command failed: %v\nargs=%v\ncalls=%#v", aliasErr, aliasArgs, aliasCaller.calls)
}
if ctx == nil {
t.Fatal("complete alias command skipped PreParse")
}
normalizeParamAliasVolatileDefaults(fixture.Command, canonicalCaller, aliasCaller)
if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) {
t.Fatalf("final transport calls differ\ncanonical args: %v\nalias args: %v\ncanonical calls: %#v\nalias calls: %#v", canonicalArgs, aliasArgs, canonicalCaller.calls, aliasCaller.calls)
}
assertParamAliasFinalPayloadEquivalent(t, fixture.Command, canonicalArgs, aliasArgs)
})
}
@@ -650,6 +765,118 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
}
}
func TestCrossPlatformCoverageReviewedCalendarParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) {
if os.Getenv(paramAliasCalendarPayloadChildEnv) != "1" {
command := exec.Command(
os.Args[0],
"-test.run=^TestCrossPlatformCoverageReviewedCalendarParamAliasesReachCanonicalEquivalentFinalPayloads$",
"-test.count=1",
"-test.timeout=5m",
)
command.Env = append(os.Environ(), paramAliasCalendarPayloadChildEnv+"=1")
output, err := command.CombinedOutput()
if err != nil {
t.Fatalf("Calendar param-alias payload subprocess failed: %v\n%s", err, strings.TrimSpace(string(output)))
}
return
}
concepts, err := cli.LoadParamConcepts()
if err != nil {
t.Fatalf("LoadParamConcepts() error = %v", err)
}
executed := make(map[string]bool)
executedConfirmation := make(map[string]bool)
for _, fixture := range concepts.Fixture {
caseKey := paramAliasPayloadCaseKey(fixture.Command, fixture.Emitted)
if !paramAliasCalendarPayloadCases[caseKey] {
continue
}
executed[caseKey] = true
fixture := fixture
t.Run(fixture.Command+"/"+fixture.Emitted, func(t *testing.T) {
complete, ok := paramAliasCompleteCommand(fixture.Command, fixture.Expect)
if !ok {
t.Fatal("reviewed Calendar alias has no complete-command E2E template")
}
canonicalArgs := append([]string(nil), complete...)
aliasArgs, replacements := replaceLongFlag(canonicalArgs, fixture.Expect, fixture.Emitted)
if replacements != 1 {
t.Fatalf("complete Calendar command must contain canonical --%s exactly once; replacements=%d args=%v", fixture.Expect, replacements, canonicalArgs)
}
assertParamAliasFinalPayloadEquivalent(t, fixture.Command, canonicalArgs, aliasArgs)
if paramAliasCalendarConfirmationCases[caseKey] {
executedConfirmation[caseKey] = true
assertParamAliasCannotBypassConfirmation(t, aliasArgs)
}
})
}
for caseKey := range paramAliasCalendarPayloadCases {
if !executed[caseKey] {
t.Errorf("Calendar final-payload case %q has no active reviewed fixture", caseKey)
}
}
if len(executed) != len(paramAliasCalendarPayloadCases) {
t.Fatalf("Calendar final-payload coverage = %d, want %d", len(executed), len(paramAliasCalendarPayloadCases))
}
for caseKey := range paramAliasCalendarConfirmationCases {
if !executedConfirmation[caseKey] {
t.Errorf("Calendar confirmation case %q has no active reviewed fixture", caseKey)
}
}
if len(executedConfirmation) != len(paramAliasCalendarConfirmationCases) {
t.Fatalf("Calendar confirmation coverage = %d, want %d", len(executedConfirmation), len(paramAliasCalendarConfirmationCases))
}
}
func assertParamAliasCannotBypassConfirmation(t *testing.T, aliasArgs []string) {
t.Helper()
unconfirmedArgs, removals := removeExactArg(aliasArgs, "--yes")
if removals != 1 {
t.Fatalf("confirmation template must contain --yes exactly once; removals=%d args=%v", removals, aliasArgs)
}
caller := &paramAliasCaptureCaller{}
ctx, err := executeParamAliasPayloadE2E(t, caller, unconfirmedArgs...)
if ctx == nil {
t.Fatal("unconfirmed Calendar alias command skipped PreParse")
}
var appErr *apperrors.Error
if !errors.As(err, &appErr) || appErr.Reason != "confirmation_required" {
t.Fatalf("unconfirmed Calendar alias command error = %#v, want confirmation_required\nargs=%v", err, unconfirmedArgs)
}
if len(caller.calls) != 0 {
t.Fatalf("unconfirmed Calendar alias crossed the transport boundary: args=%v calls=%#v", unconfirmedArgs, caller.calls)
}
}
func assertParamAliasFinalPayloadEquivalent(t *testing.T, command string, canonicalArgs, aliasArgs []string) {
t.Helper()
canonicalCaller := &paramAliasCaptureCaller{}
_, canonicalErr := executeParamAliasPayloadE2E(t, canonicalCaller, canonicalArgs...)
if canonicalErr != nil {
t.Fatalf("complete canonical command failed: %v\nargs=%v\ncalls=%#v", canonicalErr, canonicalArgs, canonicalCaller.calls)
}
if len(canonicalCaller.calls) == 0 {
t.Fatalf("complete canonical command reached no final transport payload: args=%v", canonicalArgs)
}
aliasCaller := &paramAliasCaptureCaller{}
ctx, aliasErr := executeParamAliasPayloadE2E(t, aliasCaller, aliasArgs...)
if aliasErr != nil {
t.Fatalf("complete alias command failed: %v\nargs=%v\ncalls=%#v", aliasErr, aliasArgs, aliasCaller.calls)
}
if ctx == nil {
t.Fatal("complete alias command skipped PreParse")
}
normalizeParamAliasVolatileDefaults(command, canonicalCaller, aliasCaller)
if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) {
t.Fatalf("final transport calls differ\ncanonical args: %v\nalias args: %v\ncanonical calls: %#v\nalias calls: %#v", canonicalArgs, aliasArgs, canonicalCaller.calls, aliasCaller.calls)
}
}
func TestCrossPlatformCoverageNewIMParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) {
activeAliases := 0
for _, test := range paramAliasNewIMCases {
+22 -3
View File
@@ -16,12 +16,12 @@ import (
)
const (
publicShortcutCount = 434
publicShortcutCount = 435
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
// including the hidden historical minutes.shortcut_minutes_search contract.
schemaPublishedShortcutCount = 437
schemaPublishedShortcutCount = 438
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
publiclyDeliveredShortcutCount = 434
publiclyDeliveredShortcutCount = 435
)
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
@@ -140,6 +140,25 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
assertChatCatalogCompleteLeafContracts(t)
}
func TestCrossPlatformCoverageAITableTableBootstrapPublishesResultContract(t *testing.T) {
leaf := executeShortcutSchemaQuery(t, "--cli-path", "aitable +table-bootstrap")
result, _ := leaf["result"].(map[string]any)
if got, want := schemaContractStringSlice(result["outcomes"]), []string{"success", "failure"}; !schemaContractJSONEqual(got, want) {
t.Fatalf("aitable +table-bootstrap outcomes = %#v, want %#v", got, want)
}
dataSchema, _ := result["data_schema"].(map[string]any)
properties := schemaContractMap(dataSchema["properties"])
status := properties["status"]
if got, want := schemaContractStringSlice(status["enum"]), []string{"success", "planned", "partial_success", "unknown"}; !schemaContractJSONEqual(got, want) {
t.Fatalf("aitable +table-bootstrap status enum = %#v, want %#v", got, want)
}
for _, property := range []string{"contractVersion", "operation", "executed", "retryable", "plan", "completedSteps", "verification", "checkpoint", "knownSideEffects", "result"} {
if properties[property] == nil {
t.Errorf("aitable +table-bootstrap final Result data_schema is missing %q", property)
}
}
}
func TestDeliveryWikiSpaceSearchDeclaresCompatibilityAdapter(t *testing.T) {
leaf := executeShortcutSchemaQuery(t, "--cli-path", "wiki +space-search")
if got := schemaContractString(leaf["interface_mode"]); got != "composite" {
+481
View File
@@ -1773,6 +1773,439 @@ var generatedParamAliases = []ParamAliasEntry{
},
Blocked: []string{"at-user-ids", "staff-id", "uid", "user", "user-id", "userid"},
},
{
CLIPath: "calendar +agenda",
Aliases: map[string]string{
"begin": "start",
"calendar": "calendar-id",
"calendar-book-id": "calendar-id",
"end-date": "end",
"end-time": "end",
"from": "start",
"from-date": "start",
"max-result": "limit",
"max-results": "limit",
"max-time": "end",
"min-time": "start",
"next-cursor": "cursor",
"next-page-token": "cursor",
"next-token": "cursor",
"page-size": "limit",
"page-token": "cursor",
"per-page": "limit",
"since": "start",
"size": "limit",
"start-date": "start",
"start-time": "start",
"take": "limit",
"time-max": "end",
"time-min": "start",
"to": "end",
"top": "limit",
},
Blocked: []string{"acl-id", "count", "date", "event", "event-id", "id", "offset", "page", "room-id", "time"},
},
{
CLIPath: "calendar +attendee-list",
Aliases: map[string]string{
"calendar": "calendar-id",
"calendar-book-id": "calendar-id",
"calendar-event-id": "event",
"event-id": "event",
},
Blocked: []string{"acl-id", "room-id"},
Ambiguous: []string{"id"},
},
{
CLIPath: "calendar +book",
Aliases: map[string]string{
"attendee-names": "with",
"begin": "start",
"end-time": "end",
"from": "start",
"names": "with",
"participant-names": "with",
"start-time": "start",
"subject": "title",
"summary": "title",
"to": "end",
},
Blocked: []string{"attendees", "calendar-id", "calendar-name", "date", "end-date", "name", "open-dingtalk-ids", "participants", "room-id", "room-ids", "room-name", "rooms", "start-date", "time", "time-max", "time-min", "user", "user-id", "user-ids", "users"},
},
{
CLIPath: "calendar +book-search",
Aliases: map[string]string{
"keyword": "query",
"keywords": "query",
"name": "query",
"q": "query",
"search": "query",
"search-word": "query",
},
Blocked: []string{"subject", "text", "title"},
},
{
CLIPath: "calendar +cancel-event",
Aliases: map[string]string{
"calendar-event-id": "event",
"event-id": "event",
"id": "event",
},
Blocked: []string{"acl-id", "calendar-book-id", "calendar-id", "room-id"},
},
{
CLIPath: "calendar +conflicts",
Aliases: map[string]string{
"day-offset": "in-days",
"days-from-today": "in-days",
},
Blocked: []string{"days", "duration", "end", "from", "start", "to"},
},
{
CLIPath: "calendar +create",
Aliases: map[string]string{
"attendee-ids": "attendees",
"begin": "start",
"calendar": "calendar-id",
"calendar-book-id": "calendar-id",
"description": "desc",
"end-time": "end",
"freebusy": "free-busy",
"from": "start",
"room-id": "rooms",
"room-ids": "rooms",
"start-time": "start",
"subject": "title",
"summary": "title",
"time-zone": "timezone",
"to": "end",
"tz": "timezone",
"user-ids": "attendees",
"users": "attendees",
},
Blocked: []string{"acl-id", "attendee-name", "attendee-names", "calendar-name", "config", "date", "end-date", "event", "event-id", "field-description", "group-id", "id", "locale", "name", "offset", "open-dingtalk-ids", "participant-name", "participant-names", "rich-text-desc", "room", "room-name", "start-date", "time", "time-max", "time-min", "utc-offset", "who", "with"},
},
{
CLIPath: "calendar +free",
Aliases: map[string]string{
"begin": "start",
"end-date": "end",
"end-time": "end",
"from": "start",
"from-date": "start",
"max-time": "end",
"min-time": "start",
"name": "who",
"person": "who",
"person-name": "who",
"since": "start",
"start-date": "start",
"start-time": "start",
"time-max": "end",
"time-min": "start",
"to": "end",
},
Blocked: []string{"date", "time", "user", "user-id", "user-ids", "users", "with"},
},
{
CLIPath: "calendar +free-slots",
Aliases: map[string]string{
"day-offset": "in-days",
"days-from-today": "in-days",
"end-hour": "to",
"start-hour": "from",
},
Blocked: []string{"days", "duration", "end", "end-time", "start", "start-time", "time-max", "time-min"},
},
{
CLIPath: "calendar +freebusy",
Aliases: map[string]string{
"begin": "start",
"end-date": "end",
"end-time": "end",
"from": "start",
"from-date": "start",
"max-time": "end",
"min-time": "start",
"room-id": "rooms",
"room-ids": "rooms",
"since": "start",
"start-date": "start",
"start-time": "start",
"time-max": "end",
"time-min": "start",
"to": "end",
"user-ids": "users",
},
Blocked: []string{"at-user-ids", "attendee-names", "date", "group-id", "location", "name", "names", "participant-names", "room", "room-name", "staff-id", "time", "uid", "user", "user-id", "userid", "who", "with"},
},
{
CLIPath: "calendar +get",
Aliases: map[string]string{
"calendar": "calendar-id",
"calendar-book-id": "calendar-id",
"calendar-event-id": "event",
"event-id": "event",
},
Blocked: []string{"acl-id", "room-id"},
Ambiguous: []string{"id"},
},
{
CLIPath: "calendar +invite",
Aliases: map[string]string{
"attendee-names": "with",
"calendar-event-id": "event",
"event-id": "event",
"id": "event",
"names": "with",
"participant-names": "with",
},
Blocked: []string{"acl-id", "attendees", "calendar-book-id", "calendar-id", "open-dingtalk-ids", "participants", "room-id", "user", "user-id", "user-ids", "users"},
},
{
CLIPath: "calendar +my-free",
Aliases: map[string]string{
"begin": "start",
"end-date": "end",
"end-time": "end",
"from": "start",
"from-date": "start",
"max-time": "end",
"min-time": "start",
"since": "start",
"start-date": "start",
"start-time": "start",
"time-max": "end",
"time-min": "start",
"to": "end",
},
Blocked: []string{"date", "time"},
},
{
CLIPath: "calendar +reschedule",
Aliases: map[string]string{
"begin": "start",
"calendar-event-id": "event",
"end-time": "end",
"event-id": "event",
"from": "start",
"id": "event",
"start-time": "start",
"to": "end",
},
Blocked: []string{"acl-id", "calendar-book-id", "calendar-id", "date", "end-date", "room-id", "start-date", "time", "time-max", "time-min"},
},
{
CLIPath: "calendar +room-find",
Aliases: map[string]string{
"begin": "start",
"end-date": "end",
"end-time": "end",
"from": "start",
"from-date": "start",
"group": "group-id",
"max-result": "limit",
"max-results": "limit",
"max-time": "end",
"min-time": "start",
"name": "room-name",
"page-index": "page",
"page-size": "limit",
"per-page": "limit",
"query": "room-name",
"room-group-id": "group-id",
"since": "start",
"size": "limit",
"start-date": "start",
"start-time": "start",
"take": "limit",
"time-max": "end",
"time-min": "start",
"to": "end",
"top": "limit",
},
Blocked: []string{"count", "cursor", "date", "location", "next-cursor", "page-token", "room", "room-id", "room-ids", "rooms", "time"},
},
{
CLIPath: "calendar +room-groups",
Aliases: map[string]string{
"max-result": "limit",
"max-results": "limit",
"page-index": "page",
"page-size": "limit",
"per-page": "limit",
"size": "limit",
"take": "limit",
"top": "limit",
},
Blocked: []string{"count", "cursor", "page-token"},
},
{
CLIPath: "calendar +room-search",
Aliases: map[string]string{
"name": "room-name",
"query": "room-name",
},
Blocked: []string{"group-id", "location", "room", "room-id", "room-ids", "rooms"},
},
{
CLIPath: "calendar +rsvp",
Aliases: map[string]string{
"calendar": "calendar-id",
"calendar-book-id": "calendar-id",
"calendar-event-id": "event",
"event-id": "event",
"response": "status",
"response-status": "status",
},
Blocked: []string{"acl-id", "availability", "done", "free-busy", "room-id", "state"},
Ambiguous: []string{"id"},
},
{
CLIPath: "calendar +search-event",
Aliases: map[string]string{
"begin": "start",
"calendar": "calendar-id",
"calendar-book-id": "calendar-id",
"end-date": "end",
"end-time": "end",
"from": "start",
"from-date": "start",
"keyword": "query",
"keywords": "query",
"max-result": "limit",
"max-results": "limit",
"max-time": "end",
"min-time": "start",
"next-cursor": "cursor",
"next-page-token": "cursor",
"next-token": "cursor",
"page-size": "limit",
"page-token": "cursor",
"per-page": "limit",
"q": "query",
"search": "query",
"search-word": "query",
"since": "start",
"size": "limit",
"start-date": "start",
"start-time": "start",
"take": "limit",
"time-max": "end",
"time-min": "start",
"to": "end",
"top": "limit",
},
Blocked: []string{"acl-id", "count", "date", "event", "event-id", "id", "name", "offset", "page", "page-index", "room-id", "subject", "text", "time", "title"},
},
{
CLIPath: "calendar +suggest-time",
Aliases: map[string]string{
"attendee-names": "with",
"begin": "start",
"duration-minutes": "duration",
"end-date": "end",
"end-time": "end",
"from": "start",
"from-date": "start",
"max-time": "end",
"meeting-duration-minutes": "duration",
"min-time": "start",
"names": "with",
"participant-names": "with",
"since": "start",
"start-date": "start",
"start-time": "start",
"time-max": "end",
"time-min": "start",
"to": "end",
},
Blocked: []string{"attendees", "date", "open-dingtalk-ids", "participants", "remind-minutes", "time", "user", "user-id", "user-ids", "users"},
},
{
CLIPath: "calendar +suggestion",
Aliases: map[string]string{
"begin": "start",
"duration-minutes": "duration",
"end-date": "end",
"end-time": "end",
"from": "start",
"from-date": "start",
"max-time": "end",
"meeting-duration-minutes": "duration",
"min-time": "start",
"since": "start",
"start-date": "start",
"start-time": "start",
"time-max": "end",
"time-min": "start",
"time-zone": "timezone",
"to": "end",
"tz": "timezone",
"user-ids": "users",
},
Blocked: []string{"at-user-ids", "attendee-name", "attendee-names", "date", "locale", "name", "names", "offset", "open-dingtalk-ids", "participant-name", "participant-names", "remind-minutes", "room-id", "room-ids", "room-name", "rooms", "staff-id", "time", "uid", "user", "user-id", "userid", "utc-offset", "who", "with"},
},
{
CLIPath: "calendar +update",
Aliases: map[string]string{
"add-user-ids": "add-attendees",
"add-users": "add-attendees",
"begin": "start",
"calendar": "calendar-id",
"calendar-book-id": "calendar-id",
"calendar-event-id": "event",
"description": "desc",
"end-time": "end",
"event-id": "event",
"freebusy": "free-busy",
"from": "start",
"remove-user-ids": "remove-attendees",
"remove-users": "remove-attendees",
"start-time": "start",
"subject": "title",
"summary": "title",
"time-zone": "timezone",
"to": "end",
"tz": "timezone",
},
Blocked: []string{"acl-id", "attendee-name", "attendee-names", "calendar-name", "config", "date", "end-date", "field-description", "group-id", "locale", "name", "offset", "open-dingtalk-ids", "participant-name", "participant-names", "remind-minutes", "reminder-minutes", "rich-text-desc", "room-id", "room-ids", "room-name", "rooms", "start-date", "time", "time-max", "time-min", "utc-offset", "who", "with"},
Ambiguous: []string{"attendees", "id", "user-ids", "users"},
},
{
CLIPath: "calendar acl delete",
Blocked: []string{"calendar-book-id", "calendar-id", "event", "event-id", "room-id", "user-id"},
},
{
CLIPath: "calendar attachment add",
Blocked: []string{"attachments", "file", "file-id", "file-ids"},
},
{
CLIPath: "calendar attendee add",
Blocked: []string{"attendee-name", "attendee-names", "open-dingtalk-ids", "participant-name", "participant-names", "who", "with"},
},
{
CLIPath: "calendar attendee delete",
Blocked: []string{"attendee-name", "attendee-names", "open-dingtalk-ids", "participant-name", "participant-names", "who", "with"},
},
{
CLIPath: "calendar busy search",
Aliases: map[string]string{
"room-id": "rooms",
},
Blocked: []string{"attendee-name", "attendee-names", "group-id", "location", "name", "names", "participant-names", "room", "room-name", "who", "with"},
},
{
CLIPath: "calendar event create",
Aliases: map[string]string{
"reminder-minutes": "remind-minutes",
"reminder-offset-minutes": "remind-minutes",
"room-id": "rooms",
"time-zone": "timezone",
"tz": "timezone",
},
Blocked: []string{"at", "attendee-name", "attendee-names", "due", "duration", "group-id", "locale", "offset", "participant-name", "participant-names", "remind-at", "reminder-time", "room", "room-name", "utc-offset", "who", "with"},
},
{
CLIPath: "calendar event list",
Aliases: map[string]string{
@@ -1789,6 +2222,54 @@ var generatedParamAliases = []ParamAliasEntry{
},
Blocked: []string{"offset", "page", "time"},
},
{
CLIPath: "calendar event respond",
Aliases: map[string]string{
"response": "status",
"response-status": "status",
},
Blocked: []string{"availability", "done", "free-busy", "state"},
},
{
CLIPath: "calendar event suggest",
Aliases: map[string]string{
"duration-minutes": "duration",
"meeting-duration-minutes": "duration",
"time-zone": "timezone",
"tz": "timezone",
},
Blocked: []string{"attendee-name", "attendee-names", "from", "locale", "name", "names", "offset", "open-dingtalk-ids", "participant-names", "remind-minutes", "to", "utc-offset", "who", "with"},
},
{
CLIPath: "calendar event update",
Aliases: map[string]string{
"time-zone": "timezone",
"tz": "timezone",
},
Blocked: []string{"attendees", "group-id", "locale", "offset", "participants", "remind-minutes", "reminder-minutes", "room-id", "room-ids", "room-name", "rooms", "utc-offset"},
},
{
CLIPath: "calendar room add",
Aliases: map[string]string{
"room-id": "rooms",
},
Blocked: []string{"group-id", "location", "room", "room-name"},
},
{
CLIPath: "calendar room delete",
Aliases: map[string]string{
"room-id": "rooms",
},
Blocked: []string{"group-id", "location", "room", "room-name"},
},
{
CLIPath: "calendar room search",
Aliases: map[string]string{
"group": "group-id",
"room-group-id": "group-id",
},
Blocked: []string{"location", "room", "room-id", "room-ids", "rooms"},
},
{
CLIPath: "chat +bot-find",
Aliases: map[string]string{
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1058,7 +1058,7 @@ var schemaCompactPayloadKeys = map[string]bool{
"agent_summary": true, "description": true,
"effect": true, "risk": true, "confirmation": true, "idempotency": true,
"interface_mode": true, "availability": true, "interface_reason": true,
"parameters": true, "constraints": true, "positionals": true, "dry_run": true,
"parameters": true, "constraints": true, "positionals": true, "dry_run": true, "wait": true,
"result": true, "pagination": true,
"examples": true, "use_when": true, "avoid_when": true,
}
+1
View File
@@ -81,6 +81,7 @@ var schemaCatalogToolOptionalKeys = []string{
"pagination",
"positionals",
"result",
"wait",
}
var schemaCatalogToolEnums = map[string][]string{
+21
View File
@@ -60,6 +60,7 @@ type ToolSpec struct {
Constraints RuntimeSchemaConstraints
Positionals []contract.RuntimeSchemaPositional
DryRun *contract.DryRunSpec
Wait *contract.WaitSpec
Result *contract.ResultSpec
Pagination *contract.PaginationSpec
Safety contract.SafetySpec
@@ -135,6 +136,7 @@ type RuntimeToolSpecInput struct {
Constraints RuntimeSchemaConstraints
Positionals []contract.RuntimeSchemaPositional
DryRun *contract.DryRunSpec
Wait *contract.WaitSpec
Result *contract.ResultSpec
Pagination *contract.PaginationSpec
Safety contract.SafetySpec
@@ -542,6 +544,11 @@ func (t ToolSpec) Validate() error {
return err
}
}
if t.Wait != nil {
if err := t.Wait.Validate(id.CanonicalPath); err != nil {
return err
}
}
if t.Result != nil {
if _, err := contract.NormalizeResultSpec(t.Result, id.CanonicalPath); err != nil {
return err
@@ -744,6 +751,16 @@ func (t ToolSpec) normalized() ToolSpec {
dryRun.PreviewKind = strings.TrimSpace(dryRun.PreviewKind)
out.DryRun = &dryRun
}
if t.Wait != nil {
// NormalizeWaitSpec is the single canonical form shared with the
// declaration path: trimmed status values, duplicate/conflict
// rejection, defensive copy. Invalid declarations are rejected by
// ToolSpec.Validate below, which runs the same normalization
// through WaitSpec.Validate.
if wait, err := contract.NormalizeWaitSpec(t.Wait, id.CanonicalPath); err == nil {
out.Wait = wait
}
}
if t.Result != nil {
result, err := contract.NormalizeResultSpec(t.Result, id.CanonicalPath)
if err == nil {
@@ -982,6 +999,10 @@ func (t ToolSpec) ToPayload() (map[string]any, error) {
value, _ := typedJSONValue(t.DryRun)
payload["dry_run"] = value
}
if t.Wait != nil {
value, _ := typedJSONValue(t.Wait)
payload["wait"] = value
}
if t.Result != nil {
value, _ := typedJSONValue(t.Result)
payload["result"] = value
@@ -974,3 +974,57 @@ func TestFinalProvenanceCoverageDoesNotInventOptionalInterfaceReason(t *testing.
t.Fatalf("optional local interface_reason should not require invented provenance: %v", err)
}
}
func TestToolSpecWaitCapabilityIsPositiveOnly(t *testing.T) {
base := RuntimeToolSpecInput{Identity: contract.ToolIdentitySpec{
ProductID: "sample",
Name: "waitrun",
CLIName: "waitrun",
CLIPath: "sample waitrun",
}}
withoutCapability, err := ToolSpecFromRuntime(base)
if err != nil {
t.Fatalf("ToolSpecFromRuntime() error = %v", err)
}
payload, err := withoutCapability.ToPayload()
if err != nil {
t.Fatalf("ToPayload() error = %v", err)
}
if _, ok := payload["wait"]; ok {
t.Fatalf("nil capability unexpectedly emitted wait: %#v", payload["wait"])
}
base.Wait = &contract.WaitSpec{Mode: "webhook"}
if _, err := ToolSpecFromRuntime(base); err == nil || !strings.Contains(err.Error(), "unknown mode") {
t.Fatalf("invalid mode error = %v", err)
}
base.Wait = &contract.WaitSpec{Mode: contract.WaitModeEvent, StatusQuery: "status", Terminal: map[string]contract.ResultOutcome{"DONE": contract.ResultOutcomeSuccess}}
if _, err := ToolSpecFromRuntime(base); err == nil || !strings.Contains(err.Error(), "requires event_key") {
t.Fatalf("event mode body error = %v", err)
}
base.Wait = &contract.WaitSpec{
Mode: contract.WaitModePoll,
PollCommand: "sample status get",
StatusQuery: "result.status",
Terminal: map[string]contract.ResultOutcome{"COMPLETED": contract.ResultOutcomeSuccess},
PendingValues: []string{"NEW"},
DefaultTimeoutSecs: 120,
}
withCapability, err := ToolSpecFromRuntime(base)
if err != nil {
t.Fatalf("ToolSpecFromRuntime(valid wait) error = %v", err)
}
if withCapability.Wait == nil || withCapability.Wait.Mode != contract.WaitModePoll {
t.Fatalf("wait capability lost through normalization: %#v", withCapability.Wait)
}
payload, err = withCapability.ToPayload()
if err != nil {
t.Fatalf("ToPayload(valid wait) error = %v", err)
}
wait := payload["wait"].(map[string]any)
if wait["mode"] != contract.WaitModePoll || wait["poll_command"] != "sample status get" {
t.Fatalf("wait payload=%#v", wait)
}
}
+1
View File
@@ -354,6 +354,7 @@ func runtimeToolSpecFromContractFinal(entry runtimeSchemaEntry, final contract.C
Constraints: constraints,
Positionals: positionals,
DryRun: final.DryRun,
Wait: final.Wait,
Result: result,
Pagination: pagination,
Safety: safety,
+2
View File
@@ -65,6 +65,7 @@ type schemaToolWire struct {
Constraints RuntimeSchemaConstraints `json:"constraints"`
Positionals []contract.RuntimeSchemaPositional `json:"positionals"`
DryRun *contract.DryRunSpec `json:"dry_run"`
Wait *contract.WaitSpec `json:"wait"`
Result *contract.ResultSpec `json:"result"`
Pagination *contract.PaginationSpec `json:"pagination"`
Effect string `json:"effect"`
@@ -269,6 +270,7 @@ func schemaToolSpecFromWire(wire schemaToolWire) (ToolSpec, error) {
Constraints: wire.Constraints,
Positionals: wire.Positionals,
DryRun: wire.DryRun,
Wait: wire.Wait,
Result: wire.Result,
Pagination: wire.Pagination,
Safety: contract.SafetySpec{
+1
View File
@@ -30,6 +30,7 @@ type ContractFinalPayload struct {
Parameters []ParamDecl
Safety *SafetySpec
DryRun *DryRunSpec
Wait *WaitSpec
Result *ResultSpec
Pagination *PaginationSpec
Interface *InterfaceSpec
+149
View File
@@ -62,6 +62,155 @@ type DryRunSpec struct {
RemoteReads bool `json:"remote_reads,omitempty"`
}
// Wait modes. Poll executes the leaf's WaitPoll hook on a cadence. Event
// consumes the leaf's WaitEvents push stream and correlates events to the
// accepted resource. Auto prefers the event stream and falls back to polling
// when the stream ends before a terminal status.
const (
WaitModePoll = "poll"
WaitModeEvent = "event"
WaitModeAuto = "auto"
)
// WaitSpec is a positive capability declaration for terminal-state waiting
// (approval flows, async exports, batch jobs). A nil ToolSpec.Wait means the
// command has not declared reviewed --wait support; the flag is not
// registered and the Schema does not publish the capability.
//
// Like DryRunSpec, the object is one atomic contract field: Schema only
// projects the reviewed capability; runtime execution stays owned by the
// command runner through the leaf's WaitPoll / WaitEvents hooks. PollCommand
// names the read command that observes status — it is a declared,
// catalog-visible fact (the same command an agent would poll manually), not
// a framework-owned invocation: how one poll or event subscription executes
// is decided by the leaf.
type WaitSpec struct {
Mode string `json:"mode"`
PollCommand string `json:"poll_command,omitempty"`
StatusQuery string `json:"status_query"`
Terminal map[string]ResultOutcome `json:"terminal"`
PendingValues []string `json:"pending_values,omitempty"`
// EventKey is the push channel key the WaitEvents hook subscribes to
// (event/auto modes). Declared for the catalog; the transport stays
// leaf-owned.
EventKey string `json:"event_key,omitempty"`
// MatchField is the event-document path holding the resource identifier
// (event/auto modes); its value must equal the ResourceQuery resolution
// of the accepted result.
MatchField string `json:"match_field,omitempty"`
// ResourceQuery is the dotted path into the accepted result data that
// yields the resource identifier correlated against MatchField
// (event/auto modes).
ResourceQuery string `json:"resource_query,omitempty"`
// DefaultTimeoutSecs is the reviewed default for --wait-timeout. Zero
// means the framework default (300s); the user flag always wins.
DefaultTimeoutSecs int `json:"default_timeout_secs"`
}
// Validate checks mode requirements and the terminal/pending status maps.
// Unknown terminal outcomes, unknown modes, and mode/body mismatches fail at
// declaration so a malformed wait capability cannot reach the wire.
// Validation delegates to NormalizeWaitSpec so the acceptance rules can never
// drift from the normalization the wire and the runtime wait engine share.
func (w WaitSpec) Validate(canonical string) error {
_, err := NormalizeWaitSpec(&w, canonical)
return err
}
// NormalizeWaitSpec returns a validated, canonical, defensively copied wait
// contract. It is shared by declaration (corecmd.New / AttachContract),
// ToolSpec, and snapshot paths, mirroring NormalizeResultSpec. Status values
// are trimmed into their wire form: the wait engine compares backend
// statuses verbatim against these tables, so a padded declaration
// (" processing ") would publish a Schema that its own runtime treats as an
// unknown status. Values collapsing onto one value after trimming (duplicate
// pending values, duplicate terminal keys, terminal/pending conflicts) are
// rejected instead of silently merged.
func NormalizeWaitSpec(in *WaitSpec, canonical string) (*WaitSpec, error) {
if in == nil {
return nil, nil
}
canonical = defaultString(strings.TrimSpace(canonical), "<unknown>")
out := &WaitSpec{
Mode: strings.TrimSpace(in.Mode),
PollCommand: strings.TrimSpace(in.PollCommand),
StatusQuery: strings.TrimSpace(in.StatusQuery),
EventKey: strings.TrimSpace(in.EventKey),
MatchField: strings.TrimSpace(in.MatchField),
ResourceQuery: strings.TrimSpace(in.ResourceQuery),
DefaultTimeoutSecs: in.DefaultTimeoutSecs,
}
if out.Mode == "" {
return nil, fmt.Errorf("schema tool %s wait has no mode", canonical)
}
switch out.Mode {
case WaitModePoll, WaitModeEvent, WaitModeAuto:
default:
return nil, fmt.Errorf("schema tool %s wait has unknown mode %q", canonical, out.Mode)
}
needsPoll := out.Mode == WaitModePoll || out.Mode == WaitModeAuto
if needsPoll && out.PollCommand == "" {
return nil, fmt.Errorf("schema tool %s wait mode %s requires poll_command", canonical, out.Mode)
}
needsEvent := out.Mode == WaitModeEvent || out.Mode == WaitModeAuto
if needsEvent {
if out.EventKey == "" {
return nil, fmt.Errorf("schema tool %s wait mode %s requires event_key", canonical, out.Mode)
}
if out.MatchField == "" {
return nil, fmt.Errorf("schema tool %s wait mode %s requires match_field", canonical, out.Mode)
}
if out.ResourceQuery == "" {
return nil, fmt.Errorf("schema tool %s wait mode %s requires resource_query", canonical, out.Mode)
}
}
if out.StatusQuery == "" {
return nil, fmt.Errorf("schema tool %s wait mode %s requires status_query", canonical, out.Mode)
}
if len(in.Terminal) == 0 {
return nil, fmt.Errorf("schema tool %s wait has no terminal states", canonical)
}
out.Terminal = make(map[string]ResultOutcome, len(in.Terminal))
for status, outcome := range in.Terminal {
status = strings.TrimSpace(status)
if status == "" {
return nil, fmt.Errorf("schema tool %s wait has a blank terminal status", canonical)
}
if _, dup := out.Terminal[status]; dup {
return nil, fmt.Errorf("schema tool %s wait has duplicate terminal status %q", canonical, status)
}
// Terminal states must close into success or failure. Pending and
// partial are not wait outcomes: pending is expressed through
// timeout, and partial requires the typed multi-status payload only
// the leaf can construct.
if outcome != ResultOutcomeSuccess && outcome != ResultOutcomeFailure {
return nil, fmt.Errorf(
"schema tool %s wait terminal status %q must map to success or failure, got %q",
canonical, status, outcome)
}
out.Terminal[status] = outcome
}
seenPending := make(map[string]bool, len(in.PendingValues))
for _, value := range in.PendingValues {
value = strings.TrimSpace(value)
if value == "" {
return nil, fmt.Errorf("schema tool %s wait has a blank pending value", canonical)
}
if _, conflict := out.Terminal[value]; conflict {
return nil, fmt.Errorf("schema tool %s wait status %q is both terminal and pending", canonical, value)
}
if seenPending[value] {
return nil, fmt.Errorf("schema tool %s wait has duplicate pending value %q", canonical, value)
}
seenPending[value] = true
out.PendingValues = append(out.PendingValues, value)
}
if in.DefaultTimeoutSecs < 0 {
return nil, fmt.Errorf("schema tool %s wait default_timeout_secs must be >= 0", canonical)
}
return out, nil
}
// ResultOutcome is one closed unified-output envelope outcome.
type ResultOutcome string
+227
View File
@@ -0,0 +1,227 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package contract
import "testing"
func TestWaitSpecValidateAcceptsReviewedShapes(t *testing.T) {
cases := []WaitSpec{
{
Mode: WaitModePoll,
PollCommand: "oa approval-instance get",
StatusQuery: "result.status",
Terminal: map[string]ResultOutcome{
"COMPLETED": ResultOutcomeSuccess,
"REJECTED": ResultOutcomeFailure,
},
PendingValues: []string{"NEW", "RUNNING"},
DefaultTimeoutSecs: 600,
},
}
for i, spec := range cases {
if err := spec.Validate("sample.tool"); err != nil {
t.Fatalf("case %d: unexpected error: %v", i, err)
}
}
}
func TestWaitSpecValidateRejectsMalformedShapes(t *testing.T) {
terminal := map[string]ResultOutcome{"COMPLETED": ResultOutcomeSuccess}
cases := map[string]WaitSpec{
"no mode": {
Terminal: terminal,
},
"unknown mode": {
Mode: "webhook",
Terminal: terminal,
},
"poll without poll_command": {
Mode: WaitModePoll,
StatusQuery: "status",
Terminal: terminal,
},
"poll without status_query": {
Mode: WaitModePoll,
PollCommand: "oa approval-instance get",
Terminal: terminal,
},
"event without event_key": {
Mode: WaitModeEvent,
MatchField: "process_instance_id",
ResourceQuery: "id",
StatusQuery: "result.status",
Terminal: terminal,
},
"event without match_field": {
Mode: WaitModeEvent,
EventKey: "bpms_instance_change",
ResourceQuery: "id",
StatusQuery: "result.status",
Terminal: terminal,
},
"event without resource_query": {
Mode: WaitModeEvent,
EventKey: "bpms_instance_change",
MatchField: "process_instance_id",
StatusQuery: "result.status",
Terminal: terminal,
},
"auto missing poll_command": {
Mode: WaitModeAuto,
EventKey: "export_finished",
MatchField: "job_id",
ResourceQuery: "job_id",
StatusQuery: "status",
Terminal: terminal,
},
"no terminal states": {
Mode: WaitModePoll,
PollCommand: "oa approval-instance get",
StatusQuery: "status",
},
"blank terminal status": {
Mode: WaitModePoll,
PollCommand: "oa approval-instance get",
StatusQuery: "status",
Terminal: map[string]ResultOutcome{" ": ResultOutcomeSuccess},
},
"terminal outcome pending": {
Mode: WaitModePoll,
PollCommand: "oa approval-instance get",
StatusQuery: "status",
Terminal: map[string]ResultOutcome{"COMPLETED": ResultOutcomePending, "REJECTED": ResultOutcomeFailure},
},
"terminal outcome partial": {
Mode: WaitModePoll,
PollCommand: "oa approval-instance get",
StatusQuery: "status",
Terminal: map[string]ResultOutcome{"COMPLETED": ResultOutcomePartialFailure, "REJECTED": ResultOutcomeFailure},
},
"terminal outcome outside closed set": {
Mode: WaitModePoll,
PollCommand: "oa approval-instance get",
StatusQuery: "status",
Terminal: map[string]ResultOutcome{"COMPLETED": ResultOutcome("explosion")},
},
"only pending terminal outcome": {
Mode: WaitModePoll,
PollCommand: "oa approval-instance get",
StatusQuery: "status",
Terminal: map[string]ResultOutcome{"NEW": ResultOutcomePending},
},
"status both terminal and pending": {
Mode: WaitModePoll,
PollCommand: "oa approval-instance get",
StatusQuery: "status",
Terminal: terminal,
PendingValues: []string{"COMPLETED"},
},
"blank pending value": {
Mode: WaitModePoll,
PollCommand: "oa approval-instance get",
StatusQuery: "status",
Terminal: terminal,
PendingValues: []string{" "},
},
"negative timeout default": {
Mode: WaitModePoll,
PollCommand: "oa approval-instance get",
StatusQuery: "status",
Terminal: terminal,
DefaultTimeoutSecs: -1,
},
}
for name, spec := range cases {
if err := spec.Validate("sample.tool"); err == nil {
t.Fatalf("%s: expected error, got nil", name)
}
}
}
func TestNormalizeWaitSpecTrimsStatusValuesIntoWireForm(t *testing.T) {
in := &WaitSpec{
Mode: " poll ",
PollCommand: " oa approval-instance get ",
StatusQuery: " result.status ",
Terminal: map[string]ResultOutcome{" COMPLETED ": ResultOutcomeSuccess, "REJECTED": ResultOutcomeFailure},
PendingValues: []string{" NEW ", "RUNNING"},
DefaultTimeoutSecs: 60,
}
out, err := NormalizeWaitSpec(in, "sample.tool")
if err != nil {
t.Fatalf("NormalizeWaitSpec() error = %v", err)
}
if out.Mode != WaitModePoll || out.PollCommand != "oa approval-instance get" || out.StatusQuery != "result.status" {
t.Fatalf("normalized scalars: %#v", out)
}
if len(out.Terminal) != 2 {
t.Fatalf("terminal=%#v, want two trimmed keys", out.Terminal)
}
if got := out.Terminal["COMPLETED"]; got != ResultOutcomeSuccess {
t.Fatalf("terminal[COMPLETED]=%q, want success (key must be trimmed)", got)
}
if _, padded := out.Terminal[" COMPLETED "]; padded {
t.Fatal("padded terminal key survived normalization")
}
for i, want := range []string{"NEW", "RUNNING"} {
if out.PendingValues[i] != want {
t.Fatalf("pending[%d]=%q, want %q", i, out.PendingValues[i], want)
}
}
// The input declaration must stay untouched (defensive copy).
if _, padded := in.Terminal[" COMPLETED "]; !padded {
t.Fatal("NormalizeWaitSpec mutated its input terminal map")
}
if in.PendingValues[0] != " NEW " {
t.Fatal("NormalizeWaitSpec mutated its input pending values")
}
}
func TestNormalizeWaitSpecRejectsDuplicatesAndConflictsAfterTrim(t *testing.T) {
terminal := map[string]ResultOutcome{"COMPLETED": ResultOutcomeSuccess}
cases := map[string]*WaitSpec{
"terminal keys collapsing after trim": {
Mode: WaitModePoll,
PollCommand: "oa approval-instance get",
StatusQuery: "status",
Terminal: map[string]ResultOutcome{"COMPLETED": ResultOutcomeSuccess, " COMPLETED ": ResultOutcomeFailure},
},
"pending values collapsing after trim": {
Mode: WaitModePoll,
PollCommand: "oa approval-instance get",
StatusQuery: "status",
Terminal: terminal,
PendingValues: []string{"NEW", " NEW "},
},
"terminal/pending conflict hidden by padding": {
Mode: WaitModePoll,
PollCommand: "oa approval-instance get",
StatusQuery: "status",
Terminal: terminal,
PendingValues: []string{" COMPLETED "},
},
}
for name, spec := range cases {
if _, err := NormalizeWaitSpec(spec, "sample.tool"); err == nil {
t.Fatalf("%s: expected error, got nil", name)
}
}
}
func TestNormalizeWaitSpecNilReturnsNil(t *testing.T) {
out, err := NormalizeWaitSpec(nil, "sample.tool")
if err != nil || out != nil {
t.Fatalf("NormalizeWaitSpec(nil) = %#v, %v", out, err)
}
}
+4
View File
@@ -42,6 +42,7 @@ type ContractDecl struct {
Positionals []contract.RuntimeSchemaPositional
Parameters []contract.ParamDecl
DryRun *contract.DryRunSpec
Wait *contract.WaitSpec
Result *contract.ResultSpec
Pagination *contract.PaginationSpec
Interface *contract.InterfaceSpec
@@ -146,6 +147,9 @@ func (s ContractDecl) empty() bool {
if s.DryRun != nil && strings.TrimSpace(s.DryRun.PreviewKind) != "" {
return false
}
if s.Wait != nil && strings.TrimSpace(s.Wait.Mode) != "" {
return false
}
if s.Result != nil {
return false
}
@@ -200,6 +200,13 @@ func TestFrameworkContractFinalDeepCopyAndSafetyConflicts(t *testing.T) {
Parameters: []contract.ParamDecl{{Name: "mode", Enum: []string{"a"}, Required: boolPointer(true)}},
Safety: &contract.SafetySpec{Effect: " read ", EffectSource: " source ", Risk: " low ", Confirmation: " not_required ", Idempotency: " idempotent "},
DryRun: &contract.DryRunSpec{PreviewKind: "plan"},
Wait: &contract.WaitSpec{
Mode: contract.WaitModePoll,
PollCommand: "oa approval-instance get",
StatusQuery: "result.status",
Terminal: map[string]contract.ResultOutcome{"COMPLETED": contract.ResultOutcomeSuccess, "REJECTED": contract.ResultOutcomeFailure},
PendingValues: []string{"NEW"},
},
Result: &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess},
DataSchema: []byte(`{"type":"object"}`), SensitivePaths: []string{"token"},
@@ -218,6 +225,8 @@ func TestFrameworkContractFinalDeepCopyAndSafetyConflicts(t *testing.T) {
if !ok || got.Result == payload.Result || got.Pagination == payload.Pagination || got.Interface == payload.Interface || got.Selection == payload.Selection || got.Identity == payload.Identity {
t.Fatalf("payload not deeply cloned: %#v", got)
}
payload.Wait.Terminal["COMPLETED"] = contract.ResultOutcomeFailure
payload.Wait.PendingValues[0] = "mutated"
payload.Parameters[0].Enum[0] = "changed"
*payload.Parameters[0].Required = false
*payload.Selection.ExampleDispositions[0].Index = 9
@@ -226,6 +235,10 @@ func TestFrameworkContractFinalDeepCopyAndSafetyConflicts(t *testing.T) {
if again.Parameters[0].Enum[0] != "a" || !*again.Parameters[0].Required || *again.Selection.ExampleDispositions[0].Index != 1 || !*again.Selection.Reviewed {
t.Fatalf("stored payload aliased input: %#v", again)
}
if again.Wait == payload.Wait || again.Wait.Terminal["COMPLETED"] != contract.ResultOutcomeSuccess || again.Wait.PendingValues[0] != "NEW" {
t.Fatalf("wait spec aliased input: %#v", again.Wait)
t.Fatalf("stored payload aliased input: %#v", again)
}
matching := &cobra.Command{Use: "matching"}
t.Cleanup(func() { ClearRuntimeContractFinalForTest(matching) })
+9
View File
@@ -74,6 +74,15 @@ func cloneContractFinalPayload(in contract.ContractFinalPayload) contract.Contra
value := *in.DryRun
out.DryRun = &value
}
if in.Wait != nil {
value := *in.Wait
value.Terminal = make(map[string]contract.ResultOutcome, len(in.Wait.Terminal))
for status, outcome := range in.Wait.Terminal {
value.Terminal[status] = outcome
}
value.PendingValues = cloneSlice(in.Wait.PendingValues)
out.Wait = &value
}
if in.Result != nil {
value := *in.Result
value.Outcomes = cloneSlice(in.Result.Outcomes)
+330
View File
@@ -49,12 +49,16 @@ package corecmd
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"os"
"strconv"
"strings"
"time"
"github.com/mattn/go-isatty"
"github.com/spf13/cobra"
@@ -64,6 +68,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
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/wait"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
)
@@ -282,6 +287,21 @@ type Spec struct {
// Orchestrate executes a multi-step command; it assembles whatever payloads
// it needs from the Ctx.
Orchestrate func(c *Ctx) error
// WaitPoll executes one poll of the declared Contract.Wait capability.
// Exactly one poll is one call; cadence, status extraction, and outcome
// mapping belong to the framework wait phase. Required for poll/auto
// declarations — a declared capability without a runtime implementation
// can never honor --wait, so New rejects the pairing at construction.
// ctx is the wait-phase deadline (--wait-timeout); leaf I/O must honor
// it so a blocked poll cannot outlive the declared timeout.
WaitPoll func(ctx context.Context, c *Ctx) (wait.PollDoc, error)
// WaitEvents opens the push subscription of the declared Contract.Wait
// capability (event/auto modes). The framework owns correlation and
// status mapping; the leaf owns the transport. Auto mode falls back to
// WaitPoll when the stream ends before a terminal status. ctx is the
// same wait-phase deadline as WaitPoll; subscription setup must honor
// it so --wait-timeout can cancel a blocked subscribe.
WaitEvents func(ctx context.Context, c *Ctx) (wait.EventStream, error)
}
// Ctx is the framework-neutral execution context handed to Invoke/Orchestrate.
@@ -355,6 +375,15 @@ func (c *Ctx) Changed(name string) bool { return c.cmd.Flags().Changed(name) }
// DryRun reports the effective global --dry-run.
func (c *Ctx) DryRun() bool { return BoolFlag(c.cmd, "dry-run") }
// Wait reports the effective --wait flag. It is false on commands that did
// not declare the capability: the flag is not registered there, so passing it
// is an unknown-flag error rather than a silently ignored value.
func (c *Ctx) Wait() bool { return BoolFlag(c.cmd, waitFlagName) }
// WaitTimeoutSecs reports the effective --wait-timeout in seconds (flag
// value, then the declared default, then the framework default).
func (c *Ctx) WaitTimeoutSecs() int { return waitTimeoutSecs(c.cmd) }
// Yes reports the effective global --yes.
func (c *Ctx) Yes() bool { return BoolFlag(c.cmd, "yes") }
@@ -374,6 +403,8 @@ func New(spec Spec) *cobra.Command {
validateDispatchDecl(spec)
validateSafetySpec(spec)
validateContractDecl(spec)
normalizeWaitDecl(&spec)
validateWaitDecl(spec)
validateInputSpecs(spec.Use, spec.Flags)
// Help prose inherits the declaration when not authored separately:
// Selection.Examples (already contract-validated against the real flags)
@@ -390,6 +421,7 @@ func New(spec Spec) *cobra.Command {
Hidden: spec.Hidden,
}
RegisterFlags(cmd, spec.Flags)
registerWaitFlags(cmd, spec)
ValidateConstraintDecls(spec.Use, spec.Flags, spec.Constraints)
embedContractIntoSchema(cmd, spec)
AnnotateConstraints(cmd, spec.Constraints)
@@ -455,6 +487,10 @@ func New(spec Spec) *cobra.Command {
if err != nil {
return err
}
result, err = runDeclaredWaitPhase(cmd, args, spec, result)
if err != nil {
return err
}
return output.StoreResult(cmd.Context(), result)
}
return spec.Invoke(ctx, toolArgs)
@@ -506,6 +542,293 @@ func runDeclaredPreflight(cmd *cobra.Command, args []string, spec Spec) error {
return nil
}
// Wait-phase framework flags. They are registered natively on the leaf (never
// as FlagSpec) so they cannot leak into toolArgs / MCP payloads: --wait is a
// client-side execution modifier, not a backend parameter. On commands that
// did not declare Contract.Wait the flags do not exist, so passing --wait
// fails as an unknown flag instead of being silently ignored.
const (
waitFlagName = "wait"
waitTimeoutFlagName = "wait-timeout"
defaultWaitTimeoutS = 300
)
// DefaultWaitTimeoutSecs is the framework default for --wait-timeout when the
// declaration carries no reviewed default.
const DefaultWaitTimeoutSecs = defaultWaitTimeoutS
// normalizeWaitDecl rewrites the spec's declared Wait in place with its
// canonical form (contract.NormalizeWaitSpec): trimmed status values,
// duplicate/conflict rejection, defensive copy. The runtime wait phase and
// AttachContract both read spec.Contract.Wait, so normalizing once at
// construction guarantees the wait engine, the Schema wire, and the
// registered ContractFinal payload all see identical status tables — a
// padded declaration can no longer publish a Schema its own runtime treats
// as unknown statuses. An invalid declaration panics here, next to the
// authoring mistake, with the same message Validate reports.
func normalizeWaitDecl(spec *Spec) {
decl := spec.Contract.Wait
if decl == nil || strings.TrimSpace(decl.Mode) == "" {
return
}
normalized, err := contract.NormalizeWaitSpec(decl, spec.Contract.Identity.CanonicalPath)
if err != nil {
panic(fmt.Sprintf("command %q has invalid Contract.Wait: %v", spec.Use, err))
}
spec.Contract.Wait = normalized
}
// validateWaitDecl enforces the declaration ⇄ implementation pairing at build
// time: a declared Contract.Wait without a WaitPoll hook is a capability the
// command can never honor, and a WaitPoll hook without the declaration has no
// flags or Schema capability to serve. The declaration also requires the
// ResultInvoke dispatcher: only the unified-result envelope can be closed
// into the terminal outcome (error.type "wait", exit code 8) and the timed-out
// pending form — legacy Invoke/Orchestrate/RunE paths emit their own output
// and would observe a failure terminal while still exiting 0. All three
// mismatches are programming errors.
func validateWaitDecl(spec Spec) {
decl := spec.Contract.Wait
declared := decl != nil && strings.TrimSpace(decl.Mode) != ""
if !declared {
if spec.WaitPoll != nil || spec.WaitEvents != nil {
panic(fmt.Sprintf(
"command %q sets a wait hook without declaring Contract.Wait: the wait flags and Schema capability come from the declaration",
spec.Use))
}
return
}
if spec.ResultInvoke == nil {
panic(fmt.Sprintf(
"command %q declares Contract.Wait without ResultInvoke: wait closes the unified-result envelope, which legacy Invoke/Orchestrate/RunE paths cannot rewrite",
spec.Use))
}
mode := strings.TrimSpace(decl.Mode)
needsPoll := mode == contract.WaitModePoll || mode == contract.WaitModeAuto
needsEvent := mode == contract.WaitModeEvent || mode == contract.WaitModeAuto
if needsPoll && spec.WaitPoll == nil {
panic(fmt.Sprintf(
"command %q declares wait mode %s but sets no WaitPoll: a declared wait capability must carry its runtime poll implementation",
spec.Use, mode))
}
if needsEvent && spec.WaitEvents == nil {
panic(fmt.Sprintf(
"command %q declares wait mode %s but sets no WaitEvents: a declared event wait must carry its runtime subscription",
spec.Use, mode))
}
if !needsPoll && spec.WaitPoll != nil {
panic(fmt.Sprintf(
"command %q declares wait mode %s but sets WaitPoll: the declaration decides which hooks run",
spec.Use, mode))
}
if !needsEvent && spec.WaitEvents != nil {
panic(fmt.Sprintf(
"command %q declares wait mode %s but sets WaitEvents: the declaration decides which hooks run",
spec.Use, mode))
}
}
// registerWaitFlags adds --wait / --wait-timeout to a leaf that declared
// Contract.Wait. The timeout default is the reviewed declaration, falling
// back to DefaultWaitTimeoutSecs.
func registerWaitFlags(cmd *cobra.Command, spec Spec) {
decl := spec.Contract.Wait
if decl == nil || strings.TrimSpace(decl.Mode) == "" {
return
}
cmd.Flags().Bool(waitFlagName, false,
"等待到达命令声明的终态(如审批完成、导出结束)后再返回;未声明该能力的命令不接受此 flag")
timeoutDefault := decl.DefaultTimeoutSecs
if timeoutDefault <= 0 {
timeoutDefault = DefaultWaitTimeoutSecs
}
cmd.Flags().Int(waitTimeoutFlagName, timeoutDefault,
"等待超时秒数;超时以 pending 结束(异步受理不是失败)")
}
// runDeclaredWaitPhase runs the declared wait loop after a successful
// ResultInvoke dispatch and closes the accepted unified envelope into the
// wait outcome (validateWaitDecl guarantees the ResultInvoke pairing).
// Only a pending accepted result is waitable: success / failure / partial
// are already terminal and must be returned unchanged. Waiting on a
// business failure would let WithOutcome(..., success) overwrite it into
// an illegal success-with-error envelope.
func runDeclaredWaitPhase(cmd *cobra.Command, args []string, spec Spec, result output.CommandResult) (output.CommandResult, error) {
if !BoolFlag(cmd, waitFlagName) {
return result, nil
}
if result == nil || result.Outcome() != output.OutcomePending {
return result, nil
}
decl := spec.Contract.Wait
timeout, err := waitTimeoutDuration(int64(waitTimeoutSecs(cmd)))
if err != nil {
return result, err
}
ctx := newCtx(cmd, args, spec.Flags)
outcome, err := runWaitLoop(cmd.Context(), decl, timeout, spec, ctx, result)
if err != nil {
return result, err
}
if outcome.TimedOut {
cmd.PrintErrf("等待超时(%s):当前状态 %q,未到达终态,以 pending 结束\n", timeout, outcome.Status)
return output.WithOutcome(result, output.OutcomePending,
output.WithOperationTimedOut(outcome.Status)), nil
}
if outcome.Outcome == contract.ResultOutcomeFailure {
return output.WithOutcome(result, output.OutcomeFailure,
output.WithOperationTerminalState(outcome.Status),
output.WithErrorInfo(&output.ErrorInfo{
Type: "wait",
Subtype: "terminal_failure",
Message: fmt.Sprintf("等待到达失败终态:%s", outcome.Status),
})), nil
}
return output.WithOutcome(result, output.OutcomeSuccess,
output.WithOperationTerminalState(outcome.Status)), nil
}
// runWaitLoop executes the declared wait mode. One deadline spans the event
// phase and an auto-mode poll fallback (the inner loops run without their
// own timeouts and inherit this context's deadline). The deadline is
// forwarded to WaitPoll / WaitEvents and bound onto the cobra command so
// leaf I/O that reads either the hook ctx or Command().Context() is
// cancelled when --wait-timeout expires.
func runWaitLoop(parent context.Context, decl *contract.WaitSpec, timeout time.Duration, spec Spec, ctx *Ctx, result output.CommandResult) (wait.Outcome, error) {
loopCtx := parent
if timeout > 0 {
var cancel context.CancelFunc
loopCtx, cancel = context.WithTimeout(parent, timeout)
defer cancel()
}
if ctx != nil && ctx.cmd != nil {
prev := ctx.cmd.Context()
ctx.cmd.SetContext(loopCtx)
defer ctx.cmd.SetContext(prev)
}
mode := strings.TrimSpace(decl.Mode)
if mode == contract.WaitModePoll {
return wait.Run(loopCtx, wait.LoopSpec{
StatusQuery: decl.StatusQuery,
Terminal: decl.Terminal,
Pending: decl.PendingValues,
}, func(pollCtx context.Context) (wait.PollDoc, error) {
return spec.WaitPoll(pollCtx, ctx)
})
}
resource, err := waitResource(decl, result)
if err != nil {
return wait.Outcome{}, err
}
stream, err := spec.WaitEvents(loopCtx, ctx)
if err != nil {
if loopCtx.Err() != nil {
// Subscribe blocked until the wait deadline: same contract as a
// cancelled poll — close as timed-out pending, do not surface
// ctx.Err() as a subscription failure (and do not poll-fallback
// in auto mode; the shared deadline is already exhausted).
return wait.Outcome{Outcome: contract.ResultOutcomePending, TimedOut: true}, nil
}
if mode == contract.WaitModeAuto {
// Subscription failed before any event: fall back to polling.
return pollWithSpec(loopCtx, decl, spec, ctx)
}
return wait.Outcome{}, fmt.Errorf("wait: event subscription failed: %w", err)
}
eventSpec := wait.EventLoopSpec{
StatusQuery: decl.StatusQuery,
MatchField: decl.MatchField,
Terminal: decl.Terminal,
Pending: decl.PendingValues,
}
outcome, err := wait.RunEvent(loopCtx, eventSpec, resource, stream)
if err == nil {
return outcome, nil
}
if mode == contract.WaitModeAuto && errors.Is(err, wait.ErrEventStreamEnded) {
// Stream ended before a terminal status: fall back to polling under
// the same deadline.
return pollWithSpec(loopCtx, decl, spec, ctx)
}
return outcome, err
}
// pollWithSpec runs the poll loop for an auto-mode fallback.
func pollWithSpec(loopCtx context.Context, decl *contract.WaitSpec, spec Spec, ctx *Ctx) (wait.Outcome, error) {
return wait.Run(loopCtx, wait.LoopSpec{
StatusQuery: decl.StatusQuery,
Terminal: decl.Terminal,
Pending: decl.PendingValues,
}, func(pollCtx context.Context) (wait.PollDoc, error) {
return spec.WaitPoll(pollCtx, ctx)
})
}
// waitResource resolves the resource identifier an event stream correlates
// against, from the accepted result data via the declared ResourceQuery.
// result.Data() returns any deep-copied business payload, which may be a
// map[string]any, struct, or struct pointer. We normalize via JSON round-trip
// to support all valid result types uniformly.
func waitResource(decl *contract.WaitSpec, result output.CommandResult) (string, error) {
raw := result.Data()
if raw == nil {
return "", fmt.Errorf("wait: accepted result data is nil; cannot resolve resource %q", decl.ResourceQuery)
}
// Fast path: already a map.
if data, ok := raw.(map[string]any); ok {
resource, ok := wait.ExtractStatus(wait.PollDoc(data), decl.ResourceQuery)
if !ok || strings.TrimSpace(resource) == "" {
return "", fmt.Errorf("wait: resource query %q not found in accepted result data", decl.ResourceQuery)
}
return resource, nil
}
// Slow path: struct or struct pointer. Normalize via JSON round-trip.
jsonBytes, err := json.Marshal(raw)
if err != nil {
return "", fmt.Errorf("wait: accepted result data cannot be serialized to JSON: %w", err)
}
var data map[string]any
if err := json.Unmarshal(jsonBytes, &data); err != nil {
return "", fmt.Errorf("wait: accepted result data is not an object; cannot resolve resource %q", decl.ResourceQuery)
}
resource, ok := wait.ExtractStatus(wait.PollDoc(data), decl.ResourceQuery)
if !ok || strings.TrimSpace(resource) == "" {
return "", fmt.Errorf("wait: resource query %q not found in accepted result data", decl.ResourceQuery)
}
return resource, nil
}
// waitTimeoutSecs resolves the effective timeout. The flag is registered
// with the reviewed declaration default (or the framework default), so the
// flag value is authoritative; a non-positive explicit value falls back to
// the framework default.
func waitTimeoutSecs(cmd *cobra.Command) int {
if value, err := cmd.Flags().GetInt(waitTimeoutFlagName); err == nil && value > 0 {
return value
}
return DefaultWaitTimeoutSecs
}
// maxWaitTimeoutSecs is the largest second count that still fits in a
// time.Duration. Multiplying a larger int by time.Second overflows to a
// non-positive duration, which would skip the deadline and wait forever.
const maxWaitTimeoutSecs = math.MaxInt64 / int64(time.Second)
// waitTimeoutDuration converts a resolved second count into the wait-phase
// deadline. Values that cannot be represented as a positive time.Duration
// are rejected as validation errors instead of silently disabling timeout.
func waitTimeoutDuration(secs int64) (time.Duration, error) {
if secs <= 0 {
secs = DefaultWaitTimeoutSecs
}
if secs > maxWaitTimeoutSecs {
return 0, apperrors.NewValidation(fmt.Sprintf(
"参数 --%s 取值 %d 超出可表示范围(最大 %d 秒)",
waitTimeoutFlagName, secs, maxWaitTimeoutSecs))
}
return time.Duration(secs) * time.Second, nil
}
// validateDispatchDecl enforces "exactly one dispatcher" at build time. Like
// ValidateConstraintDecls this panics: a spec with no runnable body (or with two
// competing ones) is a programming error that every test and startup path should
@@ -1408,6 +1731,13 @@ func AttachContract(cmd *cobra.Command, safety contract.SafetySpec, decl Contrac
d.PreviewKind = strings.TrimSpace(d.PreviewKind)
payload.DryRun = &d
}
if decl.Wait != nil && strings.TrimSpace(decl.Wait.Mode) != "" {
waitSpec, err := contract.NormalizeWaitSpec(decl.Wait, decl.Identity.CanonicalPath)
if err != nil {
panic(fmt.Sprintf("command %q has invalid Contract.Wait: %v", cmd.Name(), err))
}
payload.Wait = waitSpec
}
if decl.Result != nil {
result, err := contract.NormalizeResultSpec(decl.Result, decl.Identity.CanonicalPath)
if err != nil {
+109
View File
@@ -26,6 +26,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
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/testseam"
"github.com/spf13/cobra"
)
@@ -2120,3 +2121,111 @@ func TestCrossPlatformCoverageEmbedContractSkipsBlankAndHiddenFlags(t *testing.T
}
}
}
// TestWaitResourceStronglyTypedDTOs verifies waitResource handles struct and
// struct pointer results via JSON normalization (P1 fix for auto-CR).
func TestWaitResourceStronglyTypedDTOs(t *testing.T) {
type TaskDTO struct {
TaskID string `json:"task_id"`
Status string `json:"status"`
}
type NestedDTO struct {
Meta struct {
ResourceID string `json:"resource_id"`
} `json:"meta"`
}
tests := []struct {
name string
data any
query string
wantResource string
wantErrSubstr string
}{
{
name: "map[string]any fast path",
data: map[string]any{"task_id": "abc123"},
query: "task_id",
wantResource: "abc123",
},
{
name: "struct value",
data: TaskDTO{TaskID: "struct-456", Status: "running"},
query: "task_id",
wantResource: "struct-456",
},
{
name: "struct pointer",
data: &TaskDTO{TaskID: "ptr-789", Status: "pending"},
query: "task_id",
wantResource: "ptr-789",
},
{
name: "nested struct dotted query",
data: NestedDTO{},
query: "meta.resource_id",
wantResource: "",
wantErrSubstr: "not found",
},
{
name: "nested struct with value",
data: func() NestedDTO {
var d NestedDTO
d.Meta.ResourceID = "nested-xyz"
return d
}(),
query: "meta.resource_id",
wantResource: "nested-xyz",
},
{
name: "nil data",
data: nil,
query: "task_id",
wantErrSubstr: "nil",
},
{
name: "non-object data (string)",
data: "not-an-object",
query: "task_id",
wantErrSubstr: "not an object",
},
{
name: "non-object data (slice)",
data: []string{"a", "b"},
query: "task_id",
wantErrSubstr: "not an object",
},
{
name: "missing query field in struct",
data: TaskDTO{TaskID: "abc"},
query: "nonexistent",
wantErrSubstr: "not found",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
decl := &contract.WaitSpec{
ResourceQuery: tt.query,
}
result := output.Success(tt.data)
resource, err := waitResource(decl, result)
if tt.wantErrSubstr != "" {
if err == nil {
t.Fatalf("expected error containing %q, got nil", tt.wantErrSubstr)
}
if !strings.Contains(err.Error(), tt.wantErrSubstr) {
t.Errorf("error = %q; want substring %q", err.Error(), tt.wantErrSubstr)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resource != tt.wantResource {
t.Errorf("resource = %q; want %q", resource, tt.wantResource)
}
})
}
}
+902
View File
@@ -0,0 +1,902 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package corecmd
import (
"bytes"
"context"
"errors"
"io"
"math"
"strconv"
"strings"
"testing"
"time"
"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"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/wait"
)
func waitTestDecl() ContractDecl {
return ContractDecl{
Title: "Wait Title",
Description: "Wait Desc",
Wait: &contract.WaitSpec{
Mode: contract.WaitModePoll,
PollCommand: "oa approval-instance get",
StatusQuery: "result.status",
Terminal: map[string]contract.ResultOutcome{"COMPLETED": contract.ResultOutcomeSuccess, "REJECTED": contract.ResultOutcomeFailure},
PendingValues: []string{"NEW", "RUNNING"},
DefaultTimeoutSecs: 60,
},
Interface: &contract.InterfaceSpec{Mode: "local", Availability: "available"},
Selection: contract.SelectionSpec{
AgentSummary: "summary",
UseWhen: []string{"when wait"},
AvoidWhen: []string{"when nowait"},
Examples: []string{"dws wait-sample --wait"},
},
Identity: contract.ToolIdentitySpec{ProductID: "sample", Name: "waitsample", CanonicalPath: "sample.waitsample", CLIPath: "wait-sample", PrimaryCLIPath: "wait-sample"},
}
}
func baseWaitSpec(decl ContractDecl, poll func(context.Context, *Ctx) (wait.PollDoc, error)) Spec {
return Spec{
Use: "wait-sample",
OutputRollout: output.RolloutUnifiedActive,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: decl,
WaitPoll: poll,
ResultInvoke: func(*Ctx, map[string]any) (output.CommandResult, error) {
return output.Pending(map[string]any{"id": "job-1"}, &output.OperationInfo{
ID: "job-1",
State: "NEW",
NextCommand: "dws wait-sample --id job-1",
}), nil
},
}
}
func TestWaitFlagsOnlyRegisteredWhenDeclared(t *testing.T) {
declared := New(baseWaitSpec(waitTestDecl(), func(context.Context, *Ctx) (wait.PollDoc, error) {
return wait.PollDoc{"result": map[string]any{"status": "COMPLETED"}}, nil
}))
if flag := declared.Flags().Lookup(waitFlagName); flag == nil {
t.Fatal("declared command missing --wait flag")
}
if flag := declared.Flags().Lookup(waitTimeoutFlagName); flag == nil {
t.Fatal("declared command missing --wait-timeout flag")
}
undeclared := New(Spec{
Use: "nowait",
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Invoke: func(*Ctx, map[string]any) error { return nil },
})
if flag := undeclared.Flags().Lookup(waitFlagName); flag != nil {
t.Fatal("undeclared command registered --wait")
}
undeclared.SetArgs([]string{"--wait"})
if err := undeclared.Execute(); err == nil || !strings.Contains(err.Error(), "unknown flag") {
t.Fatalf("err=%v want unknown-flag", err)
}
}
func TestValidateWaitDeclPairsDeclarationWithImplementation(t *testing.T) {
decl := waitTestDecl()
spec := baseWaitSpec(decl, nil)
expectPanic(t, func() { New(spec) }, "WaitPoll")
spec.WaitPoll = func(context.Context, *Ctx) (wait.PollDoc, error) { return nil, nil }
expectPanic(t, func() {
New(Spec{
Use: "hook-only",
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Invoke: func(*Ctx, map[string]any) error { return nil },
WaitPoll: func(context.Context, *Ctx) (wait.PollDoc, error) { return nil, nil },
})
}, "Contract.Wait")
}
func expectPanic(t *testing.T, fn func(), want string) {
t.Helper()
defer func() {
recovered := recover()
if recovered == nil {
t.Fatalf("expected panic containing %q", want)
}
if message, ok := recovered.(string); !ok || !strings.Contains(message, want) {
t.Fatalf("panic=%v want containing %q", recovered, want)
}
}()
fn()
}
func TestWaitTimeoutFlagDefaultsComeFromDeclaration(t *testing.T) {
stub := func(context.Context, *Ctx) (wait.PollDoc, error) {
return wait.PollDoc{"result": map[string]any{"status": "COMPLETED"}}, nil
}
declared := New(baseWaitSpec(waitTestDecl(), stub))
if value, err := declared.Flags().GetInt(waitTimeoutFlagName); err != nil || value != 60 {
t.Fatalf("declared default=%d/%v, want reviewed 60", value, err)
}
decl := waitTestDecl()
decl.Wait.DefaultTimeoutSecs = 0
fallback := New(baseWaitSpec(decl, stub))
if value, err := fallback.Flags().GetInt(waitTimeoutFlagName); err != nil || value != DefaultWaitTimeoutSecs {
t.Fatalf("fallback default=%d/%v, want framework %d", value, err, DefaultWaitTimeoutSecs)
}
// A non-positive explicit value falls back to the framework default.
fallback.SetArgs([]string{"--wait-timeout", "0", "--wait"})
if err := fallback.Flags().Set(waitTimeoutFlagName, "0"); err != nil {
t.Fatal(err)
}
if got := waitTimeoutSecs(fallback); got != DefaultWaitTimeoutSecs {
t.Fatalf("waitTimeoutSecs=%d, want %d", got, DefaultWaitTimeoutSecs)
}
}
func TestWaitTimeoutDurationRejectsOverflowingSeconds(t *testing.T) {
// math.MaxInt64 (9223372036854775807) is a legal pflag int on 64-bit
// platforms and overflows time.Duration(secs)*time.Second to a negative
// value, which would disable the wait deadline.
if _, err := waitTimeoutDuration(math.MaxInt64); err == nil || !strings.Contains(err.Error(), "超出可表示范围") {
t.Fatalf("err=%v, want overflow validation", err)
}
d, err := waitTimeoutDuration(maxWaitTimeoutSecs)
if err != nil {
t.Fatal(err)
}
if d <= 0 || d != time.Duration(maxWaitTimeoutSecs)*time.Second {
t.Fatalf("duration=%d, want the largest representable timeout", d)
}
// Non-positive second counts fall back to the framework default instead
// of disabling the deadline (waitTimeoutSecs already maps a zero/negative
// flag to the default; this keeps the conversion itself fail-safe).
if got, err := waitTimeoutDuration(0); err != nil || got != time.Duration(DefaultWaitTimeoutSecs)*time.Second {
t.Fatalf("duration/err=%d/%v, want framework default", got, err)
}
if got, err := waitTimeoutDuration(-5); err != nil || got != time.Duration(DefaultWaitTimeoutSecs)*time.Second {
t.Fatalf("duration/err=%d/%v, want framework default", got, err)
}
}
func TestResultInvokeWaitTimeoutOverflowIsValidationError(t *testing.T) {
if int64(math.MaxInt) <= maxWaitTimeoutSecs {
t.Skip("platform int cannot overflow time.Duration")
}
polled := false
cmd := New(baseWaitSpec(waitTestDecl(), func(context.Context, *Ctx) (wait.PollDoc, error) {
polled = true
return wait.PollDoc{"result": map[string]any{"status": "COMPLETED"}}, nil
}))
cmd.SetArgs([]string{"--wait", "--wait-timeout", strconv.Itoa(math.MaxInt)})
ctx, _ := output.WithResultStore(context.Background())
cmd.SetContext(ctx)
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "超出可表示范围") {
t.Fatalf("err=%v, want overflow validation", err)
}
if polled {
t.Fatal("overflowing --wait-timeout must not start the wait loop")
}
}
func TestResultInvokeWaitPollErrorFailsTheCommand(t *testing.T) {
boom := errors.New("rpc down")
cmd := New(baseWaitSpec(waitTestDecl(), func(context.Context, *Ctx) (wait.PollDoc, error) {
return nil, boom
}))
cmd.SetArgs([]string{"--wait"})
ctx, _ := output.WithResultStore(context.Background())
cmd.SetContext(ctx)
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "rpc down") {
t.Fatalf("err=%v, want poll error surfaced", err)
}
}
func TestResultInvokeWaitUnknownStatusFailsClosed(t *testing.T) {
cmd := New(baseWaitSpec(waitTestDecl(), func(context.Context, *Ctx) (wait.PollDoc, error) {
return wait.PollDoc{"result": map[string]any{"status": "Mystery"}}, nil
}))
cmd.SetArgs([]string{"--wait"})
ctx, _ := output.WithResultStore(context.Background())
cmd.SetContext(ctx)
err := cmd.Execute()
if err == nil || !wait.IsUnknownStatus(err) {
t.Fatalf("err=%v, want unknown-status", err)
}
}
func TestWaitCtxAccessorsExposeDeclaredCapability(t *testing.T) {
var gotWait bool
var gotTimeout int
cmd := New(baseWaitSpec(waitTestDecl(), func(_ context.Context, c *Ctx) (wait.PollDoc, error) {
gotWait = c.Wait()
gotTimeout = c.WaitTimeoutSecs()
return wait.PollDoc{"result": map[string]any{"status": "COMPLETED"}}, nil
}))
cmd.SetArgs([]string{"--wait", "--wait-timeout", "90"})
ctx, _ := output.WithResultStore(context.Background())
cmd.SetContext(ctx)
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
if !gotWait || gotTimeout != 90 {
t.Fatalf("ctx accessors=%v/%d", gotWait, gotTimeout)
}
}
func eventTestDecl(mode string) ContractDecl {
decl := waitTestDecl()
decl.Wait.Mode = mode
decl.Wait.EventKey = "bpms_instance_change"
decl.Wait.MatchField = "process_instance_id"
decl.Wait.ResourceQuery = "id"
return decl
}
type scriptedStream struct {
events []wait.PollDoc
err error
}
func (s *scriptedStream) Recv(context.Context) (wait.PollDoc, error) {
if len(s.events) > 0 {
doc := s.events[0]
s.events = s.events[1:]
return doc, nil
}
if s.err != nil {
return nil, s.err
}
return nil, io.EOF
}
func TestValidateWaitDeclPairsModeWithHooks(t *testing.T) {
poll := func(context.Context, *Ctx) (wait.PollDoc, error) { return nil, nil }
events := func(context.Context, *Ctx) (wait.EventStream, error) { return nil, nil }
cases := []struct {
name string
mode string
waitPoll bool
waitEvents bool
want string
}{
{"event without WaitEvents", contract.WaitModeEvent, false, false, "WaitEvents"},
{"auto without WaitPoll", contract.WaitModeAuto, false, true, "WaitPoll"},
{"poll with WaitEvents", contract.WaitModePoll, true, true, "WaitEvents"},
{"event with WaitPoll", contract.WaitModeEvent, true, true, "WaitPoll"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
expectPanic(t, func() {
New(Spec{
Use: "wait-sample",
OutputRollout: output.RolloutUnifiedActive,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: eventTestDecl(tc.mode),
ResultInvoke: func(*Ctx, map[string]any) (output.CommandResult, error) {
return output.Pending(map[string]any{}, nil), nil
},
WaitPoll: hookOrNil(tc.waitPoll, poll),
WaitEvents: eventHookOrNil(tc.waitEvents, events),
})
}, tc.want)
})
}
}
func hookOrNil(set bool, hook func(context.Context, *Ctx) (wait.PollDoc, error)) func(context.Context, *Ctx) (wait.PollDoc, error) {
if !set {
return nil
}
return hook
}
func eventHookOrNil(set bool, hook func(context.Context, *Ctx) (wait.EventStream, error)) func(context.Context, *Ctx) (wait.EventStream, error) {
if !set {
return nil
}
return hook
}
func runWaitModeCommand(t *testing.T, decl ContractDecl, poll func(context.Context, *Ctx) (wait.PollDoc, error), events func(context.Context, *Ctx) (wait.EventStream, error), args ...string) (string, error) {
t.Helper()
cmd := New(Spec{
Use: "wait-sample",
OutputRollout: output.RolloutUnifiedActive,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: decl,
ResultInvoke: func(*Ctx, map[string]any) (output.CommandResult, error) {
return output.Pending(map[string]any{"id": "job-1"}, &output.OperationInfo{
ID: "job-1", State: "NEW", NextCommand: "dws wait-sample --id job-1",
}), nil
},
WaitPoll: poll,
WaitEvents: events,
})
cmd.SetArgs(append([]string{"--wait"}, args...))
ctx, _ := output.WithResultStore(context.Background())
cmd.SetContext(ctx)
var stdout bytes.Buffer
cmd.SetOut(&stdout)
cmd.PersistentPostRunE = func(executed *cobra.Command, _ []string) error {
_, _, err := output.EmitStoredResult(executed)
return err
}
err := cmd.Execute()
return stdout.String(), err
}
func TestEventModeClosesEnvelopeFromCorrelatedEvent(t *testing.T) {
stream := &scriptedStream{events: []wait.PollDoc{
{"process_instance_id": "other", "result": map[string]any{"status": "COMPLETED"}},
{"process_instance_id": "job-1", "result": map[string]any{"status": "REJECTED"}},
}}
stdout, err := runWaitModeCommand(t, eventTestDecl(contract.WaitModeEvent), nil, func(context.Context, *Ctx) (wait.EventStream, error) {
return stream, nil
})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(stdout, `"outcome": "failure"`) || !strings.Contains(stdout, `"type": "wait"`) {
t.Fatalf("stdout=%s", stdout)
}
}
func TestEventModeSurfacesStreamEndAsError(t *testing.T) {
_, err := runWaitModeCommand(t, eventTestDecl(contract.WaitModeEvent), nil, func(context.Context, *Ctx) (wait.EventStream, error) {
return &scriptedStream{}, nil
})
if err == nil || !errors.Is(err, wait.ErrEventStreamEnded) {
t.Fatalf("err=%v, want stream-ended", err)
}
}
func TestEventModeRejectsUnresolvableResource(t *testing.T) {
decl := eventTestDecl(contract.WaitModeEvent)
decl.Wait.ResourceQuery = "missing"
_, err := runWaitModeCommand(t, decl, nil, func(context.Context, *Ctx) (wait.EventStream, error) {
return &scriptedStream{}, nil
})
if err == nil || !strings.Contains(err.Error(), "resource query") {
t.Fatalf("err=%v", err)
}
}
func TestAutoModeFallsBackToPollOnStreamEnd(t *testing.T) {
polled := false
stdout, err := runWaitModeCommand(t, eventTestDecl(contract.WaitModeAuto),
func(context.Context, *Ctx) (wait.PollDoc, error) {
polled = true
return wait.PollDoc{"result": map[string]any{"status": "COMPLETED"}}, nil
},
func(context.Context, *Ctx) (wait.EventStream, error) {
return &scriptedStream{}, nil // ends immediately
})
if err != nil {
t.Fatal(err)
}
if !polled {
t.Fatal("auto mode did not fall back to polling")
}
if !strings.Contains(stdout, `"outcome": "success"`) {
t.Fatalf("stdout=%s", stdout)
}
}
func TestAutoModeFallsBackToPollOnSubscriptionFailure(t *testing.T) {
polled := false
_, err := runWaitModeCommand(t, eventTestDecl(contract.WaitModeAuto),
func(context.Context, *Ctx) (wait.PollDoc, error) {
polled = true
return wait.PollDoc{"result": map[string]any{"status": "COMPLETED"}}, nil
},
func(context.Context, *Ctx) (wait.EventStream, error) {
return nil, errors.New("no subscriber credential")
})
if err != nil {
t.Fatal(err)
}
if !polled {
t.Fatal("auto mode did not fall back to polling on subscription failure")
}
}
func TestResultInvokeWaitClosesEnvelopeOutcome(t *testing.T) {
cmd := New(baseWaitSpec(waitTestDecl(), func(context.Context, *Ctx) (wait.PollDoc, error) {
return wait.PollDoc{"result": map[string]any{"status": "REJECTED"}}, nil
}))
cmd.SetArgs([]string{"--wait"})
ctx, store := output.WithResultStore(context.Background())
cmd.SetContext(ctx)
var stdout bytes.Buffer
cmd.SetOut(&stdout)
cmd.PersistentPostRunE = func(executed *cobra.Command, _ []string) error {
_, _, err := output.EmitStoredResult(executed)
return err
}
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
if code, emitted := output.StoredExitCode(store); !emitted || code != 8 {
t.Fatalf("stored code/emitted=%d/%v, want dedicated wait-terminal-failure code 8", code, emitted)
}
if !strings.Contains(stdout.String(), `"type": "wait"`) {
t.Fatalf("stdout=%s, want error.type wait", stdout.String())
}
if !strings.Contains(stdout.String(), `"outcome": "failure"`) {
t.Fatalf("stdout=%s", stdout.String())
}
// The final emitted envelope must carry the observed terminal status in
// meta.operation.state — the acceptance-phase state ("NEW") must not
// survive the close (P1 regression guard).
if !strings.Contains(stdout.String(), `"state": "REJECTED"`) {
t.Fatalf("stdout=%s, want operation.state synced to the terminal status", stdout.String())
}
if strings.Contains(stdout.String(), `"state": "NEW"`) {
t.Fatalf("stdout=%s, acceptance-phase operation.state leaked into the terminal envelope", stdout.String())
}
if strings.Contains(stdout.String(), `"timed_out": true`) {
t.Fatalf("stdout=%s, terminal close must not claim timed_out", stdout.String())
}
}
func TestResultInvokeWaitSuccessCloseSyncsOperationState(t *testing.T) {
cmd := New(baseWaitSpec(waitTestDecl(), func(context.Context, *Ctx) (wait.PollDoc, error) {
return wait.PollDoc{"result": map[string]any{"status": "COMPLETED"}}, nil
}))
cmd.SetArgs([]string{"--wait"})
ctx, store := output.WithResultStore(context.Background())
cmd.SetContext(ctx)
var stdout bytes.Buffer
cmd.SetOut(&stdout)
cmd.PersistentPostRunE = func(executed *cobra.Command, _ []string) error {
_, _, err := output.EmitStoredResult(executed)
return err
}
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
if code, emitted := output.StoredExitCode(store); !emitted || code != 0 {
t.Fatalf("stored code/emitted=%d/%v, want success exit 0", code, emitted)
}
if !strings.Contains(stdout.String(), `"outcome": "success"`) {
t.Fatalf("stdout=%s", stdout.String())
}
// Success close must publish the terminal status, never the stale
// acceptance-phase state (no outcome=success with state=processing/NEW).
if !strings.Contains(stdout.String(), `"state": "COMPLETED"`) {
t.Fatalf("stdout=%s, want operation.state synced to the terminal status", stdout.String())
}
if strings.Contains(stdout.String(), `"state": "NEW"`) {
t.Fatalf("stdout=%s, acceptance-phase operation.state leaked into the success envelope", stdout.String())
}
// Operation identity (id / next_command) survives the terminal close.
if !strings.Contains(stdout.String(), `"id": "job-1"`) || !strings.Contains(stdout.String(), `"next_command"`) {
t.Fatalf("stdout=%s, want operation id/next_command preserved", stdout.String())
}
}
func TestResultInvokeWaitTimeoutKeepsPending(t *testing.T) {
polls := 0
cmd := New(baseWaitSpec(waitTestDecl(), func(context.Context, *Ctx) (wait.PollDoc, error) {
polls++
return wait.PollDoc{"result": map[string]any{"status": "RUNNING"}}, nil
}))
cmd.SetArgs([]string{"--wait", "--wait-timeout", "1"})
ctx, store := output.WithResultStore(context.Background())
cmd.SetContext(ctx)
var stdout bytes.Buffer
cmd.SetOut(&stdout)
cmd.PersistentPostRunE = func(executed *cobra.Command, _ []string) error {
_, _, err := output.EmitStoredResult(executed)
return err
}
if err := cmd.Execute(); err != nil {
t.Fatalf("timeout wait must exit 0 (pending is not failure): %v", err)
}
if code, emitted := output.StoredExitCode(store); !emitted || code != 0 {
t.Fatalf("stored code/emitted=%d/%v", code, emitted)
}
if !strings.Contains(stdout.String(), `"outcome": "pending"`) {
t.Fatalf("stdout=%s", stdout.String())
}
if polls == 0 {
t.Fatal("wait phase never polled")
}
}
func TestWaitDeclRequiresResultInvokeDispatcher(t *testing.T) {
// A declared wait on the legacy Invoke path would observe a failure
// terminal while still exiting 0 — construction must reject it.
expectPanic(t, func() {
New(Spec{
Use: "wait-sample",
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: waitTestDecl(),
WaitPoll: func(context.Context, *Ctx) (wait.PollDoc, error) { return nil, nil },
Invoke: func(*Ctx, map[string]any) error { return nil },
})
}, "ResultInvoke")
}
func TestResultInvokeWithoutWaitFlagSkipsPhase(t *testing.T) {
polled := false
cmd := New(baseWaitSpec(waitTestDecl(), func(context.Context, *Ctx) (wait.PollDoc, error) {
polled = true
return wait.PollDoc{"result": map[string]any{"status": "COMPLETED"}}, nil
}))
cmd.SetArgs(nil)
ctx, _ := output.WithResultStore(context.Background())
cmd.SetContext(ctx)
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
if polled {
t.Fatal("wait phase ran without --wait")
}
}
func TestAttachContractPanicsOnInvalidWaitDeclaration(t *testing.T) {
decl := waitTestDecl()
decl.Wait.Mode = "event" // not implemented
defer func() {
recovered := recover()
if recovered == nil {
t.Fatal("expected panic on invalid Contract.Wait")
}
if message, ok := recovered.(string); !ok || !strings.Contains(message, "Contract.Wait") {
t.Fatalf("panic=%v", recovered)
}
}()
AttachContract(&cobra.Command{Use: "wait-sample"}, contract.SafetySpec{
Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent",
}, decl, "", "")
}
func TestWaitDeclPaddedStatusValuesAreNormalized(t *testing.T) {
// A declaration whose status values carry surrounding whitespace must be
// canonicalized at construction so the runtime wait engine and the
// published Schema agree: the backend returns "COMPLETED" verbatim, and
// a padded terminal key would fail closed as an unknown status.
decl := waitTestDecl()
decl.Wait.Terminal = map[string]contract.ResultOutcome{
" COMPLETED ": contract.ResultOutcomeSuccess,
"\tREJECTED": contract.ResultOutcomeFailure,
}
decl.Wait.PendingValues = []string{" NEW ", "RUNNING "}
cmd := New(baseWaitSpec(decl, func(context.Context, *Ctx) (wait.PollDoc, error) {
return wait.PollDoc{"result": map[string]any{"status": "COMPLETED"}}, nil
}))
cmd.SetArgs([]string{"--wait"})
ctx, store := output.WithResultStore(context.Background())
cmd.SetContext(ctx)
cmd.PersistentPostRunE = func(executed *cobra.Command, _ []string) error {
_, _, err := output.EmitStoredResult(executed)
return err
}
if err := cmd.Execute(); err != nil {
t.Fatalf("padded declaration must still reach the terminal status: %v", err)
}
if code, emitted := output.StoredExitCode(store); !emitted || code != 0 {
t.Fatalf("stored code/emitted=%d/%v, want success", code, emitted)
}
final, ok := contractfinal.RuntimeContractFinal(cmd)
if !ok || final.Wait == nil {
t.Fatal("registered ContractFinal lost the wait capability")
}
if _, ok := final.Wait.Terminal["COMPLETED"]; !ok {
t.Fatalf("registered terminal table not trimmed: %#v", final.Wait.Terminal)
}
for _, value := range final.Wait.PendingValues {
if strings.TrimSpace(value) != value {
t.Fatalf("registered pending value %q not trimmed", value)
}
}
}
func TestNewPanicsOnDuplicateOrConflictingWaitStatusesAfterTrim(t *testing.T) {
// Values that collapse onto one status after trimming are programming
// errors: silently merging them would pick one outcome for two authored
// declarations.
dupTerminal := waitTestDecl()
dupTerminal.Wait.Terminal = map[string]contract.ResultOutcome{
"COMPLETED": contract.ResultOutcomeSuccess,
" COMPLETED": contract.ResultOutcomeFailure,
}
expectPanic(t, func() { New(baseWaitSpec(dupTerminal, nil)) }, "Contract.Wait")
conflict := waitTestDecl()
conflict.Wait.PendingValues = []string{" COMPLETED "}
expectPanic(t, func() { New(baseWaitSpec(conflict, nil)) }, "Contract.Wait")
}
func TestContractDeclEmptyTreatsWaitAsAuthored(t *testing.T) {
// Only Wait is authored: empty() must report non-empty through the Wait
// branch (before validateContractDecl then fails on the missing prose).
decl := ContractDecl{Wait: &contract.WaitSpec{
Mode: contract.WaitModePoll,
PollCommand: "oa approval-instance get",
StatusQuery: "result.status",
Terminal: map[string]contract.ResultOutcome{"COMPLETED": contract.ResultOutcomeSuccess},
}}
if decl.Empty() {
t.Fatal("Wait-only declaration must count as authored")
}
defer func() {
if recover() == nil {
t.Fatal("expected validateContractDecl to reject the missing prose")
}
}()
validateContractDecl(Spec{Use: "wait-only", Contract: decl})
}
func TestEventModeRejectsNonObjectResultData(t *testing.T) {
cmd := New(Spec{
Use: "wait-sample",
OutputRollout: output.RolloutUnifiedActive,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: eventTestDecl(contract.WaitModeEvent),
ResultInvoke: func(*Ctx, map[string]any) (output.CommandResult, error) {
return output.Pending([]any{"not", "an", "object"}, &output.OperationInfo{
ID: "job-1", State: "NEW", NextCommand: "dws wait-sample",
}), nil
},
WaitEvents: func(context.Context, *Ctx) (wait.EventStream, error) {
return &scriptedStream{}, nil
},
})
cmd.SetArgs([]string{"--wait"})
ctx, _ := output.WithResultStore(context.Background())
cmd.SetContext(ctx)
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "not an object") {
t.Fatalf("err=%v, want non-object data rejection", err)
}
}
func TestEventModeSubscriptionFailureSurfacesInStrictMode(t *testing.T) {
decl := eventTestDecl(contract.WaitModeEvent)
_, err := runWaitModeCommand(t, decl, nil, func(context.Context, *Ctx) (wait.EventStream, error) {
return nil, errors.New("no subscriber credential")
})
if err == nil || !strings.Contains(err.Error(), "subscription failed") {
t.Fatalf("err=%v, want subscription failure surfaced", err)
}
}
func TestResultInvokeNonPendingSkipsWaitPhase(t *testing.T) {
partial, err := output.NewPartialData(2,
[]any{map[string]any{"id": "ok"}},
[]output.PartialFailedEntry{{ID: "bad", Error: &output.ErrorInfo{Type: "api", Message: "item failed"}}},
nil)
if err != nil {
t.Fatal(err)
}
cases := []struct {
name string
result output.CommandResult
want string
}{
{"failure", output.Failure(&output.ErrorInfo{Type: "api", Message: "business failed"}), "failure"},
{"success", output.Success(map[string]any{"id": "job-1"}), "success"},
{"partial", output.Partial(partial), "partial_failure"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
polled := false
subscribed := false
cmd := New(Spec{
Use: "wait-sample",
OutputRollout: output.RolloutUnifiedActive,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: eventTestDecl(contract.WaitModeAuto),
ResultInvoke: func(*Ctx, map[string]any) (output.CommandResult, error) {
return tc.result, nil
},
WaitPoll: func(context.Context, *Ctx) (wait.PollDoc, error) {
polled = true
return wait.PollDoc{"result": map[string]any{"status": "COMPLETED"}}, nil
},
WaitEvents: func(context.Context, *Ctx) (wait.EventStream, error) {
subscribed = true
return &scriptedStream{}, nil
},
})
cmd.SetArgs([]string{"--wait"})
ctx, store := output.WithResultStore(context.Background())
cmd.SetContext(ctx)
var stdout bytes.Buffer
cmd.SetOut(&stdout)
cmd.PersistentPostRunE = func(executed *cobra.Command, _ []string) error {
_, _, err := output.EmitStoredResult(executed)
return err
}
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
if polled || subscribed {
t.Fatal("wait phase must not call WaitPoll/WaitEvents for a non-pending initial result")
}
if _, emitted := output.StoredExitCode(store); !emitted {
t.Fatal("initial result was not stored")
}
if !strings.Contains(stdout.String(), `"outcome": "`+tc.want+`"`) {
t.Fatalf("stdout=%s, want outcome %s preserved", stdout.String(), tc.want)
}
if strings.Contains(stdout.String(), `"type": "wait"`) {
t.Fatalf("stdout=%s, wait phase overwrote the original envelope", stdout.String())
}
})
}
}
func TestWaitTimeoutCancelsBlockingPoll(t *testing.T) {
started := make(chan struct{})
cmd := New(baseWaitSpec(waitTestDecl(), func(ctx context.Context, c *Ctx) (wait.PollDoc, error) {
close(started)
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-c.Command().Context().Done():
return nil, c.Command().Context().Err()
}
}))
cmd.SetArgs([]string{"--wait", "--wait-timeout", "1"})
ctx, store := output.WithResultStore(context.Background())
cmd.SetContext(ctx)
var stdout bytes.Buffer
cmd.SetOut(&stdout)
cmd.PersistentPostRunE = func(executed *cobra.Command, _ []string) error {
_, _, err := output.EmitStoredResult(executed)
return err
}
done := make(chan error, 1)
go func() { done <- cmd.Execute() }()
select {
case <-started:
case <-time.After(2 * time.Second):
t.Fatal("blocking poll never started")
}
select {
case err := <-done:
if err != nil {
t.Fatalf("timeout wait must exit 0 (pending is not failure): %v", err)
}
case <-time.After(3 * time.Second):
t.Fatal("blocked poll was not cancelled by --wait-timeout")
}
if code, emitted := output.StoredExitCode(store); !emitted || code != 0 {
t.Fatalf("stored code/emitted=%d/%v", code, emitted)
}
if !strings.Contains(stdout.String(), `"outcome": "pending"`) {
t.Fatalf("stdout=%s", stdout.String())
}
}
func TestWaitTimeoutCancelsBlockingSubscribe(t *testing.T) {
started := make(chan struct{})
cmd := New(Spec{
Use: "wait-sample",
OutputRollout: output.RolloutUnifiedActive,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: eventTestDecl(contract.WaitModeEvent),
ResultInvoke: func(*Ctx, map[string]any) (output.CommandResult, error) {
return output.Pending(map[string]any{"id": "job-1"}, &output.OperationInfo{
ID: "job-1", State: "NEW", NextCommand: "dws wait-sample --id job-1",
}), nil
},
WaitEvents: func(ctx context.Context, c *Ctx) (wait.EventStream, error) {
close(started)
// Leaf subscribe may wait on either the hook ctx or the cobra
// command context; both must carry the wait-timeout deadline.
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-c.Command().Context().Done():
return nil, c.Command().Context().Err()
}
},
})
cmd.SetArgs([]string{"--wait", "--wait-timeout", "1"})
ctx, store := output.WithResultStore(context.Background())
cmd.SetContext(ctx)
var stdout bytes.Buffer
cmd.SetOut(&stdout)
cmd.PersistentPostRunE = func(executed *cobra.Command, _ []string) error {
_, _, err := output.EmitStoredResult(executed)
return err
}
done := make(chan error, 1)
go func() { done <- cmd.Execute() }()
select {
case <-started:
case <-time.After(2 * time.Second):
t.Fatal("blocking subscribe never started")
}
select {
case err := <-done:
if err != nil {
t.Fatalf("timeout wait must exit 0 (pending is not failure): %v", err)
}
case <-time.After(3 * time.Second):
t.Fatal("blocked subscribe was not cancelled by --wait-timeout")
}
if code, emitted := output.StoredExitCode(store); !emitted || code != 0 {
t.Fatalf("stored code/emitted=%d/%v", code, emitted)
}
if !strings.Contains(stdout.String(), `"outcome": "pending"`) {
t.Fatalf("stdout=%s", stdout.String())
}
}
func TestWaitTimeoutCancelsBlockingPollAfterAutoFallback(t *testing.T) {
started := make(chan struct{})
cmd := New(Spec{
Use: "wait-sample",
OutputRollout: output.RolloutUnifiedActive,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: eventTestDecl(contract.WaitModeAuto),
ResultInvoke: func(*Ctx, map[string]any) (output.CommandResult, error) {
return output.Pending(map[string]any{"id": "job-1"}, &output.OperationInfo{
ID: "job-1", State: "NEW", NextCommand: "dws wait-sample --id job-1",
}), nil
},
WaitEvents: func(context.Context, *Ctx) (wait.EventStream, error) {
return &scriptedStream{}, nil // ends immediately → poll fallback
},
WaitPoll: func(ctx context.Context, _ *Ctx) (wait.PollDoc, error) {
close(started)
<-ctx.Done()
return nil, ctx.Err()
},
})
cmd.SetArgs([]string{"--wait", "--wait-timeout", "1"})
ctx, store := output.WithResultStore(context.Background())
cmd.SetContext(ctx)
var stdout bytes.Buffer
cmd.SetOut(&stdout)
cmd.PersistentPostRunE = func(executed *cobra.Command, _ []string) error {
_, _, err := output.EmitStoredResult(executed)
return err
}
done := make(chan error, 1)
go func() { done <- cmd.Execute() }()
select {
case <-started:
case <-time.After(2 * time.Second):
t.Fatal("auto-fallback blocking poll never started")
}
select {
case err := <-done:
if err != nil {
t.Fatalf("timeout wait must exit 0 (pending is not failure): %v", err)
}
case <-time.After(3 * time.Second):
t.Fatal("blocked auto-fallback poll was not cancelled by --wait-timeout")
}
if code, emitted := output.StoredExitCode(store); !emitted || code != 0 {
t.Fatalf("stored code/emitted=%d/%v", code, emitted)
}
if !strings.Contains(stdout.String(), `"outcome": "pending"`) {
t.Fatalf("stdout=%s", stdout.String())
}
}
+2
View File
@@ -58,6 +58,8 @@ const (
// 5 internal (CategoryInternal 与兜底:非结构化错误、panic 收敛均归 5)
// 6 discovery (CategoryDiscovery)
// 7 partial_failure(部分成功专用码,见 ExitCodePartial)
// 8 wait (--wait 观察到失败终态的专用码,见 internal/output
// 的 exitCodeWait;不设 Category,仅经统一信封产出)
//
// ExitCodePartial is the partial-result exit code shared with internal/output.
// It is not returned for CategoryPartial errors because they lack the typed
+1
View File
@@ -132,6 +132,7 @@ func TestClientCreateRuleBasedSubscriptionsUsesDocumentedRuleParam(t *testing.T)
{"oa_approval_task_finished", EventOAApprovalTaskFinished, RuleOptions{}, map[string]any{}},
{"oa_approval_task_redirected", EventOAApprovalTaskRedirected, RuleOptions{}, map[string]any{}},
{"oa_approval_instance_started", EventOAApprovalInstanceStarted, RuleOptions{}, map[string]any{}},
{"oa_approval_instance_cc", EventOAApprovalInstanceCC, RuleOptions{}, map[string]any{}},
{"oa_approval_instance_terminated", EventOAApprovalInstanceTerminated, RuleOptions{}, map[string]any{}},
{"oa_approval_instance_finished", EventOAApprovalInstanceFinished, RuleOptions{}, map[string]any{}},
{"read_group", EventReadGroup, RuleOptions{GroupID: "cid-1"}, map[string]any{"openConversationId": "cid-1"}},
+29
View File
@@ -180,6 +180,19 @@ type OAApprovalInstanceStartedOutput struct {
EventTime int64 `json:"event_time" description:"审批实例事件业务时间" format:"timestamp_ms"`
}
type OAApprovalInstanceCCOutput struct {
Type string `json:"type" description:"事件类型,固定为当前 event_key"`
EventID string `json:"event_id" description:"事件 ID,可用于去重"`
Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"`
SubscribeID string `json:"subscribe_id" description:"订阅 ID"`
ProcessInstanceID string `json:"process_instance_id" description:"审批实例 ID"`
ProcessCode string `json:"process_code" description:"审批流程模板编码"`
Title string `json:"title" description:"审批标题"`
Status string `json:"status" description:"审批实例到达抄送节点时的状态"`
CreateTime int64 `json:"create_time" description:"审批实例创建时间" format:"timestamp_ms"`
EventTime int64 `json:"event_time" description:"审批抄送事件业务时间" format:"timestamp_ms"`
}
type OAApprovalInstanceTerminatedOutput struct {
Type string `json:"type" description:"事件类型,固定为当前 event_key"`
EventID string `json:"event_id" description:"事件 ID,可用于去重"`
@@ -667,6 +680,19 @@ func projectOAApprovalEvent(ev transport.Event, base baseEventOutput, raw json.R
CreateTime: payload.Body.CreateTime,
EventTime: payload.EventTime,
}, nil
case EventOAApprovalInstanceCC:
return OAApprovalInstanceCCOutput{
Type: base.Type,
EventID: base.EventID,
Timestamp: base.Timestamp,
SubscribeID: base.SubscribeID,
ProcessInstanceID: payload.Body.ProcessInstanceID,
ProcessCode: payload.Body.ProcessCode,
Title: payload.Body.Title,
Status: payload.Body.Status,
CreateTime: payload.Body.CreateTime,
EventTime: payload.EventTime,
}, nil
case EventOAApprovalInstanceTerminated:
return OAApprovalInstanceTerminatedOutput{
Type: base.Type,
@@ -871,6 +897,8 @@ func outputTypeForEvent(eventKey string) reflect.Type {
return reflect.TypeOf(OAApprovalTaskRedirectedOutput{})
case eventKey == EventOAApprovalInstanceStarted:
return reflect.TypeOf(OAApprovalInstanceStartedOutput{})
case eventKey == EventOAApprovalInstanceCC:
return reflect.TypeOf(OAApprovalInstanceCCOutput{})
case eventKey == EventOAApprovalInstanceTerminated:
return reflect.TypeOf(OAApprovalInstanceTerminatedOutput{})
case eventKey == EventOAApprovalInstanceFinished:
@@ -906,6 +934,7 @@ func isOAEvent(eventKey string) bool {
eventKey == EventOAApprovalTaskFinished ||
eventKey == EventOAApprovalTaskRedirected ||
eventKey == EventOAApprovalInstanceStarted ||
eventKey == EventOAApprovalInstanceCC ||
eventKey == EventOAApprovalInstanceTerminated ||
eventKey == EventOAApprovalInstanceFinished
}
+18
View File
@@ -173,6 +173,8 @@ func personalOAData(eventKey string) string {
body["finishTime"] = int64(1785229199000)
case EventOAApprovalInstanceStarted:
body["status"] = "RUNNING"
case EventOAApprovalInstanceCC:
body["status"] = "RUNNING"
case EventOAApprovalInstanceTerminated:
body["status"] = "TERMINATED"
body["finishTime"] = int64(1785229199000)
@@ -521,6 +523,21 @@ func TestCrossPlatformCoverageProjectOutputOAEvents(t *testing.T) {
EventTime: 1785229199000,
},
},
{
eventKey: EventOAApprovalInstanceCC,
want: OAApprovalInstanceCCOutput{
Type: EventOAApprovalInstanceCC,
EventID: "oa-event",
Timestamp: 1785229200123,
SubscribeID: "outer-sub",
ProcessInstanceID: "process-instance-1",
ProcessCode: "PROC-TEST-1",
Title: "测试审批",
Status: "RUNNING",
CreateTime: 1785229100000,
EventTime: 1785229199000,
},
},
{
eventKey: EventOAApprovalInstanceTerminated,
want: OAApprovalInstanceTerminatedOutput{
@@ -785,6 +802,7 @@ func TestCrossPlatformCoverageProjectOutputRejectsInvalidOAPayloads(t *testing.T
EventOAApprovalTaskFinished,
EventOAApprovalTaskRedirected,
EventOAApprovalInstanceStarted,
EventOAApprovalInstanceCC,
EventOAApprovalInstanceTerminated,
EventOAApprovalInstanceFinished,
} {
+12
View File
@@ -43,6 +43,7 @@ const (
EventOAApprovalTaskFinished = "user_oa_approval_task_finished"
EventOAApprovalTaskRedirected = "user_oa_approval_task_redirected"
EventOAApprovalInstanceStarted = "user_oa_approval_instance_started"
EventOAApprovalInstanceCC = "user_oa_approval_instance_cc"
EventOAApprovalInstanceTerminated = "user_oa_approval_instance_terminated"
EventOAApprovalInstanceFinished = "user_oa_approval_instance_finished"
)
@@ -323,6 +324,17 @@ var definitions = []Definition{
Auth: map[string]any{"identity": "user"},
Public: true,
},
{
EventKey: EventOAApprovalInstanceCC,
DisplayName: "审批单抄送",
Description: "审批实例到达抄送节点,发送给被抄送人",
Category: "oa",
RuleType: "all",
Status: StatusEnabled,
RequiredParams: nil,
Auth: map[string]any{"identity": "user"},
Public: true,
},
{
EventKey: EventOAApprovalInstanceTerminated,
DisplayName: "审批单终止",
+12
View File
@@ -50,6 +50,7 @@ func TestCatalogEnabledEvents(t *testing.T) {
EventOAApprovalTaskFinished,
EventOAApprovalTaskRedirected,
EventOAApprovalInstanceStarted,
EventOAApprovalInstanceCC,
EventOAApprovalInstanceTerminated,
EventOAApprovalInstanceFinished,
}
@@ -65,6 +66,7 @@ func TestOAEventCatalogDefinitions(t *testing.T) {
EventOAApprovalTaskFinished,
EventOAApprovalTaskRedirected,
EventOAApprovalInstanceStarted,
EventOAApprovalInstanceCC,
EventOAApprovalInstanceTerminated,
EventOAApprovalInstanceFinished,
}
@@ -158,6 +160,7 @@ func TestSchemaDocumentsDefaultToTransportEnvelope(t *testing.T) {
EventOAApprovalTaskFinished,
EventOAApprovalTaskRedirected,
EventOAApprovalInstanceStarted,
EventOAApprovalInstanceCC,
EventOAApprovalInstanceTerminated,
EventOAApprovalInstanceFinished,
} {
@@ -509,6 +512,13 @@ func TestOAEventSchemaDocumentsMatchOutputDTO(t *testing.T) {
"process_code", "title", "status", "create_time", "event_time",
},
},
{
eventKey: EventOAApprovalInstanceCC,
properties: []string{
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
"process_code", "title", "status", "create_time", "event_time",
},
},
{
eventKey: EventOAApprovalInstanceTerminated,
properties: []string{
@@ -633,6 +643,7 @@ func TestBuildRuleParamAllEvents(t *testing.T) {
EventOAApprovalTaskFinished,
EventOAApprovalTaskRedirected,
EventOAApprovalInstanceStarted,
EventOAApprovalInstanceCC,
EventOAApprovalInstanceTerminated,
EventOAApprovalInstanceFinished,
} {
@@ -845,6 +856,7 @@ func TestSupportsMessageFilter(t *testing.T) {
EventOAApprovalTaskFinished,
EventOAApprovalTaskRedirected,
EventOAApprovalInstanceStarted,
EventOAApprovalInstanceCC,
EventOAApprovalInstanceTerminated,
EventOAApprovalInstanceFinished,
"unknown_event",
+17 -3
View File
@@ -791,15 +791,29 @@ const aitableMaxRetries = 3
// callAitableTool 是 aitable 专用的 MCP 调用入口,带自动重试。
// 替代直接调用 callMCPTool,对网络抖动和服务端瞬态错误进行透明重试。
func callAitableTool(toolName string, args map[string]any) error {
return callAitableToolContext(context.Background(), toolName, args)
}
func callAitableToolContext(ctx context.Context, toolName string, args map[string]any) error {
if ctx == nil {
ctx = context.Background()
}
var lastErr error
for attempt := 0; attempt <= aitableMaxRetries; attempt++ {
if err := ctx.Err(); err != nil {
return err
}
if attempt > 0 {
backoff := time.Duration(1<<(attempt-1)) * time.Second // 1s, 2s, 4s
fmt.Fprintf(os.Stderr, "[aitable retry %d/%d] %s after %v...\n", attempt, aitableMaxRetries, toolName, backoff)
helperSleep(backoff)
select {
case <-ctx.Done():
return ctx.Err()
case <-helperAfter(backoff):
}
}
err := callMCPTool(toolName, args)
err := callMCPToolContext(ctx, toolName, args)
if err == nil {
return nil
}
@@ -1877,7 +1891,7 @@ config 结构参考:
if v, _ := cmd.Flags().GetString("field-ids"); v != "" {
toolArgs["fieldIds"] = parseCSVValues(v)
}
return callAitableTool("get_fields", toolArgs)
return callAitableToolContext(cmd.Context(), "get_fields", toolArgs)
},
}
DeclareLeafMetadata(fieldGetCmd, LeafSpec{
@@ -9,6 +9,7 @@ import (
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
@@ -18,6 +19,22 @@ type aitableCommandCoverageCaller struct {
response map[string]string
}
type aitableCommandContextKey struct{}
type aitableCommandContextCaller struct {
value any
}
func (c *aitableCommandContextCaller) CallTool(ctx context.Context, _, _ string, _ map[string]any) (*edition.ToolResult, error) {
c.value = ctx.Value(aitableCommandContextKey{})
return nil, context.Canceled
}
func (*aitableCommandContextCaller) Format() string { return "json" }
func (*aitableCommandContextCaller) DryRun() bool { return false }
func (*aitableCommandContextCaller) Fields() string { return "" }
func (*aitableCommandContextCaller) JQ() string { return "" }
func (c *aitableCommandCoverageCaller) CallTool(_ context.Context, _, tool string, _ map[string]any) (*edition.ToolResult, error) {
if c.err != nil {
return nil, c.err
@@ -67,6 +84,11 @@ func TestCrossPlatformCoverageAitableRetryWrappersExhaustAndRecover(t *testing.T
oldDeps, oldSleep := deps, helperSleep
t.Cleanup(func() { deps, helperSleep = oldDeps, oldSleep })
helperSleep = func(time.Duration) {}
testseam.Swap(t, &helperAfter, func(time.Duration) <-chan time.Time {
ready := make(chan time.Time, 1)
ready <- time.Time{}
return ready
})
retryable := fmt.Errorf("timeout: retryable: true")
caller := &aitableTestCaller{errors: []error{retryable, retryable, retryable, retryable}}
@@ -86,6 +108,48 @@ func TestCrossPlatformCoverageAitableRetryWrappersExhaustAndRecover(t *testing.T
if err := callAitableHelperTool("retry", nil); err == nil {
t.Fatal("exhausted helper retries returned nil")
}
caller = &aitableTestCaller{}
installAitableDeps(t, caller)
if err := callAitableToolContext(nil, "nil-context", nil); err != nil {
t.Fatalf("nil context was not normalized: %v", err)
}
caller = &aitableTestCaller{errors: []error{retryable}}
installAitableDeps(t, caller)
ctx, cancel := context.WithCancel(context.Background())
backoffPending := make(chan time.Time)
testseam.Swap(t, &helperAfter, func(time.Duration) <-chan time.Time {
cancel()
return backoffPending
})
if err := callAitableToolContext(ctx, "cancel-during-backoff", nil); err != context.Canceled {
t.Fatalf("cancel during retry backoff = %v, want %v", err, context.Canceled)
}
}
func TestCrossPlatformCoverageAitableFieldListPreservesCommandContext(t *testing.T) {
old := deps
t.Cleanup(func() { deps = old })
caller := &aitableCommandContextCaller{}
InitDeps(caller)
deps.Out.w = io.Discard
deps.Out.errW = io.Discard
root := newAitableCommand()
installExampleGlobalFlags(root)
root.SilenceErrors = true
root.SilenceUsage = true
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs([]string{"field", "list", "--base-id=b", "--table-id=t"})
ctx := context.WithValue(context.Background(), aitableCommandContextKey{}, "field-list-context")
if err := root.ExecuteContext(ctx); err == nil {
t.Fatal("field list context probe unexpectedly succeeded")
}
if caller.value != "field-list-context" {
t.Fatalf("field list caller context value = %#v", caller.value)
}
}
func TestCrossPlatformCoverageAitableCommandValidationEdges(t *testing.T) {
+43 -5
View File
@@ -155,7 +155,18 @@ func callMCPToolReturnTextOnServer(ctx context.Context, serverID, toolName strin
// ReadToolCaller capability; if unavailable, it fails closed instead of
// returning a synthetic dry-run envelope that looks like business data.
func CallMCPReadToolTextOnServer(serverID, toolName string, args map[string]any) (string, error) {
return callMCPReadToolReturnTextOnServer(context.Background(), serverID, toolName, args)
return CallMCPReadToolTextOnServerContext(context.Background(), serverID, toolName, args)
}
// CallMCPReadToolTextOnServerContext is the cancellable form used by Cobra
// commands and composite shortcuts. Keeping the caller context attached to the
// transport lets SIGTERM/parent deadlines stop an in-flight MCP read and return
// a structured error instead of leaving the CLI silent until the host kills it.
func CallMCPReadToolTextOnServerContext(ctx context.Context, serverID, toolName string, args map[string]any) (string, error) {
if ctx == nil {
ctx = context.Background()
}
return callMCPReadToolReturnTextOnServer(ctx, serverID, toolName, args)
}
func callMCPReadToolReturnTextOnServer(ctx context.Context, serverID, toolName string, args map[string]any) (string, error) {
@@ -266,7 +277,17 @@ func parseMCPToolTextResult(serverID, toolName string, result *edition.ToolResul
// print path. Exported for the shortcut layer's multi-step ("smart") shortcuts,
// which chain several tool calls and need each intermediate result as data.
func CallMCPToolTextOnServer(serverID, toolName string, args map[string]any) (string, error) {
return callMCPToolReturnTextOnServer(context.Background(), serverID, toolName, args)
return CallMCPToolTextOnServerContext(context.Background(), serverID, toolName, args)
}
// CallMCPToolTextOnServerContext invokes one MCP tool while preserving the
// command's cancellation and deadline. Legacy callers may continue using
// CallMCPToolTextOnServer, which intentionally retains background context.
func CallMCPToolTextOnServerContext(ctx context.Context, serverID, toolName string, args map[string]any) (string, error) {
if ctx == nil {
ctx = context.Background()
}
return callMCPToolReturnTextOnServer(ctx, serverID, toolName, args)
}
// CallMCPToolDataOnServer invokes one tool without printing and decodes its
@@ -293,7 +314,11 @@ func CallMCPToolDataOnServer(ctx context.Context, serverID, toolName string, arg
// callMCPTool 是通用的 MCP 工具调用入口:自动路由 → 调用 → 格式化输出。
// 通过 resolveProductID() 自动确定目标 MCP Server,JSON 输出使用默认的 HTML 转义。
func callMCPTool(toolName string, args map[string]any) error {
return callMCPToolInternalOpts("", toolName, args, false)
return callMCPToolContext(context.Background(), toolName, args)
}
func callMCPToolContext(ctx context.Context, toolName string, args map[string]any) error {
return callMCPToolInternalOptsContext(ctx, "", toolName, args, false)
}
// callMCPToolUnescaped 与 callMCPTool 功能相同,但 JSON 输出禁用 HTML 转义。
@@ -306,7 +331,7 @@ func callMCPToolUnescaped(toolName string, args map[string]any) error {
// callMCPToolOnServer 在指定的 MCP Server 上调用工具,跳过 resolveProductID() 的自动路由。
// 用于需要显式指定 serverID 的场景(如 credit 等多 server 产品)。
func callMCPToolOnServer(serverID, toolName string, args map[string]any) error {
return callMCPToolInternalOpts(serverID, toolName, args, false)
return callMCPToolInternalOptsContext(context.Background(), serverID, toolName, args, false)
}
// CallMCPToolOnServer is the exported version of callMCPToolOnServer for use
@@ -315,6 +340,13 @@ func CallMCPToolOnServer(serverID, toolName string, args map[string]any) error {
return callMCPToolOnServer(serverID, toolName, args)
}
// CallMCPToolOnServerContext is the cancellable print-path variant for
// Shortcut execution. It preserves the same output and error projection as the
// legacy wrapper while allowing the root signal context to abort transport.
func CallMCPToolOnServerContext(ctx context.Context, serverID, toolName string, args map[string]any) error {
return callMCPToolInternalOptsContext(ctx, serverID, toolName, args, false)
}
// GroupRunE is the exported version of groupRunE for use by extension packages.
func GroupRunE(cmd *cobra.Command, args []string) error {
return groupRunE(cmd, args)
@@ -343,7 +375,13 @@ func MustGetStringFlag(cmd *cobra.Command, name string) string {
// 3. 错误分类:网关错误 → 未登录 → PAT 错误 → 业务错误
// 4. 根据 --format 标志选择输出格式(json / table / raw)
func callMCPToolInternalOpts(explicitServerID, toolName string, args map[string]any, unescapeHTML bool) error {
ctx := context.Background()
return callMCPToolInternalOptsContext(context.Background(), explicitServerID, toolName, args, unescapeHTML)
}
func callMCPToolInternalOptsContext(ctx context.Context, explicitServerID, toolName string, args map[string]any, unescapeHTML bool) error {
if ctx == nil {
ctx = context.Background()
}
// DryRun 模式:仅预览工具名和参数,不实际调用 MCP Server
if deps.Caller.DryRun() {
+56
View File
@@ -39,6 +39,20 @@ type helpersReadCaller struct {
readCalls int
}
type helpersContextCaller struct {
*helpersCoreCaller
seenErr error
}
func (c *helpersContextCaller) CallTool(ctx context.Context, _ string, _ string, _ map[string]any) (*edition.ToolResult, error) {
c.calls++
c.seenErr = ctx.Err()
if c.seenErr != nil {
return nil, c.seenErr
}
return nil, errors.New("stop after context capture")
}
func (c *helpersReadCaller) CallReadTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
c.readCalls++
return c.readResult, c.readErr
@@ -128,6 +142,48 @@ func TestCrossPlatformCoverageReadLookupInitializationAndRegularExecution(t *tes
}
}
func TestCrossPlatformCoverageMCPContextWrappersPreserveCancellation(t *testing.T) {
caller := &helpersContextCaller{helpersCoreCaller: &helpersCoreCaller{format: "json"}}
installHelpersCoreDeps(t, caller)
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := CallMCPToolTextOnServerContext(ctx, "aitable", "get_fields", nil); err == nil {
t.Fatal("cancelled text call unexpectedly succeeded")
}
if !errors.Is(caller.seenErr, context.Canceled) || caller.calls != 1 {
t.Fatalf("text call context/calls = %v, %d", caller.seenErr, caller.calls)
}
if err := CallMCPToolOnServerContext(ctx, "aitable", "get_fields", nil); err == nil {
t.Fatal("cancelled print call unexpectedly succeeded")
}
if !errors.Is(caller.seenErr, context.Canceled) || caller.calls != 2 {
t.Fatalf("print call context/calls = %v, %d", caller.seenErr, caller.calls)
}
// Public context-aware wrappers intentionally accept nil for compatibility,
// but must normalize it before crossing the transport boundary.
if _, err := CallMCPToolTextOnServerContext(nil, "aitable", "get_fields", nil); err == nil {
t.Fatal("nil-context text call unexpectedly succeeded with a nil result")
}
if caller.seenErr != nil || caller.calls != 3 {
t.Fatalf("nil-context text call context/calls = %v, %d", caller.seenErr, caller.calls)
}
if _, err := CallMCPReadToolTextOnServerContext(nil, "aitable", "get_fields", nil); err == nil {
t.Fatal("nil-context read call unexpectedly succeeded with a nil result")
}
if caller.seenErr != nil || caller.calls != 4 {
t.Fatalf("nil-context read call context/calls = %v, %d", caller.seenErr, caller.calls)
}
if err := CallMCPToolOnServerContext(nil, "aitable", "get_fields", nil); err == nil {
t.Fatal("nil-context print call unexpectedly succeeded with a nil result")
}
if caller.seenErr != nil || caller.calls != 5 {
t.Fatalf("nil-context print call context/calls = %v, %d", caller.seenErr, caller.calls)
}
}
func TestCrossPlatformCoverageSharedDependenciesRoutingAndWrappers(t *testing.T) {
oldDeps := deps
deps = nil
+3 -3
View File
@@ -2,7 +2,7 @@ package helpers
import "strings"
// shellQuoteArg 按 POSIX sh 规则把 s 引用成可安全放进可复制命令的单个 argv 元素。
// ShellQuoteArg 按 POSIX sh 规则把 s 引用成可安全放进可复制命令的单个 argv 元素。
//
// 为什么需要它:错误提示里的恢复命令是给用户直接复制到 shell 执行的,其中的查询域取自用户
// 输入(--workspace 的常见形态就是带查询串的 URL)。裸拼接下,合法 URL 里的 `&` 就会把命令
@@ -21,7 +21,7 @@ import "strings"
//
// 空串必须显式引用成一对空单引号,否则该参数会从 argv 里整个消失,后面的 token 会被前一个
// flag 吞掉。
func shellQuoteArg(s string) string {
func ShellQuoteArg(s string) string {
if s == "" {
return "''"
}
@@ -41,7 +41,7 @@ func shellValueIsBare(s string) bool {
// driveLatestPosixScopeValue 是 POSIX shell 下把用户值放进可复制命令的策略:单引号内 sh 不做
// 任何展开,因此含元字符的值引用后即可安全内联。第二个返回值恒为 true。
func driveLatestPosixScopeValue(value string) (string, bool) {
return shellQuoteArg(value), true
return ShellQuoteArg(value), true
}
// driveLatestWindowsScopeValue 是 Windows 构建下的策略:只内联本身就安全的值,其余一律不进
@@ -51,7 +51,7 @@ func TestCrossPlatformCoverageShellQuoteArgRoundTrip(t *testing.T) {
for _, want := range values {
t.Run(want, func(t *testing.T) {
quoted := shellQuoteArg(want)
quoted := ShellQuoteArg(want)
// 1) 未被拆参也未被吞掉:位置参数个数必须恰好为 1。
// 引用失效时 "sp 7" 会变 2 个、"" 会变 0 个、$(id) 会变 id 的输出词数。
+2 -2
View File
@@ -69,8 +69,8 @@ func TestCrossPlatformCoverageShellQuoteArg(t *testing.T) {
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := shellQuoteArg(tc.in); got != tc.want {
t.Fatalf("shellQuoteArg(%q)\n got: %s\nwant: %s", tc.in, got, tc.want)
if got := ShellQuoteArg(tc.in); got != tc.want {
t.Fatalf("ShellQuoteArg(%q)\n got: %s\nwant: %s", tc.in, got, tc.want)
}
})
}
+3
View File
@@ -435,6 +435,7 @@ const (
exitCodeInternal = 5
exitCodeDiscovery = 6
exitCodePartial = 7 // partial_failure 专用(契约 §4;规划 WS2 第4项;B142 将在 errors 侧补同源常量)
exitCodeWait = 8 // wait 终态失败专用(--wait 观察到失败终态;与 partial 同为"仅新增专用码")
)
// subtypeConfirmationRequired 是门禁拦截的 failure 子类标记(契约规范 §2.4),
@@ -491,6 +492,8 @@ func exitCodeForErrorInfo(info *ErrorInfo) int {
return exitCodePermission
case "discovery":
return exitCodeDiscovery
case "wait":
return exitCodeWait
default:
return exitCodeInternal
}
+4 -2
View File
@@ -283,9 +283,11 @@ func (e *ErrorInfo) Validate() error {
}
// error.type is a wire-stable Agent branch key, not an open-ended label.
// Keep this set aligned with exitCodeForErrorInfo. "permission" is the
// compatibility projection for PAT failures (rc=4).
// compatibility projection for PAT failures (rc=4); "wait" is the wait
// phase's terminal-failure projection (rc=8, e.g. an approval observed
// REJECTED after --wait).
switch errorType {
case "api", "auth", "validation", "permission", "discovery", "internal":
case "api", "auth", "validation", "permission", "discovery", "internal", "wait":
default:
return fmt.Errorf("output: unsupported failure error.type %q", e.Type)
}
@@ -19,6 +19,7 @@ type forgedResult struct {
func (r forgedResult) Outcome() Outcome { return r.env.Outcome }
func (r forgedResult) ExitCode() int { return r.exit }
func (r forgedResult) Data() any { return r.env.Data }
func (r forgedResult) envelope() *Envelope { copy := r.env; return &copy }
type cloneNode struct {
+90
View File
@@ -19,6 +19,10 @@ import (
type CommandResult interface {
Outcome() Outcome
ExitCode() int
// Data returns the accepted payload (already deep-copied). The wait
// phase reads it to resolve the resource identifier an event stream
// correlates against.
Data() any
envelope() *Envelope
}
@@ -30,6 +34,7 @@ type commandResult struct {
func (r *commandResult) Outcome() Outcome { return r.env.Outcome }
func (r *commandResult) ExitCode() int { return r.exitCode }
func (r *commandResult) Data() any { return cloneResultData(r.env.Data) }
func (r *commandResult) envelope() *Envelope {
copy := cloneEnvelope(r.env)
return &copy
@@ -71,6 +76,91 @@ func Partial(data *PartialData, opts ...ResultOption) CommandResult {
return newCommandResult(OutcomePartialFailure, data, nil, opts...)
}
// WithMeta(meta *Meta) ResultOption was declared above; the two options below
// exist for the wait phase.
// WithErrorInfo replaces the error info of the envelope. Used when a wait
// phase closes an accepted result into failure: envelope invariant I3
// requires an error iff the outcome is failure.
func WithErrorInfo(info *ErrorInfo) ResultOption {
return ResultOption{apply: func(env *Envelope) {
if info != nil {
info = cloneErrorInfo(info)
}
env.Error = info
}}
}
// WithOperationTimedOut marks the envelope's async operation as timed out at
// the last observed state, preserving the declared id / next_command resume
// facts (契约规范 §2.2: 超时必须保持 State 真实值并置 TimedOut:true). A result
// without operation info keeps nil — the pending envelope invariant then fails
// at emission, surfacing the leaf bug instead of synthesizing fake resume
// facts.
func WithOperationTimedOut(state string) ResultOption {
return ResultOption{apply: func(env *Envelope) {
if env.Meta == nil || env.Meta.Operation == nil {
return
}
operation := *env.Meta.Operation
// A subscribe/poll that never observed a status still times out
// against the accepted pending result: keep the original state
// rather than wiping it to empty (pending requires operation.state).
if strings.TrimSpace(state) != "" {
operation.State = state
}
operation.TimedOut = true
env.Meta.Operation = &operation
}}
}
// WithOperationTerminalState closes the envelope's async operation at the observed
// terminal status (契约规范 §2.2: 终态封装必须同步 operation.state — a success or
// failure close that kept the acceptance-phase state would emit a
// self-contradicting envelope such as outcome=success with
// operation.state=processing). The declared id / next_command facts are kept
// as the operation identity, and timed_out is cleared: the §2.2 anti-spoof
// rule forbids a timed-out claim on an operation that reached a terminal
// state. A result without operation info is left untouched.
func WithOperationTerminalState(state string) ResultOption {
return ResultOption{apply: func(env *Envelope) {
if env.Meta == nil || env.Meta.Operation == nil {
return
}
operation := *env.Meta.Operation
if strings.TrimSpace(state) != "" {
operation.State = state
}
operation.TimedOut = false
env.Meta.Operation = &operation
}}
}
// WithOutcome rewraps an existing result with a new outcome, preserving data,
// meta, identity, and any error info (subject to the opts). The corecmd wait
// phase uses it to close an accepted result into its terminal (or timed-out
// pending) outcome; the exit code is re-derived from the new envelope.
func WithOutcome(result CommandResult, outcome Outcome, opts ...ResultOption) CommandResult {
env := *result.envelope()
for _, opt := range opts {
if opt.apply != nil {
opt.apply(&env)
}
}
env.Outcome = outcome
env.OK = outcome == OutcomeSuccess || outcome == OutcomePending
if outcome == OutcomeFailure {
// Invariant I3: data and error are mutually exclusive. Closing into
// failure replaces the accepted data with the failure error.
env.Data = nil
}
exitCode := ExitCodeForEnvelope(&env)
if env.Error != nil {
env.Error.ExitCode = exitCode
}
return &commandResult{env: env, exitCode: exitCode}
}
// Failure constructs an immutable typed failure result.
func Failure(info *ErrorInfo, opts ...ResultOption) CommandResult {
if info != nil {
+165
View File
@@ -0,0 +1,165 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package output
import (
"strings"
"testing"
)
func pendingAcceptedResult() CommandResult {
return Pending(map[string]any{"id": "job-1"}, &OperationInfo{
ID: "job-1",
State: "NEW",
NextCommand: "dws wait-sample --id job-1",
})
}
func TestWithOutcomeClosesSuccessPreservingDataAndMeta(t *testing.T) {
result := WithOutcome(pendingAcceptedResult(), OutcomeSuccess)
if result.Outcome() != OutcomeSuccess || result.ExitCode() != 0 {
t.Fatalf("outcome=%s exit=%d", result.Outcome(), result.ExitCode())
}
env := result.envelope()
if env.Meta == nil || env.Meta.Operation == nil {
t.Fatal("operation info lost on success close")
}
if err := ValidateResult(result); err != nil {
t.Fatal(err)
}
}
func TestWithOutcomeFailureDropsDataAndCarriesError(t *testing.T) {
result := WithOutcome(pendingAcceptedResult(), OutcomeFailure, WithErrorInfo(&ErrorInfo{
Type: "wait",
Subtype: "terminal_failure",
Message: "等待到达失败终态:REJECTED",
}))
if result.Outcome() != OutcomeFailure || result.ExitCode() != 8 {
t.Fatalf("outcome=%s exit=%d, want failure/8", result.Outcome(), result.ExitCode())
}
env := result.envelope()
if env.Data != nil {
t.Fatal("failure close must drop data (I3)")
}
if env.Error == nil || env.Error.Type != "wait" {
t.Fatal("failure close must carry error info (I3)")
}
if err := ValidateResult(result); err != nil {
t.Fatal(err)
}
}
func TestWithOperationTimedOutMarksStateAndKeepsResumeFacts(t *testing.T) {
result := WithOutcome(pendingAcceptedResult(), OutcomePending, WithOperationTimedOut("RUNNING"))
env := result.envelope()
op := env.Meta.Operation
if op.State != "RUNNING" || !op.TimedOut || op.ID != "job-1" || op.NextCommand != "dws wait-sample --id job-1" {
t.Fatalf("operation=%+v", op)
}
if err := ValidateResult(result); err != nil {
t.Fatal(err)
}
}
func TestWithOperationTimedOutEmptyStatePreservesExisting(t *testing.T) {
result := WithOutcome(pendingAcceptedResult(), OutcomePending, WithOperationTimedOut(""))
op := result.envelope().Meta.Operation
if op.State != "NEW" || !op.TimedOut {
t.Fatalf("operation=%+v, want original state kept and timed_out set", op)
}
if err := ValidateResult(result); err != nil {
t.Fatal(err)
}
}
func TestWithOperationTimedOutWithoutOperationInfoLeavesEnvelopeUntouched(t *testing.T) {
// A result without operation info keeps nil — ValidateResult must then
// reject the pending envelope instead of the option synthesizing fake
// resume facts.
result := WithOutcome(Success(map[string]any{"ok": true}), OutcomePending, WithOperationTimedOut("RUNNING"))
env := result.envelope()
if env.Meta != nil && env.Meta.Operation != nil {
t.Fatalf("operation=%+v, want untouched", env.Meta.Operation)
}
err := ValidateResult(result)
if err == nil || !strings.Contains(err.Error(), "meta.operation") {
t.Fatalf("err=%v, want pending-requires-operation rejection", err)
}
}
func TestDataAccessorReturnsDeepCopy(t *testing.T) {
result := pendingAcceptedResult()
data, ok := result.Data().(map[string]any)
if !ok {
t.Fatalf("data=%#v", result.Data())
}
data["id"] = "mutated"
again := result.Data().(map[string]any)
if again["id"] != "job-1" {
t.Fatalf("Data() aliased internal state: %#v", again)
}
}
func TestWithOperationTerminalStateSyncsStateAndClearsTimedOut(t *testing.T) {
for _, tc := range []struct {
name string
outcome Outcome
}{
{"success", OutcomeSuccess},
{"failure", OutcomeFailure},
} {
t.Run(tc.name, func(t *testing.T) {
opts := []ResultOption{WithOperationTerminalState("COMPLETED")}
if tc.outcome == OutcomeFailure {
opts = append(opts, WithErrorInfo(&ErrorInfo{
Type: "wait", Subtype: "terminal_failure", Message: "等待到达失败终态:COMPLETED",
}))
}
result := WithOutcome(pendingAcceptedResult(), tc.outcome, opts...)
op := result.envelope().Meta.Operation
if op.State != "COMPLETED" || op.TimedOut {
t.Fatalf("operation=%+v, want terminal state synced and timed_out cleared", op)
}
if op.ID != "job-1" || op.NextCommand != "dws wait-sample --id job-1" {
t.Fatalf("operation=%+v, want resume identity facts preserved", op)
}
if err := ValidateResult(result); err != nil {
t.Fatal(err)
}
})
}
}
func TestWithOperationTerminalStateBlankKeepsObservedState(t *testing.T) {
// A terminal observation that carries no status must keep the last known
// state rather than wipe it: operation.state may never be emptied by a
// close transition.
result := WithOutcome(pendingAcceptedResult(), OutcomeSuccess, WithOperationTerminalState(" "))
op := result.envelope().Meta.Operation
if op.State != "NEW" || op.TimedOut {
t.Fatalf("operation=%+v, want original state kept and timed_out cleared", op)
}
if err := ValidateResult(result); err != nil {
t.Fatal(err)
}
}
func TestWithOperationTerminalStateWithoutOperationInfoLeavesEnvelopeUntouched(t *testing.T) {
result := WithOutcome(Success(map[string]any{"ok": true}), OutcomeSuccess, WithOperationTerminalState("COMPLETED"))
env := result.envelope()
if env.Meta != nil && env.Meta.Operation != nil {
t.Fatalf("operation=%+v, want untouched", env.Meta.Operation)
}
}
+14 -7
View File
@@ -659,8 +659,8 @@ var RecordQuery = shortcut.Shortcut{
Service: "aitable",
Command: "+record-query",
Product: serverMain,
Description: "查询表格记录(按 ID 取 / 条件筛选 / 关键词 / 分页)",
Intent: "当你要读取表格里的行数据——按 recordId 精确取、按结构化条件筛选、按关键词全文搜索或分页遍历时使用;返回匹配记录及其单元格值。",
Description: "查询表格记录(按 ID / 条件 / 关键词,并支持字段投影和分页)",
Intent: "读取表格行数据;可按 recordId 精确取、按条件筛选、按关键词搜索,并用 fieldIds 只返回用户要求的字段以避免无关数据和 token 消耗。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
@@ -674,17 +674,20 @@ var RecordQuery = shortcut.Shortcut{
CLIPath: "aitable +record-query",
PrimaryCLIPath: "aitable +record-query",
},
Description: "查询表格记录(按 ID 取 / 条件筛选 / 关键词 / 分页)",
Description: "查询表格记录(按 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: "查询表格记录(按 ID 取 / 条件筛选 / 关键词 / 分页)",
UseWhen: []string{"当你要读取表格里的行数据——按 recordId 精确取、按结构化条件筛选、按关键词全文搜索或分页遍历时使用;返回匹配记录及其单元格值。"},
AgentSummary: "查询表格记录(按 ID / 条件 / 关键词,并支持字段投影和分页)",
UseWhen: []string{"读取表格行数据;可按 recordId 精确取、按条件筛选、按关键词搜索,并用 fieldIds 只返回用户要求的字段以避免无关数据和 token 消耗。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws aitable +record-query --base-id B --table-id T --query \"关键词\" --limit 50"},
Examples: []string{
"dws aitable +record-query --base-id B --table-id T --query \"关键词\" --limit 50",
"dws aitable +record-query --base-id B --table-id T --record-ids R1,R2 --field-ids F_NAME,F_STATUS",
},
},
},
Flags: []shortcut.Flag{
@@ -698,7 +701,10 @@ var RecordQuery = shortcut.Shortcut{
{Name: "limit", Type: shortcut.FlagInt, Desc: "单次最大记录数,默认 100(可选)"},
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标(可选)"},
},
Tips: []string{`dws aitable +record-query --base-id B --table-id T --query "关键词" --limit 50`},
Tips: []string{
`dws aitable +record-query --base-id B --table-id T --query "关键词" --limit 50`,
`dws aitable +record-query --base-id B --table-id T --record-ids R1,R2 --field-ids F_NAME,F_STATUS`,
},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{
"baseId": rt.Str("base-id"),
@@ -3110,6 +3116,7 @@ func init() {
BaseSchemaSnapshot,
BaseBootstrap,
TableGet,
TableBootstrap,
TableCopy,
TableUpdate,
TableDelete,
+109 -24
View File
@@ -5,6 +5,7 @@ package aitable
import (
"fmt"
"reflect"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
@@ -50,7 +51,7 @@ var BaseBootstrap = shortcut.Shortcut{
"+base-bootstrap",
"一次创建 Base、数据表和字段,逐层读回验证并在中断时报告已知副作用",
"当你已有声明式 tables JSON、想一次搭好一套 AI 表格结构时使用;表内字段自动按 15 个拆批,每次创建都读回验证。",
"已有 Base 只需新增一张表时用 table create;复制现有 Base 用 base copy;不要对失败请求盲目重试",
"已有 Base 只需新增一张表时用 +table-bootstrap;复制现有 Base 用 +base-copy;不要对失败请求盲目重试",
`dws aitable +base-bootstrap --name "项目管理" --tables '[{"name":"任务","fields":[]}]'`,
),
Flags: []shortcut.Flag{
@@ -73,25 +74,25 @@ type bootstrapTable struct {
func parseBootstrapTables(raw string) ([]bootstrapTable, error) {
value, err := parseJSONAny("tables", raw)
if err != nil {
return nil, err
return nil, baseBootstrapValidation(fmt.Sprintf("--tables 不是合法 JSON 数组:%v", err))
}
items, ok := value.([]any)
if !ok || len(items) == 0 {
return nil, apperrors.NewValidation("--tables 必须是非空 JSON 数组")
return nil, baseBootstrapValidation("--tables 必须是非空 JSON 数组")
}
if len(items) > 100 {
return nil, apperrors.NewValidation("--tables 最多接受 100 张表")
return nil, baseBootstrapValidation("--tables 最多接受 100 张表")
}
seen := map[string]bool{}
out := make([]bootstrapTable, 0, len(items))
for index, item := range items {
object, ok := item.(map[string]any)
if !ok {
return nil, apperrors.NewValidation(fmt.Sprintf("--tables[%d] 必须是 JSON 对象", index))
return nil, baseBootstrapValidation(fmt.Sprintf("--tables[%d] 必须是 JSON 对象", index))
}
name := strings.TrimSpace(stringValue(object, "name"))
if name == "" || seen[name] {
return nil, apperrors.NewValidation(fmt.Sprintf("--tables[%d].name 必须非空且不能重复", index))
return nil, baseBootstrapValidation(fmt.Sprintf("--tables[%d].name 必须非空且不能重复", index))
}
seen[name] = true
fields := []any{}
@@ -99,13 +100,24 @@ func parseBootstrapTables(raw string) ([]bootstrapTable, error) {
var fieldsOK bool
fields, fieldsOK = rawFields.([]any)
if !fieldsOK {
return nil, apperrors.NewValidation(fmt.Sprintf("--tables[%d].fields 必须是 JSON 数组", index))
return nil, baseBootstrapValidation(fmt.Sprintf("--tables[%d].fields 必须是 JSON 数组", index))
}
}
fieldNames := map[string]bool{}
for fieldIndex, field := range fields {
object, ok := field.(map[string]any)
if !ok || strings.TrimSpace(stringValue(object, "fieldName", "name")) == "" || strings.TrimSpace(stringValue(object, "type")) == "" {
return nil, apperrors.NewValidation(fmt.Sprintf("--tables[%d].fields[%d] 必须包含 fieldName 和 type", index, fieldIndex))
fieldName := strings.TrimSpace(stringValue(object, "fieldName", "name"))
if !ok || fieldName == "" || strings.TrimSpace(stringValue(object, "type")) == "" {
return nil, baseBootstrapValidation(fmt.Sprintf("--tables[%d].fields[%d] 必须包含 fieldName 和 type", index, fieldIndex))
}
if fieldNames[fieldName] {
return nil, baseBootstrapValidation(fmt.Sprintf("--tables[%d].fields[%d].fieldName %q 不能重复", index, fieldIndex, fieldName))
}
fieldNames[fieldName] = true
if config, exists := object["config"]; exists {
if _, ok := config.(map[string]any); !ok {
return nil, baseBootstrapValidation(fmt.Sprintf("--tables[%d].fields[%d].config 必须是 JSON 对象", index, fieldIndex))
}
}
}
out = append(out, bootstrapTable{Name: name, Fields: fields})
@@ -113,6 +125,14 @@ func parseBootstrapTables(raw string) ([]bootstrapTable, error) {
return out, nil
}
func baseBootstrapValidation(message string) error {
return apperrors.NewValidation(message,
apperrors.WithHint("tables 使用 [{\"name\":\"任务\",\"fields\":[{\"fieldName\":\"标题\",\"type\":\"text\"}]}];已知参数时不要先调用 --help"),
apperrors.WithActions(`dws aitable +base-bootstrap --name "项目管理" --tables '[{"name":"任务","fields":[{"fieldName":"标题","type":"text"}]}]'`),
apperrors.WithAvailableFlags("name", "folder-id", "template-id", "tables"),
)
}
func executeBaseSchemaSnapshot(rt *shortcut.RuntimeContext) error {
baseID := rt.Str("base-id")
base, err := rt.CallMCPData(serverMain, "get_base", map[string]any{"baseId": baseID})
@@ -195,17 +215,20 @@ func executeBaseBootstrap(rt *shortcut.RuntimeContext) error {
if err != nil {
result.Status = "unknown"
result.FailedCount = len(tables)
result.Checkpoint = map[string]any{"nextStep": "resolve whether base was created before retrying"}
result.Checkpoint = map[string]any{"nextStep": "resolve whether base was created before retrying", "name": rt.Str("name")}
result.NextCommand = aitableRecoveryCommand("dws", "aitable", "+base-search", "--query", rt.Str("name"), "--format", "json")
return compositeError(result, err, false)
}
baseID := findStringByKeys(baseData, "baseId")
if baseID == "" {
result.Status = "unknown"
result.Result = baseData
result.Checkpoint = map[string]any{"nextStep": "locate the created Base by exact name before retrying"}
result.Checkpoint = map[string]any{"nextStep": "locate the created Base by exact name before retrying", "name": rt.Str("name")}
result.NextCommand = aitableRecoveryCommand("dws", "aitable", "+base-search", "--query", rt.Str("name"), "--format", "json")
return compositeError(result, fmt.Errorf("create_base response is missing baseId"), false)
}
result.Resolved = map[string]any{"baseId": baseID}
result.NextCommand = aitableRecoveryCommand("dws", "aitable", "+base-get", "--base-id", baseID, "--format", "json")
result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": "create_base", "baseId": baseID})
result.CompletedSteps = append(result.CompletedSteps, compositeStep{Index: 1, Name: "create base", Tool: "create_base", Status: "completed", Result: baseData})
baseRead, err := rt.CallMCPData(serverMain, "get_base", map[string]any{"baseId": baseID})
@@ -237,6 +260,7 @@ func executeBaseBootstrap(rt *shortcut.RuntimeContext) error {
return compositeError(result, createErr, false)
}
result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": "create_table", "baseId": baseID, "tableId": tableID, "name": spec.Name})
result.NextCommand = aitableRecoveryCommand("dws", "aitable", "+table-get", "--base-id", baseID, "--table-id", tableID, "--format", "json")
for offset := initialEnd; offset < len(spec.Fields); offset += 15 {
end := minInt(offset+15, len(spec.Fields))
_, fieldErr := rt.CallMCPWriteDataStrict(serverMain, "create_fields", map[string]any{
@@ -259,10 +283,13 @@ func executeBaseBootstrap(rt *shortcut.RuntimeContext) error {
}
fieldsData, verifyErr := rt.CallMCPData(serverMain, "get_fields", map[string]any{"baseId": baseID, "tableId": tableID})
fields, found := findNamedObjectList(fieldsData, "fields", "fieldList")
if verifyErr != nil || !found || !containsAllFieldNames(fields, spec.Fields) {
if verifyErr == nil {
verifyErr = fmt.Errorf("field read-back for table %s does not contain the declared field set", tableID)
}
if verifyErr == nil && !found {
verifyErr = fmt.Errorf("field read-back for table %s is missing the fields collection", tableID)
}
if verifyErr == nil {
verifyErr = verifyDeclaredFieldStructures(fields, spec.Fields)
}
if verifyErr != nil {
result.Status = "partial_success"
result.CompletedCount = index
result.FailedCount = len(tables) - index
@@ -275,6 +302,7 @@ func executeBaseBootstrap(rt *shortcut.RuntimeContext) error {
}
result.Verification = map[string]any{"status": "verified", "baseId": baseID, "tableCount": len(createdTables)}
result.Result = map[string]any{"baseId": baseID, "tables": createdTables}
result.NextCommand = ""
return rt.Output(result)
}
@@ -378,16 +406,73 @@ func deepContainsString(value any, expected string) bool {
return false
}
func containsAllFieldNames(actual []map[string]any, expected []any) bool {
names := map[string]bool{}
func verifyDeclaredFieldStructures(actual []map[string]any, expected []any) error {
actualByName := map[string][]map[string]any{}
for _, field := range actual {
names[stringValue(field, "fieldName", "name")] = true
}
for _, raw := range expected {
field, _ := raw.(map[string]any)
if !names[stringValue(field, "fieldName", "name")] {
return false
name := strings.TrimSpace(stringValue(field, "fieldName", "name"))
if name != "" {
actualByName[name] = append(actualByName[name], field)
}
}
return true
for index, raw := range expected {
field, ok := raw.(map[string]any)
if !ok {
return fmt.Errorf("declared field at index %d is not an object", index)
}
name := strings.TrimSpace(stringValue(field, "fieldName", "name"))
matches := actualByName[name]
if len(matches) == 0 {
return fmt.Errorf("field read-back is missing declared field %q", name)
}
if len(matches) != 1 {
return fmt.Errorf("field read-back contains %d fields named %q", len(matches), name)
}
actualField := matches[0]
expectedType := strings.TrimSpace(stringValue(field, "type"))
actualType := strings.TrimSpace(stringValue(actualField, "type", "fieldType"))
if actualType != expectedType {
return fmt.Errorf("field %q type mismatch: got %q, want %q", name, actualType, expectedType)
}
for _, key := range []string{"config", "aiConfig"} {
expectedConfig, declared := field[key]
if !declared {
continue
}
actualConfig, found := actualField[key]
if !found || !declaredValueMatches(actualConfig, expectedConfig) {
return fmt.Errorf("field %q %s mismatch", name, key)
}
}
}
return nil
}
func declaredValueMatches(actual, expected any) bool {
switch expectedValue := expected.(type) {
case map[string]any:
actualValue, ok := actual.(map[string]any)
if !ok {
return false
}
for key, expectedChild := range expectedValue {
actualChild, found := actualValue[key]
if !found || !declaredValueMatches(actualChild, expectedChild) {
return false
}
}
return true
case []any:
actualValue, ok := actual.([]any)
if !ok || len(actualValue) != len(expectedValue) {
return false
}
for index := range expectedValue {
if !declaredValueMatches(actualValue[index], expectedValue[index]) {
return false
}
}
return true
default:
return reflect.DeepEqual(actual, expected)
}
}
@@ -5,14 +5,17 @@ package aitable
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"runtime"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/spf13/cobra"
)
@@ -25,11 +28,18 @@ func runAITableCompositeCLI(t *testing.T, caller *upsertByKeyCaller, command str
root.PersistentFlags().Bool("dry-run", false, "")
root.PersistentFlags().String("format", "json", "")
root.AddCommand(shortcut.Commands()...)
ctx, _ := output.WithResultStore(context.Background())
root.SetContext(ctx)
stdout := &bytes.Buffer{}
root.SetOut(stdout)
root.SetErr(&bytes.Buffer{})
root.SetArgs(append([]string{"aitable", command}, args...))
err := root.Execute()
executed, err := root.ExecuteC()
if err == nil && output.UsesUnifiedResult(executed) {
if _, _, emitErr := output.EmitStoredResult(executed); emitErr != nil {
return stdout.String(), emitErr
}
}
return stdout.String(), err
}
@@ -197,6 +207,8 @@ func TestCrossPlatformCoverageBaseBootstrapInputValidation(t *testing.T) {
"fields not array": `[{"name":"T","fields":{}}]`,
"field not object": `[{"name":"T","fields":[1]}]`,
"field missing type": `[{"name":"T","fields":[{"fieldName":"F"}]}]`,
"duplicate field": `[{"name":"T","fields":[{"fieldName":"F","type":"text"},{"fieldName":" F ","type":"number"}]}]`,
"field config array": `[{"name":"T","fields":[{"fieldName":"F","type":"text","config":[]}]}]`,
}
for name, raw := range cases {
t.Run(name, func(t *testing.T) {
@@ -238,6 +250,10 @@ func TestCrossPlatformCoverageBaseBootstrapExecuteRejectsInvalidTablesE2E(t *tes
if err == nil || out != "" {
t.Fatalf("invalid bootstrap tables = output:%q err:%v", out, err)
}
var typed *apperrors.Error
if !errors.As(err, &typed) || len(typed.Actions) != 1 || len(typed.AvailableFlags) != 4 {
t.Fatalf("invalid bootstrap recovery = %#v", err)
}
}
func TestCrossPlatformCoverageBaseBootstrapFailureStagesE2E(t *testing.T) {
@@ -256,6 +272,7 @@ func TestCrossPlatformCoverageBaseBootstrapFailureStagesE2E(t *testing.T) {
{name: "verify table error", steps: []upsertByKeyStep{{text: `{"baseId":"b"}`}, {text: `{"baseId":"b"}`}, {text: `{"tableId":"t"}`}, {err: errors.New("verify table failed")}}},
{name: "verify table wrong id", steps: []upsertByKeyStep{{text: `{"baseId":"b"}`}, {text: `{"baseId":"b"}`}, {text: `{"tableId":"t"}`}, {text: `{"tables":[]}`}}},
{name: "verify fields error", steps: []upsertByKeyStep{{text: `{"baseId":"b"}`}, {text: `{"baseId":"b"}`}, {text: `{"tableId":"t"}`}, {text: `{"tables":[{"tableId":"t"}]}`}, {err: errors.New("verify fields failed")}}},
{name: "verify fields missing collection", steps: []upsertByKeyStep{{text: `{"baseId":"b"}`}, {text: `{"baseId":"b"}`}, {text: `{"tableId":"t"}`}, {text: `{"tables":[{"tableId":"t"}]}`}, {text: `{}`}}},
{name: "verify fields mismatch", steps: []upsertByKeyStep{{text: `{"baseId":"b"}`}, {text: `{"baseId":"b"}`}, {text: `{"tableId":"t"}`}, {text: `{"tables":[{"tableId":"t"}]}`}, {text: `{"fields":[]}`}}, extra: []string{"--folder-id", "folder", "--template-id", "template"}, withField: true},
}
for _, tc := range cases {
@@ -275,6 +292,139 @@ func TestCrossPlatformCoverageBaseBootstrapFailureStagesE2E(t *testing.T) {
}
}
func TestCrossPlatformCoverageBaseBootstrapFailurePublishesExactRecovery(t *testing.T) {
out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{}`}}},
"+base-bootstrap", "--name", "Project", "--tables", marshalBootstrapTables(t, nil), "--yes")
if out != "" || err == nil {
t.Fatalf("bootstrap recovery = output:%q err:%v", out, err)
}
var typed *apperrors.Error
if !errors.As(err, &typed) || len(typed.Actions) != 1 || len(typed.AvailableFlags) != 4 {
t.Fatalf("bootstrap typed recovery = %#v", err)
}
if typed.Actions[0] != `dws aitable +base-search --query Project --format json` {
t.Fatalf("bootstrap next command = %#v", typed.Actions)
}
}
func TestCrossPlatformCoverageAITableRecoveryCommandsQuoteUntrustedValues(t *testing.T) {
t.Run("base name", func(t *testing.T) {
name := `项目 $(touch /tmp/pwn) 'Q'`
out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{}`}}},
"+base-bootstrap", "--name", name, "--tables", marshalBootstrapTables(t, nil), "--yes")
if out != "" || err == nil {
t.Fatalf("hostile base name recovery = output:%q err:%v", out, err)
}
var typed *apperrors.Error
if !errors.As(err, &typed) || len(typed.Actions) != 1 {
t.Fatalf("hostile base name error = %#v", err)
}
want := `dws aitable +base-search --query '项目 $(touch /tmp/pwn) '\''Q'\''' --format json`
if runtime.GOOS == "windows" {
want = `dws aitable +base-search --query REPLACE_QUERY --format json`
}
if typed.Actions[0] != want {
t.Fatalf("base name recovery = %q, want %q", typed.Actions[0], want)
}
})
t.Run("base id", func(t *testing.T) {
baseID := `base;printf hacked`
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: mustJSON(t, map[string]any{"baseId": baseID})},
{err: errors.New("verify base failed")},
}}
out, err := runAITableCompositeCLI(t, caller, "+base-bootstrap",
"--name", "Project", "--tables", marshalBootstrapTables(t, nil), "--yes")
if out != "" || err == nil {
t.Fatalf("hostile base id recovery = output:%q err:%v", out, err)
}
var typed *apperrors.Error
if !errors.As(err, &typed) || len(typed.Actions) != 1 {
t.Fatalf("hostile base id error = %#v", err)
}
want := `dws aitable +base-get --base-id 'base;printf hacked' --format json`
if runtime.GOOS == "windows" {
want = `dws aitable +base-get --base-id REPLACE_BASE_ID --format json`
}
if typed.Actions[0] != want {
t.Fatalf("base id recovery = %q, want %q", typed.Actions[0], want)
}
})
t.Run("base and table ids", func(t *testing.T) {
baseID := `base id;exit 1`
tableID := "table`uname`'x"
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: mustJSON(t, map[string]any{"tableId": tableID})},
{err: errors.New("verify table failed")},
}}
out, err := runAITableCompositeCLI(t, caller, "+table-bootstrap",
"--base-id", baseID, "--name", "任务", "--fields", `[]`, "--yes")
if out != "" || err == nil {
t.Fatalf("hostile table ids recovery = output:%q err:%v", out, err)
}
var typed *apperrors.Error
if !errors.As(err, &typed) || len(typed.Actions) != 1 {
t.Fatalf("hostile table ids error = %#v", err)
}
want := `dws aitable +table-get --base-id 'base id;exit 1' --table-id 'table` + "`uname`" + `'\''x' --format json`
if runtime.GOOS == "windows" {
want = `dws aitable +table-get --base-id REPLACE_BASE_ID --table-id REPLACE_TABLE_ID --format json`
}
if typed.Actions[0] != want {
t.Fatalf("table ids recovery = %q, want %q", typed.Actions[0], want)
}
})
}
func TestCrossPlatformCoverageAITableRecoveryCommandsUseWindowsPlaceholders(t *testing.T) {
tests := []struct {
name string
argv []string
want string
}{
{
name: "query ampersand",
argv: []string{"dws", "aitable", "+base-search", "--query", "x&calc", "--format", "json"},
want: "dws aitable +base-search --query REPLACE_QUERY --format json",
},
{
name: "base id pipe",
argv: []string{"dws", "aitable", "+base-get", "--base-id", "base|whoami", "--format", "json"},
want: "dws aitable +base-get --base-id REPLACE_BASE_ID --format json",
},
{
name: "table id variable expansion",
argv: []string{"dws", "aitable", "+table-get", "--base-id", "base", "--table-id", "%PATH%", "--format", "json"},
want: "dws aitable +table-get --base-id base --table-id REPLACE_TABLE_ID --format json",
},
{
name: "portable values stay inline",
argv: []string{"dws", "aitable", "+table-get", "--base-id", "base-1", "--table-id", "table_2", "--format", "json"},
want: "dws aitable +table-get --base-id base-1 --table-id table_2 --format json",
},
{
name: "unknown argument fallback",
argv: []string{"dws", "aitable", "unsafe value"},
want: "dws aitable REPLACE_VALUE",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := aitableRecoveryCommandForPlatform("windows", tc.argv...)
if got != tc.want {
t.Fatalf("windows recovery = %q, want %q", got, tc.want)
}
for _, hostile := range []string{"&", "|", "%PATH%"} {
if strings.Contains(got, hostile) {
t.Fatalf("windows recovery contains hostile token %q: %s", hostile, got)
}
}
})
}
}
func TestCrossPlatformCoverageBaseBootstrapRecoversFieldCallErrorE2E(t *testing.T) {
fields := bootstrapFields(16)
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
@@ -302,9 +452,21 @@ func TestCrossPlatformCoverageBaseCompositeShapeHelpers(t *testing.T) {
if _, ok := findNamedObjectList(map[string]any{"tables": []any{"bad"}}, "tables"); ok {
t.Fatal("non-object list item must fail")
}
if containsAllFieldNames([]map[string]any{{"fieldName": "A"}}, []any{map[string]any{"fieldName": "B"}}) {
if err := verifyDeclaredFieldStructures([]map[string]any{{"fieldName": "A", "fieldType": "text"}}, []any{map[string]any{"fieldName": "B", "type": "text"}}); err == nil {
t.Fatal("missing field name must fail")
}
if err := verifyDeclaredFieldStructures(nil, []any{"bad"}); err == nil {
t.Fatal("non-object declaration must fail")
}
if declaredValueMatches("bad", map[string]any{"x": 1}) {
t.Fatal("object declaration must not match a scalar")
}
if declaredValueMatches("bad", []any{}) || declaredValueMatches([]any{}, []any{1}) {
t.Fatal("array declaration must require an array of equal length")
}
if declaredValueMatches([]any{1}, []any{2}) {
t.Fatal("array declaration must compare each item")
}
if got := findStringByKeys(map[string]any{"items": []any{map[string]any{"nested": " value "}}}, "nested"); got != "value" {
t.Fatalf("nested array string = %q", got)
}
@@ -4,12 +4,53 @@
package aitable
import (
"encoding/json"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
func aitableCompositeContractWithResult(command, description, useWhen, avoidWhen, example string, result *contract.ResultSpec) corecmd.ContractDecl {
declaration := aitableCompositeContract(command, description, useWhen, avoidWhen, example)
declaration.Result = result
return declaration
}
func aitableTableBootstrapResultSpec() *contract.ResultSpec {
return &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{
contract.ResultOutcomeSuccess,
contract.ResultOutcomeFailure,
},
DataSchema: json.RawMessage(`{
"type":"object",
"description":"AI Table 数据表初始化的执行计划、进度、验证证据和恢复信息",
"properties":{
"contractVersion":{"type":"string","description":"复合操作业务回执版本","const":"aitable.composite.v1"},
"operation":{"type":"string","description":"复合操作标识","const":"table_bootstrap"},
"status":{"type":"string","description":"业务执行状态;失败时同结构位于 error.details.result","enum":["success","planned","partial_success","unknown"]},
"executed":{"type":"boolean","description":"是否已开始执行远端操作"},
"retryable":{"type":"boolean","description":"当前回执是否允许直接重试"},
"requestedCount":{"type":"integer","description":"请求创建的字段数量","minimum":0},
"completedCount":{"type":"integer","description":"已完成并验证的字段数量","minimum":0},
"failedCount":{"type":"integer","description":"已确认失败的字段数量","minimum":0},
"resolved":{"type":"object","description":"已解析的 Base、数据表名称和稳定 ID","additionalProperties":true},
"plan":{"type":"array","description":"dry-run 或执行前生成的有序操作计划","items":{"type":"object","description":"一个计划步骤","properties":{"index":{"type":"integer","description":"步骤序号"},"name":{"type":"string","description":"步骤名称"},"tool":{"type":"string","description":"步骤使用的 MCP 工具"},"status":{"type":"string","description":"步骤状态"},"offset":{"type":"integer","description":"分片起始偏移"},"count":{"type":"integer","description":"步骤处理数量"},"arguments":{"type":"object","description":"步骤参数摘要","additionalProperties":true},"result":{"type":"object","description":"步骤结果摘要","additionalProperties":true},"error":{"type":"string","description":"步骤错误摘要"}},"required":["index","name","status"],"additionalProperties":false}},
"completedSteps":{"type":"array","description":"已完成步骤及其验证摘要","items":{"type":"object","description":"一个已完成步骤","properties":{"index":{"type":"integer","description":"步骤序号"},"name":{"type":"string","description":"步骤名称"},"tool":{"type":"string","description":"步骤使用的 MCP 工具"},"status":{"type":"string","description":"步骤状态"},"offset":{"type":"integer","description":"分片起始偏移"},"count":{"type":"integer","description":"步骤处理数量"},"arguments":{"type":"object","description":"步骤参数摘要","additionalProperties":true},"result":{"type":"object","description":"步骤结果摘要","additionalProperties":true},"error":{"type":"string","description":"步骤错误摘要"}},"required":["index","name","status"],"additionalProperties":false}},
"verification":{"type":"object","description":"创建后读回验证证据","additionalProperties":true},
"checkpoint":{"type":"object","description":"失败或部分成功后的恢复检查点","additionalProperties":true},
"nextCommand":{"type":"string","description":"用于核验或恢复的下一条可执行命令"},
"knownSideEffects":{"type":"array","description":"已确认发生的远端副作用","items":{"type":"object","description":"一个已确认副作用","additionalProperties":true}},
"warnings":{"type":"array","description":"不阻断最终验证的警告信息","items":{"type":"string"}},
"result":{"type":"object","description":"成功时创建并验证的数据表结构","properties":{"baseId":{"type":"string","description":"目标 Base ID"},"tableId":{"type":"string","description":"新数据表稳定 ID"},"tableName":{"type":"string","description":"新数据表名称"},"fields":{"type":"array","description":"读回验证后的字段结构","items":{"type":"object","description":"一个已验证字段","additionalProperties":true}}},"required":["baseId","tableId","tableName","fields"],"additionalProperties":true}
},
"required":["contractVersion","operation","status","executed","retryable"],
"additionalProperties":false
}`),
}
}
func aitableCompositeContract(command, description, useWhen, avoidWhen, example string) corecmd.ContractDecl {
name := strings.TrimPrefix(command, "+")
name = strings.ReplaceAll(name, "-", "_")
+57 -4
View File
@@ -5,8 +5,11 @@ package aitable
import (
"fmt"
"runtime"
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
)
const aitableCompositeContractVersion = "aitable.composite.v1"
@@ -52,20 +55,70 @@ func newCompositeResult(operation string) compositeResult {
}
}
func aitableRecoveryCommand(argv ...string) string {
return aitableRecoveryCommandForPlatform(runtime.GOOS, argv...)
}
func aitableRecoveryCommandForPlatform(goos string, argv ...string) string {
quoted := make([]string, len(argv))
for index, arg := range argv {
// POSIX 单引号不能保护 cmd.exe;Windows 下只允许跨 shell 均可裸放的
// 保守白名单值进入可复制命令,其余值改成不含 shell 元字符的占位符。
if goos == "windows" && helpers.ShellQuoteArg(arg) != arg {
quoted[index] = aitableRecoveryPlaceholder(argv, index)
continue
}
quoted[index] = helpers.ShellQuoteArg(arg)
}
return strings.Join(quoted, " ")
}
func aitableRecoveryPlaceholder(argv []string, index int) string {
if index > 0 {
switch argv[index-1] {
case "--query":
return "REPLACE_QUERY"
case "--base-id":
return "REPLACE_BASE_ID"
case "--table-id":
return "REPLACE_TABLE_ID"
}
}
return "REPLACE_VALUE"
}
func compositeError(result compositeResult, cause error, retryable bool) error {
result.Retryable = retryable
if result.Status == "success" {
result.Status = "unknown"
}
return apperrors.NewAPI(fmt.Sprintf("AI Table composite %s ended with status %s", result.Operation, result.Status),
apperrors.WithOperation("aitable."+result.Operation),
options := []apperrors.Option{
apperrors.WithOperation("aitable." + result.Operation),
apperrors.WithOrigin("mcp"),
apperrors.WithFailureStage("composite_execution"),
apperrors.WithExecutionStarted(result.Executed),
apperrors.WithRetryable(retryable),
apperrors.WithReason("aitable_composite_"+result.Status),
apperrors.WithReason("aitable_composite_" + result.Status),
apperrors.WithHint("inspect error.details.result before retrying; unknown means the remote effect could not be proven"),
apperrors.WithDetails(map[string]any{"result": result}),
apperrors.WithCause(cause),
)
}
if result.NextCommand != "" {
options = append(options, apperrors.WithActions(result.NextCommand))
}
if flags := compositeRecoveryFlags(result.Operation); len(flags) > 0 {
options = append(options, apperrors.WithAvailableFlags(flags...))
}
return apperrors.NewAPI(fmt.Sprintf("AI Table composite %s ended with status %s", result.Operation, result.Status), options...)
}
func compositeRecoveryFlags(operation string) []string {
switch operation {
case "base_bootstrap":
return []string{"name", "folder-id", "template-id", "tables"}
case "table_bootstrap":
return []string{"base-id", "name", "fields"}
default:
return nil
}
}
@@ -321,6 +321,33 @@ func runRecordQueryShortcutCLI(t *testing.T, caller *upsertByKeyCaller, limit in
return payload, err
}
func TestCrossPlatformCoverageRecordQueryProjectsRequestedFieldsE2E(t *testing.T) {
caller := &upsertByKeyCaller{}
caller.callFn = func(_ int, product, tool string, args map[string]any) (string, error) {
if product != serverMain || tool != "query_records" {
t.Fatalf("record projection call = %s/%s", product, tool)
}
recordIDs, _ := args["recordIds"].([]string)
fieldIDs, _ := args["fieldIds"].([]string)
if !slices.Equal(recordIDs, []string{"r1", "r2"}) || !slices.Equal(fieldIDs, []string{"fldName", "fldStatus"}) {
t.Fatalf("record projection args = %#v", args)
}
return pagedRecordQueryResponse(t, []map[string]any{
{"recordId": "r1", "cells": map[string]any{"fldName": "青云制造", "fldStatus": "跟进中"}},
{"recordId": "r2", "cells": map[string]any{"fldName": "远海物流", "fldStatus": "已成交"}},
}, args), nil
}
payload, err := runRecordQueryShortcutCLI(t, caller, 2,
"--record-ids", "r1,r2", "--field-ids", "fldName,fldStatus")
if err != nil || payload["success"] != true || len(caller.calls) != 1 {
t.Fatalf("record projection = payload:%#v err:%v calls:%#v", payload, err, caller.calls)
}
raw, err := json.Marshal(payload)
if err != nil || bytes.Contains(raw, []byte("fldAmount")) {
t.Fatalf("record projection leaked an unrequested field: %s (err=%v)", raw, err)
}
}
func TestCrossPlatformCoverageRecordQueryServicePageBoundariesE2E(t *testing.T) {
for _, size := range []int{20, 21, 22, 100} {
t.Run(fmt.Sprintf("size_%d", size), func(t *testing.T) {
@@ -0,0 +1,176 @@
// Copyright 2026 Alibaba Group
// SPDX-License-Identifier: Apache-2.0
package aitable
import (
"fmt"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
var TableBootstrap = shortcut.Shortcut{
OutputRollout: output.RolloutUnifiedActive,
Service: "aitable",
Command: "+table-bootstrap",
Product: serverMain,
Description: "在已有 Base 中一次创建数据表和字段,自动分片并读回验证",
Intent: "当你已有 baseId、需要新增一张带完整字段结构的数据表时使用;替代 table create 后连续 field create 和手工验证。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "non_idempotent",
},
Contract: aitableCompositeContractWithResult(
"+table-bootstrap",
"在已有 Base 中一次创建数据表和字段,自动分片并读回验证",
"当你已有 baseId、需要新增一张带完整字段结构的数据表时使用;替代 table create 后连续 field create 和手工验证。",
"需要同时新建 Base 用 +base-bootstrap;复制现有表用 +table-copy;只补字段用 field create",
`dws aitable +table-bootstrap --base-id BASE_ID --name "任务" --fields '[{"fieldName":"标题","type":"text"}]'`,
aitableTableBootstrapResultSpec(),
),
Flags: []shortcut.Flag{
{Name: "base-id", Type: shortcut.FlagString, Desc: "目标 Base ID", Required: true},
{Name: "name", Type: shortcut.FlagString, Desc: "新数据表名称", Required: true},
{Name: "fields", Type: shortcut.FlagString, Desc: "字段结构 JSON 数组;字段对象使用 fieldName/type/config", Required: true},
},
Tips: []string{
`dws aitable +table-bootstrap --base-id BASE_ID --name "任务" --fields '[{"fieldName":"标题","type":"text"}]'`,
},
Execute: func(rt *shortcut.RuntimeContext) error {
return executeTableBootstrap(rt)
},
}
type createdTableStructure struct {
TableID string
Fields []map[string]any
Warnings []string
}
func parseBootstrapFields(raw string) ([]any, error) {
value, err := parseJSONAny("fields", raw)
if err != nil {
return nil, tableBootstrapValidation(fmt.Sprintf("--fields 不是合法 JSON 数组:%v", err))
}
fields, ok := value.([]any)
if !ok {
return nil, tableBootstrapValidation("--fields 必须是 JSON 数组")
}
if len(fields) > 100 {
return nil, tableBootstrapValidation("--fields 最多接受 100 个字段")
}
seen := map[string]bool{}
for index, rawField := range fields {
field, ok := rawField.(map[string]any)
name := strings.TrimSpace(stringValue(field, "fieldName", "name"))
if !ok || name == "" || strings.TrimSpace(stringValue(field, "type")) == "" {
return nil, tableBootstrapValidation(fmt.Sprintf("--fields[%d] 必须包含 fieldName 和 type", index))
}
if seen[name] {
return nil, tableBootstrapValidation(fmt.Sprintf("--fields[%d].fieldName %q 不能重复", index, name))
}
seen[name] = true
if config, exists := field["config"]; exists {
if _, ok := config.(map[string]any); !ok {
return nil, tableBootstrapValidation(fmt.Sprintf("--fields[%d].config 必须是 JSON 对象", index))
}
}
}
return fields, nil
}
func tableBootstrapValidation(message string) error {
return apperrors.NewValidation(message,
apperrors.WithHint("字段对象使用 fieldName/type/config;已知参数时直接执行,不需要先调用 --help"),
apperrors.WithActions(`dws aitable +table-bootstrap --base-id BASE_ID --name "任务" --fields '[{"fieldName":"标题","type":"text"}]'`),
apperrors.WithAvailableFlags("base-id", "name", "fields"),
)
}
func executeTableBootstrap(rt *shortcut.RuntimeContext) error {
fields, err := parseBootstrapFields(rt.Str("fields"))
if err != nil {
return err
}
baseID, tableName := rt.Str("base-id"), rt.Str("name")
result := newCompositeResult("table_bootstrap")
result.RequestedCount = len(fields)
result.Resolved = map[string]any{"baseId": baseID, "tableName": tableName}
result.Plan = []compositeStep{{Index: 1, Name: "create and verify table", Tool: "create_table", Status: "planned", Count: len(fields)}}
if rt.DryRun() {
result.Status = "planned"
result.Executed = false
return rt.Output(result)
}
created, err := createAndVerifyTableStructure(rt, baseID, tableName, fields)
result.Warnings = append(result.Warnings, created.Warnings...)
if created.TableID != "" {
result.Resolved["tableId"] = created.TableID
result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": "create_table", "baseId": baseID, "tableId": created.TableID, "name": tableName})
result.NextCommand = aitableRecoveryCommand("dws", "aitable", "+table-get", "--base-id", baseID, "--table-id", created.TableID, "--format", "json")
}
if err != nil {
if created.TableID == "" {
result.Status = "unknown"
result.Checkpoint = map[string]any{"step": "resolve table by exact name before retrying", "baseId": baseID, "tableName": tableName}
} else {
result.Status = "partial_success"
result.Checkpoint = map[string]any{"step": "verify or repair table fields", "baseId": baseID, "tableId": created.TableID}
}
return compositeError(result, err, false)
}
result.CompletedCount = len(fields)
result.CompletedSteps = []compositeStep{{Index: 1, Name: "create and verify table", Tool: "create_table", Status: "completed", Count: len(fields), Result: map[string]any{"tableId": created.TableID, "fieldCount": len(created.Fields)}}}
result.Verification = map[string]any{"status": "verified", "baseId": baseID, "tableId": created.TableID, "fieldCount": len(created.Fields)}
result.Result = map[string]any{"baseId": baseID, "tableId": created.TableID, "tableName": tableName, "fields": created.Fields}
result.NextCommand = ""
return rt.Output(result)
}
func createAndVerifyTableStructure(rt *shortcut.RuntimeContext, baseID, tableName string, fields []any) (createdTableStructure, error) {
created := createdTableStructure{}
initialEnd := minInt(15, len(fields))
createData, err := rt.CallMCPWriteDataStrict(serverMain, "create_table", map[string]any{
"baseId": baseID, "tableName": tableName, "fields": fields[:initialEnd],
})
created.TableID = findStringByKeys(createData, "tableId", "sheetId")
if err != nil || created.TableID == "" {
if err == nil {
err = fmt.Errorf("create_table response is missing tableId")
}
return created, err
}
for offset := initialEnd; offset < len(fields); offset += 15 {
end := minInt(offset+15, len(fields))
if _, fieldErr := rt.CallMCPWriteDataStrict(serverMain, "create_fields", map[string]any{
"baseId": baseID, "tableId": created.TableID, "fields": fields[offset:end],
}); fieldErr != nil {
created.Warnings = append(created.Warnings, fmt.Sprintf("create_fields offset %d returned an error; final read-back decides success: %v", offset, fieldErr))
}
}
detail, err := rt.CallMCPData(serverMain, "get_tables", map[string]any{"baseId": baseID, "tableIds": []string{created.TableID}})
if err != nil || !deepContainsString(detail, created.TableID) {
if err == nil {
err = fmt.Errorf("get_tables does not identify created tableId %s", created.TableID)
}
return created, err
}
fieldsData, err := rt.CallMCPData(serverMain, "get_fields", map[string]any{"baseId": baseID, "tableId": created.TableID})
created.Fields, _ = findNamedObjectList(fieldsData, "fields", "fieldList")
if err == nil && created.Fields == nil {
err = fmt.Errorf("field read-back for table %s is missing the fields collection", created.TableID)
}
if err == nil {
err = verifyDeclaredFieldStructures(created.Fields, fields)
}
if err != nil {
return created, err
}
return created, nil
}
@@ -0,0 +1,245 @@
// Copyright 2026 Alibaba Group
// SPDX-License-Identifier: Apache-2.0
package aitable
import (
"encoding/json"
"errors"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
func TestCrossPlatformCoverageTableBootstrapPublishesResultContract(t *testing.T) {
result, err := contract.NormalizeResultSpec(TableBootstrap.Contract.Result, "aitable.shortcut_table_bootstrap")
if err != nil {
t.Fatalf("normalize table bootstrap Result: %v", err)
}
wantOutcomes := []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}
if result == nil || !reflect.DeepEqual(result.Outcomes, wantOutcomes) {
t.Fatalf("table bootstrap outcomes = %#v, want %#v", result, wantOutcomes)
}
var schema map[string]any
if err := json.Unmarshal(result.DataSchema, &schema); err != nil {
t.Fatalf("decode table bootstrap data_schema: %v", err)
}
properties, _ := schema["properties"].(map[string]any)
status, _ := properties["status"].(map[string]any)
if got, want := mustJSON(t, status["enum"]), `["success","planned","partial_success","unknown"]`; got != want {
t.Fatalf("table bootstrap status enum = %s, want %s", got, want)
}
for _, property := range []string{"contractVersion", "operation", "executed", "retryable", "plan", "completedSteps", "verification", "checkpoint", "knownSideEffects", "result"} {
if properties[property] == nil {
t.Errorf("table bootstrap data_schema is missing %q", property)
}
}
}
func TestCrossPlatformCoverageTableBootstrapCreatesChunksAndVerifies(t *testing.T) {
fields := bootstrapFields(16)
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"data":{"tableId":"table-new"}}`},
{text: `{"data":{"createdFields":[{"fieldId":"f16"}]}}`},
{text: `{"data":{"tables":[{"tableId":"table-new","tableName":"任务"}]}}`},
{text: fieldReadBackJSON(t, fields)},
}}
out, err := runAITableCompositeCLI(t, caller, "+table-bootstrap",
"--base-id", "base", "--name", "任务", "--fields", mustJSON(t, fields), "--yes")
if err != nil {
t.Fatalf("table bootstrap error = %v", err)
}
for _, want := range []string{`"operation": "table_bootstrap"`, `"tableId": "table-new"`, `"status": "verified"`} {
if !strings.Contains(out, want) {
t.Fatalf("table bootstrap output missing %s: %s", want, out)
}
}
if got := len(caller.calls); got != 4 {
t.Fatalf("table bootstrap calls = %d, want 4: %#v", got, caller.calls)
}
wantFirstArgs := map[string]any{
"baseId": "base",
"tableName": "任务",
"fields": fields[:15],
}
if caller.calls[0].product != serverMain || caller.calls[0].tool != "create_table" || mustJSON(t, caller.calls[0].args) != mustJSON(t, wantFirstArgs) {
t.Fatalf("table bootstrap first call = %#v, want product:%q tool:create_table args:%s", caller.calls[0], serverMain, mustJSON(t, wantFirstArgs))
}
if caller.calls[0].tool != "create_table" || caller.calls[1].tool != "create_fields" || caller.calls[2].tool != "get_tables" || caller.calls[3].tool != "get_fields" {
t.Fatalf("table bootstrap call order = %#v", caller.calls)
}
if got := len(caller.calls[0].args["fields"].([]any)); got != 15 {
t.Fatalf("initial fields = %d, want 15", got)
}
if got := len(caller.calls[1].args["fields"].([]any)); got != 1 {
t.Fatalf("remaining fields = %d, want 1", got)
}
}
func TestCrossPlatformCoverageTableBootstrapRequiresConfirmationBeforeMCP(t *testing.T) {
caller := &upsertByKeyCaller{}
out, err := runAITableCompositeCLI(t, caller, "+table-bootstrap",
"--base-id", "base", "--name", "任务", "--fields", mustJSON(t, bootstrapFields(1)))
if out != "" {
t.Fatalf("unconfirmed table bootstrap output = %q, want empty", out)
}
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "confirmation_required" {
t.Fatalf("unconfirmed table bootstrap error = %#v, want confirmation_required", err)
}
if len(caller.calls) != 0 {
t.Fatalf("unconfirmed table bootstrap calls = %#v, want none", caller.calls)
}
}
func TestCrossPlatformCoverageTableBootstrapValidationPublishesRecovery(t *testing.T) {
caller := &upsertByKeyCaller{}
out, err := runAITableCompositeCLI(t, caller, "+table-bootstrap",
"--base-id", "base", "--name", "任务", "--fields", `{}`, "--yes")
if out != "" || err == nil || len(caller.calls) != 0 {
t.Fatalf("validation = output:%q err:%v calls:%#v", out, err, caller.calls)
}
var typed *apperrors.Error
if !errors.As(err, &typed) || len(typed.Actions) != 1 || len(typed.AvailableFlags) != 3 {
t.Fatalf("typed validation recovery = %#v", err)
}
}
func TestCrossPlatformCoverageTableBootstrapInputValidation(t *testing.T) {
tooMany := bootstrapFields(101)
cases := map[string]string{
"invalid JSON": `{`,
"not an array": `{}`,
"too many fields": mustJSON(t, tooMany),
"non-object field": `[1]`,
"field missing type": `[{"fieldName":"标题"}]`,
"duplicate name": `[{"fieldName":"标题","type":"text"},{"fieldName":" 标题 ","type":"number"}]`,
"config not object": `[{"fieldName":"标题","type":"text","config":[]}]`,
}
for name, raw := range cases {
t.Run(name, func(t *testing.T) {
fields, err := parseBootstrapFields(raw)
if err == nil || fields != nil {
t.Fatalf("parseBootstrapFields(%q) = %#v, %v", raw, fields, err)
}
var typed *apperrors.Error
if !errors.As(err, &typed) || len(typed.Actions) != 1 || len(typed.AvailableFlags) != 3 {
t.Fatalf("typed validation recovery = %#v", err)
}
})
}
}
func TestCrossPlatformCoverageTableBootstrapRejectsDuplicateFieldsBeforeMCP(t *testing.T) {
caller := &upsertByKeyCaller{}
out, err := runAITableCompositeCLI(t, caller, "+table-bootstrap",
"--base-id", "base", "--name", "任务",
"--fields", `[{"fieldName":"标题","type":"text"},{"fieldName":"标题","type":"number"}]`, "--yes")
if out != "" || err == nil || len(caller.calls) != 0 {
t.Fatalf("duplicate field validation = output:%q err:%v calls:%#v", out, err, caller.calls)
}
}
func TestCrossPlatformCoverageTableBootstrapVerifiesTypeAndDeclaredConfig(t *testing.T) {
fields := []any{map[string]any{
"fieldName": "状态",
"type": "singleSelect",
"config": map[string]any{
"options": []any{map[string]any{"name": "待办"}},
},
}}
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"tableId":"table-new"}`},
{text: `{"tables":[{"tableId":"table-new"}]}`},
{text: `{"fields":[{"fieldId":"field-1","fieldName":"状态","fieldType":"singleSelect","config":{"options":[{"name":"待办","optionId":"option-1"}],"extra":true}}]}`},
}}
out, err := runAITableCompositeCLI(t, caller, "+table-bootstrap",
"--base-id", "base", "--name", "任务", "--fields", mustJSON(t, fields), "--yes")
if err != nil || !strings.Contains(out, `"status": "verified"`) {
t.Fatalf("typed config verification = output:%q err:%v", out, err)
}
}
func TestCrossPlatformCoverageTableBootstrapDryRun(t *testing.T) {
caller := &upsertByKeyCaller{dryRun: true}
out, err := runAITableCompositeCLI(t, caller, "+table-bootstrap",
"--base-id", "base", "--name", "任务", "--fields", `[]`, "--dry-run")
if err != nil || len(caller.calls) != 0 || !strings.Contains(out, `"status": "planned"`) {
t.Fatalf("table bootstrap dry run = output:%q err:%v calls:%#v", out, err, caller.calls)
}
}
func TestCrossPlatformCoverageTableBootstrapFailureStages(t *testing.T) {
oneField := bootstrapFields(1)
cases := []struct {
name string
fields []any
steps []upsertByKeyStep
}{
{name: "create table error", fields: oneField, steps: []upsertByKeyStep{{err: errors.New("create table failed")}}},
{name: "create table missing id", fields: oneField, steps: []upsertByKeyStep{{text: `{}`}}},
{name: "get tables error", fields: oneField, steps: []upsertByKeyStep{{text: `{"tableId":"table-new"}`}, {err: errors.New("get tables failed")}}},
{name: "get tables wrong id", fields: oneField, steps: []upsertByKeyStep{{text: `{"tableId":"table-new"}`}, {text: `{"tables":[{"tableId":"other"}]}`}}},
{name: "get fields error", fields: oneField, steps: []upsertByKeyStep{{text: `{"tableId":"table-new"}`}, {text: `{"tables":[{"tableId":"table-new"}]}`}, {err: errors.New("get fields failed")}}},
{name: "get fields missing collection", fields: oneField, steps: []upsertByKeyStep{{text: `{"tableId":"table-new"}`}, {text: `{"tables":[{"tableId":"table-new"}]}`}, {text: `{}`}}},
{name: "get fields mismatch", fields: oneField, steps: []upsertByKeyStep{{text: `{"tableId":"table-new"}`}, {text: `{"tables":[{"tableId":"table-new"}]}`}, {text: `{"fields":[]}`}}},
{name: "get fields type mismatch", fields: oneField, steps: []upsertByKeyStep{{text: `{"tableId":"table-new"}`}, {text: `{"tables":[{"tableId":"table-new"}]}`}, {text: `{"fields":[{"fieldName":"F00","fieldType":"number"}]}`}}},
{name: "get fields config mismatch", fields: []any{map[string]any{"fieldName": "状态", "type": "singleSelect", "config": map[string]any{"multiple": false}}}, steps: []upsertByKeyStep{{text: `{"tableId":"table-new"}`}, {text: `{"tables":[{"tableId":"table-new"}]}`}, {text: `{"fields":[{"fieldName":"状态","fieldType":"singleSelect","config":{"multiple":true}}]}`}}},
{name: "get fields duplicate name", fields: oneField, steps: []upsertByKeyStep{{text: `{"tableId":"table-new"}`}, {text: `{"tables":[{"tableId":"table-new"}]}`}, {text: `{"fields":[{"fieldName":"F00","fieldType":"text"},{"fieldName":"F00","fieldType":"text"}]}`}}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
caller := &upsertByKeyCaller{steps: tc.steps}
out, err := runAITableCompositeCLI(t, caller, "+table-bootstrap",
"--base-id", "base", "--name", "任务", "--fields", mustJSON(t, tc.fields), "--yes")
if out != "" || err == nil {
t.Fatalf("table bootstrap failure = output:%q err:%v calls:%#v", out, err, caller.calls)
}
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Retryable {
t.Fatalf("typed table bootstrap failure = %#v", err)
}
})
}
}
func TestCrossPlatformCoverageTableBootstrapRecoversFieldCallError(t *testing.T) {
fields := bootstrapFields(16)
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"tableId":"table-new"}`},
{err: errors.New("create fields reply failed")},
{text: `{"tables":[{"tableId":"table-new"}]}`},
{text: fieldReadBackJSON(t, fields)},
}}
out, err := runAITableCompositeCLI(t, caller, "+table-bootstrap",
"--base-id", "base", "--name", "任务", "--fields", mustJSON(t, fields), "--yes")
if err != nil || !strings.Contains(out, `"status": "success"`) || !strings.Contains(out, "create_fields offset") {
t.Fatalf("field recovery = output:%q err:%v calls:%#v", out, err, caller.calls)
}
}
func TestCrossPlatformCoverageCompositeRecoveryFlags(t *testing.T) {
cases := map[string][]string{
"base_bootstrap": {"name", "folder-id", "template-id", "tables"},
"table_bootstrap": {"base-id", "name", "fields"},
"base_schema_snapshot_unmapped": nil,
}
for operation, want := range cases {
got := compositeRecoveryFlags(operation)
if mustJSON(t, got) != mustJSON(t, want) {
t.Fatalf("compositeRecoveryFlags(%q) = %#v, want %#v", operation, got, want)
}
}
}
func mustJSON(t *testing.T, value any) string {
t.Helper()
raw, err := json.Marshal(value)
if err != nil {
t.Fatal(err)
}
return string(raw)
}
+7 -4
View File
@@ -136,10 +136,13 @@ func executeTableCopy(rt *shortcut.RuntimeContext) error {
}
targetFieldsData, err := rt.CallMCPData(serverMain, "get_fields", map[string]any{"baseId": targetBase, "tableId": targetTable})
targetFields, found := findNamedObjectList(targetFieldsData, "fields", "fieldList")
if err != nil || !found || !containsAllFieldNames(targetFields, createFields) {
if err == nil {
err = fmt.Errorf("target field read-back does not contain the copied field set")
}
if err == nil && !found {
err = fmt.Errorf("target field read-back is missing the fields collection")
}
if err == nil {
err = verifyDeclaredFieldStructures(targetFields, createFields)
}
if err != nil {
result.Status = "partial_success"
result.Checkpoint = map[string]any{"targetTableId": targetTable, "step": "repair target fields"}
return compositeError(result, err, false)
@@ -88,11 +88,16 @@ func TestCrossPlatformCoverageTableCopySchemaFailureStagesE2E(t *testing.T) {
steps []upsertByKeyStep
}{
{name: "target fields error", steps: []upsertByKeyStep{{text: source}, {text: `{"tableId":"target"}`}, {err: errors.New("target fields failed")}}},
{name: "target fields missing collection", steps: []upsertByKeyStep{{text: source}, {text: `{"tableId":"target"}`}, {text: `{}`}}},
{name: "target fields mismatch", steps: []upsertByKeyStep{{text: source}, {text: `{"tableId":"target"}`}, {text: `{"fields":[]}`}}},
{name: "target mapping duplicate", steps: []upsertByKeyStep{
{text: source}, {text: `{"tableId":"target"}`},
{text: `{"fields":[{"fieldId":"t1","fieldName":"F0","type":"text"},{"fieldId":"t2","fieldName":"F0","type":"text"}]}`},
}},
{name: "target mapping missing id", steps: []upsertByKeyStep{
{text: source}, {text: `{"tableId":"target"}`},
{text: `{"fields":[{"fieldName":"F0","type":"text"}]}`},
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
@@ -36,8 +36,8 @@ func TestCrossPlatformCoverageAITableSemanticCatalogExactlyCoversRegisteredSurfa
}
registered[item.Command] = item
}
if len(registered) != 92 || len(source.Shortcuts) != 92 {
t.Fatalf("registered/catalog = %d/%d, want 92/92", len(registered), len(source.Shortcuts))
if len(registered) != 93 || len(source.Shortcuts) != 93 {
t.Fatalf("registered/catalog = %d/%d, want 93/93", len(registered), len(source.Shortcuts))
}
var missing, stale []string
@@ -75,6 +75,7 @@ func generatedPublicShortcutCatalog() map[string]struct{} {
"aitable\u0000+section-move-node": {},
"aitable\u0000+section-rename": {},
"aitable\u0000+section-reorder": {},
"aitable\u0000+table-bootstrap": {},
"aitable\u0000+table-copy": {},
"aitable\u0000+table-delete": {},
"aitable\u0000+table-get": {},
+14 -6
View File
@@ -14,6 +14,7 @@
package shortcut
import (
"context"
"encoding/json"
"fmt"
"strings"
@@ -160,9 +161,9 @@ func (rt *RuntimeContext) CallMCP(tool string, params map[string]any) error {
// The legacy caller owns dry-run presentation (including its human
// preview for non-JSON formats) and does not cross the business-call
// boundary. Keep using it so dual validation changes no bytes.
return helpers.CallMCPToolOnServer(rt.shortcut.product(), tool, params)
return helpers.CallMCPToolOnServerContext(rt.commandContext(), rt.shortcut.product(), tool, params)
}
text, err := helpers.CallMCPToolTextOnServer(rt.shortcut.product(), tool, params)
text, err := helpers.CallMCPToolTextOnServerContext(rt.commandContext(), rt.shortcut.product(), tool, params)
if err != nil {
return err
}
@@ -175,7 +176,7 @@ func (rt *RuntimeContext) CallMCP(tool string, params map[string]any) error {
// shadow unified result.
return helpers.RenderLegacyMCPText(tool, text)
}
return helpers.CallMCPToolOnServer(rt.shortcut.product(), tool, params)
return helpers.CallMCPToolOnServerContext(rt.commandContext(), rt.shortcut.product(), tool, params)
}
func legacyMCPPayload(text string) any {
@@ -256,7 +257,7 @@ func (rt *RuntimeContext) callMCPData(product, tool string, params map[string]an
if params == nil {
params = map[string]any{}
}
text, err := helpers.CallMCPToolTextOnServer(product, tool, params)
text, err := helpers.CallMCPToolTextOnServerContext(rt.commandContext(), product, tool, params)
if err != nil {
return nil, err
}
@@ -274,7 +275,7 @@ func (rt *RuntimeContext) callMCPReadData(product, tool string, params map[strin
if params == nil {
params = map[string]any{}
}
text, err := helpers.CallMCPReadToolTextOnServer(product, tool, params)
text, err := helpers.CallMCPReadToolTextOnServerContext(rt.commandContext(), product, tool, params)
if err != nil {
return nil, err
}
@@ -292,7 +293,7 @@ func (rt *RuntimeContext) callMCPWriteData(product, tool string, params map[stri
if params == nil {
params = map[string]any{}
}
text, err := helpers.CallMCPToolTextOnServer(product, tool, params)
text, err := helpers.CallMCPToolTextOnServerContext(rt.commandContext(), product, tool, params)
if err != nil {
return nil, err
}
@@ -312,6 +313,13 @@ func (rt *RuntimeContext) callMCPWriteData(product, tool string, params map[stri
return out, nil
}
func (rt *RuntimeContext) commandContext() context.Context {
if rt != nil && rt.cmd != nil && rt.cmd.Context() != nil {
return rt.cmd.Context()
}
return context.Background()
}
// Output prints a (typically reshaped/projected) payload honouring the root
// --format/--jq/--fields flags. Multi-step shortcuts use it to emit a clean,
// composed result instead of the raw MCP response — the output-projection
+57
View File
@@ -14,9 +14,26 @@ import (
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
type runtimeContextKey struct{}
type runtimeContextCaller struct {
value any
}
func (c *runtimeContextCaller) CallTool(ctx context.Context, _ string, _ string, _ map[string]any) (*edition.ToolResult, error) {
c.value = ctx.Value(runtimeContextKey{})
return nil, errors.New("stop after context capture")
}
func (*runtimeContextCaller) Format() string { return "json" }
func (*runtimeContextCaller) DryRun() bool { return false }
func (*runtimeContextCaller) Fields() string { return "" }
func (*runtimeContextCaller) JQ() string { return "" }
func TestCrossPlatformCoverageRuntimeContextForTest(t *testing.T) {
cmd := &cobra.Command{Use: "run"}
rt := RuntimeContextForTest(cmd, Shortcut{Service: "sample", Command: "run"})
@@ -25,6 +42,46 @@ func TestCrossPlatformCoverageRuntimeContextForTest(t *testing.T) {
}
}
func TestCrossPlatformCoverageRuntimeMCPCallsPreserveCommandContext(t *testing.T) {
caller := &runtimeContextCaller{}
old := helpers.GetCaller()
t.Cleanup(func() { helpers.InitDeps(old) })
helpers.InitDeps(caller)
ctx := context.WithValue(context.Background(), runtimeContextKey{}, "command-context")
cmd := &cobra.Command{Use: "+write"}
cmd.SetContext(ctx)
output.SetCommandRollout(cmd, output.RolloutDualValidate)
rt := RuntimeContextForTest(cmd, Shortcut{Service: "aitable", Command: "+write"})
if err := rt.CallMCP("update_records", map[string]any{"id": "r1"}); err == nil {
t.Fatal("dual-validate context capture unexpectedly succeeded")
}
if caller.value != "command-context" {
t.Fatalf("dual-validate caller context value = %#v", caller.value)
}
if _, err := rt.CallMCPWriteDataStrict("aitable", "update_records", map[string]any{"id": "r1"}); err == nil {
t.Fatal("capture caller unexpectedly succeeded")
}
if caller.value != "command-context" {
t.Fatalf("caller context value = %#v", caller.value)
}
dryCaller := &dualValidateCaller{format: "json", dryRun: true}
helpers.InitDeps(dryCaller)
dryCmd := &cobra.Command{Use: "+read"}
dryCmd.Flags().Bool("dry-run", true, "")
output.SetCommandRollout(dryCmd, output.RolloutDualValidate)
var dryOut bytes.Buffer
helpers.GetFormatter().SetWriters(&dryOut, &dryOut)
dryRT := RuntimeContextForTest(dryCmd, Shortcut{Service: "aitable", Command: "+read"})
if err := dryRT.CallMCP("get_fields", map[string]any{"baseId": "b"}); err != nil {
t.Fatalf("dual-validate dry-run call = %v", err)
}
if !strings.Contains(dryOut.String(), `"dry_run": true`) {
t.Fatalf("dual-validate dry-run output = %q", dryOut.String())
}
}
func TestShortcutCommandResultRejectsStringSuccess(t *testing.T) {
result := shortcutCommandResult(map[string]any{"success": "false"})
env, err := output.EnvelopeFromResult(result)
@@ -15,6 +15,7 @@
"+url-resolve": {"disposition":"primary_smart","semantic_delta":"严格解析 alidocs AI 表格 URL 的 base/table/view/record ID,并可通过最深层只读接口验证目标存在。","risk":"read","public":true,"reviewed":true},
"+table-get": {"disposition":"semantic_adapter","semantic_delta":"批量读取表级信息、字段和视图目录,为后续 Schema 与记录操作提供 ID。","risk":"read","public":true,"reviewed":true},
"+table-bootstrap": {"disposition":"primary_smart","semantic_delta":"在已有 Base 中声明式创建一张表和字段,字段自动分片,逐层读回验证并发布可恢复检查点。","risk":"write","public":true,"reviewed":true},
"+table-copy": {"disposition":"primary_smart","semantic_delta":"在没有服务端 table-copy task 的情况下,本地重建安全字段、映射 fieldId,并可分片复制及读回验证全部记录。","risk":"write","public":true,"reviewed":true},
"+table-update": {"disposition":"schema_leaf","semantic_delta":"统一表名、备注和行命名规则的 PATCH 风格更新。","risk":"write","public":true,"reviewed":true},
"+table-delete": {"disposition":"schema_leaf","semantic_delta":"不可逆删除数据表的一对一入口,显式发布高风险确认事实。","risk":"high-risk-write","public":true,"reviewed":true},
@@ -23,7 +24,7 @@
"+field-update": {"disposition":"semantic_adapter","semantic_delta":"统一字段名称、类型配置和 AI 配置的增量更新输入。","risk":"write","public":true,"reviewed":true},
"+field-delete": {"disposition":"schema_leaf","semantic_delta":"不可逆删除字段的一对一入口,显式发布数据丢失风险。","risk":"high-risk-write","public":true,"reviewed":true},
"+record-query": {"disposition":"semantic_adapter","semantic_delta":"统一 ID、筛选、关键词和分页查询,并投影稳定的 records/count/cursor 结果。","risk":"read","public":true,"reviewed":true},
"+record-query": {"disposition":"semantic_adapter","semantic_delta":"统一 ID、筛选、关键词、字段投影和分页查询;fieldIds 只返回用户要求的列,并投影稳定的 records/count/cursor 结果。","risk":"read","public":true,"reviewed":true},
"+record-update": {"disposition":"primary_smart","semantic_delta":"自动按 100 条分片更新并逐条读回验证;失败返回可续跑的 nextOffset。","risk":"write","public":true,"reviewed":true},
"+record-delete": {"disposition":"primary_smart","semantic_delta":"自动按 100 条分片删除;每批仅在所有目标 recordId 读回均不存在时成功,失败提供 nextOffset。","risk":"high-risk-write","public":true,"reviewed":true},
"+record-query-empty": {"disposition":"semantic_adapter","semantic_delta":"扫描记录并按用户字段为空的语义过滤空行,避免调用方手工判断 cells。","risk":"read","public":true,"reviewed":true},
+273
View File
@@ -0,0 +1,273 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package wait is the framework terminal-state wait engine behind the
// reviewed contract.WaitSpec capability. It owns polling cadence, status
// extraction, and status→outcome mapping; it knows nothing about Cobra, MCP,
// or any product backend. How one poll executes is supplied by the leaf's
// WaitPoll hook (corecmd), so "poll = an existing read command" stays a leaf
// decision rather than a framework assumption.
package wait
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
// DefaultPollInterval is the cadence between polls when LoopSpec.Interval is
// zero. The first poll runs immediately so an already-terminal resource does
// not pay a sleep tax.
const DefaultPollInterval = 2 * time.Second
// MaxPollInterval caps the exponential backoff growth between polls so a long
// wait cannot degenerate into effectively-blind polling.
const MaxPollInterval = 30 * time.Second
// PollDoc is one decoded poll response document (typically the unified-output
// envelope data of the poll command).
type PollDoc map[string]any
// Poller executes one poll. Returning an error fails the wait phase; the
// engine never retries a poller error because read commands failing is a real
// failure, not a "not yet" signal.
type Poller func(ctx context.Context) (PollDoc, error)
// LoopSpec is the runtime-resolved projection of contract.WaitSpec plus the
// caller-provided timeout.
type LoopSpec struct {
StatusQuery string
Terminal map[string]contract.ResultOutcome
Pending []string
Timeout time.Duration
Interval time.Duration
}
// Outcome is the closed result of a wait loop. TimedOut reports deadline
// exhaustion (Outcome is then pending — an accepted-but-not-terminal state is
// not a process failure per the exit-code contract); Status is the last
// observed status value.
type Outcome struct {
Status string
Outcome contract.ResultOutcome
Attempts int
TimedOut bool
}
// ErrUnknownStatus reports a status value that is neither declared terminal
// nor declared pending. Unknown fails closed: mapping it to pending could
// hide a real state change until timeout, mapping it to success is worse.
type ErrUnknownStatus struct {
Status string
Query string
}
func (e *ErrUnknownStatus) Error() string {
return fmt.Sprintf("wait: status %q (from %q) is neither terminal nor pending", e.Status, e.Query)
}
// Run polls poller until a declared terminal status, deadline exhaustion, or
// a poller error. The first poll is immediate; subsequent polls back off
// exponentially (×1.5) from Interval, capped at MaxPollInterval. Deadline
// exhaustion anywhere — before a poll, during a poll (a context-aware poller
// returns ctx.Err()), or during the wait between polls — always closes as
// timed-out pending with the last observed status, never as a poll failure.
func Run(ctx context.Context, spec LoopSpec, poll Poller) (Outcome, error) {
if spec.Interval <= 0 {
spec.Interval = DefaultPollInterval
}
if spec.Timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, spec.Timeout)
defer cancel()
}
pending := make(map[string]bool, len(spec.Pending))
for _, value := range spec.Pending {
pending[value] = true
}
timedOut := func(status string, attempts int) Outcome {
return Outcome{Status: status, Outcome: contract.ResultOutcomePending, Attempts: attempts, TimedOut: true}
}
interval := spec.Interval
attempts := 0
lastStatus := ""
for {
if ctx.Err() != nil {
return timedOut(lastStatus, attempts), nil
}
doc, err := poll(ctx)
if err != nil {
if ctx.Err() != nil {
return timedOut(lastStatus, attempts), nil
}
return Outcome{Attempts: attempts}, fmt.Errorf("wait: poll failed: %w", err)
}
attempts++
status, ok := ExtractStatus(doc, spec.StatusQuery)
if !ok {
return Outcome{Attempts: attempts}, fmt.Errorf(
"wait: status query %q not found in poll result", spec.StatusQuery)
}
lastStatus = status
if outcome, ok := spec.Terminal[status]; ok {
return Outcome{Status: status, Outcome: outcome, Attempts: attempts}, nil
}
if !pending[status] {
return Outcome{Status: status, Attempts: attempts}, &ErrUnknownStatus{Status: status, Query: spec.StatusQuery}
}
timer := time.NewTimer(interval)
select {
case <-ctx.Done():
timer.Stop()
return timedOut(status, attempts), nil
case <-timer.C:
}
interval = nextInterval(interval)
}
}
func nextInterval(current time.Duration) time.Duration {
next := current * 3 / 2
if next > MaxPollInterval {
next = MaxPollInterval
}
return next
}
// ExtractStatus resolves a dotted status query against a poll document. Each
// segment walks one map level; array indexes are not supported because wait
// targets a single resource. Numeric segments are stringified, so a document
// decoded with json.Number keys still resolves.
func ExtractStatus(doc PollDoc, query string) (string, bool) {
query = strings.TrimSpace(query)
if query == "" {
return "", false
}
// PollDoc is a defined type, so its dynamic type does not satisfy a
// map[string]any assertion — convert once at the boundary; nested values
// from JSON decoding are plain maps.
var current any = map[string]any(doc)
for _, segment := range strings.Split(query, ".") {
segment = strings.TrimSpace(segment)
if segment == "" {
return "", false
}
node, ok := current.(map[string]any)
if !ok {
return "", false
}
value, ok := node[segment]
if !ok {
return "", false
}
current = value
}
switch value := current.(type) {
case string:
return value, true
case fmt.Stringer:
return value.String(), true
case bool:
return strconv.FormatBool(value), true
case int:
return strconv.Itoa(value), true
case int64:
return strconv.FormatInt(value, 10), true
case float64:
return strconv.FormatFloat(value, 'f', -1, 64), true
default:
return "", false
}
}
// IsUnknownStatus reports whether err is the closed fail-on-unknown error.
func IsUnknownStatus(err error) bool {
var unknown *ErrUnknownStatus
return errors.As(err, &unknown)
}
// EventStream is the leaf-owned push subscription consumed by the event
// phase (the WaitEvents hook in corecmd). Recv delivers the next decoded
// event document; it returns an error or io.EOF-style termination when the
// stream ends — the engine treats non-terminal termination as a stream
// failure the caller (auto mode) may fall back from.
type EventStream interface {
Recv(ctx context.Context) (PollDoc, error)
}
// EventLoopSpec is the event-phase projection of contract.WaitSpec.
type EventLoopSpec struct {
StatusQuery string
MatchField string
Terminal map[string]contract.ResultOutcome
Pending []string
Timeout time.Duration
}
// ErrEventStreamEnded reports a stream that terminated before a terminal
// status. Auto mode uses it to fall back to polling; strict event mode
// surfaces it as a wait failure.
var ErrEventStreamEnded = errors.New("wait: event stream ended before a terminal status")
// RunEvent consumes stream until a correlated event reaches a declared
// terminal status, the deadline exhausts, or the stream ends. Events whose
// MatchField value does not equal resource are ignored (other resources on
// the same channel); a correlated event with an unknown status fails closed
// exactly like a poll would.
func RunEvent(ctx context.Context, spec EventLoopSpec, resource string, stream EventStream) (Outcome, error) {
if spec.Timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, spec.Timeout)
defer cancel()
}
pending := make(map[string]bool, len(spec.Pending))
for _, value := range spec.Pending {
pending[value] = true
}
attempts := 0
lastStatus := ""
for {
doc, err := stream.Recv(ctx)
if err != nil {
if ctx.Err() != nil {
return Outcome{Status: lastStatus, Outcome: contract.ResultOutcomePending, Attempts: attempts, TimedOut: true}, nil
}
// Wrap with ErrEventStreamEnded so auto mode can fall back to
// polling while correlated-status failures (unknown status,
// missing status query) stay non-recoverable.
return Outcome{Attempts: attempts}, fmt.Errorf("%w: %v", ErrEventStreamEnded, err)
}
attempts++
correlated, ok := ExtractStatus(doc, spec.MatchField)
if !ok || correlated != resource {
continue
}
status, ok := ExtractStatus(doc, spec.StatusQuery)
if !ok {
return Outcome{Attempts: attempts}, fmt.Errorf(
"wait: status query %q not found in event document", spec.StatusQuery)
}
lastStatus = status
if outcome, ok := spec.Terminal[status]; ok {
return Outcome{Status: status, Outcome: outcome, Attempts: attempts}, nil
}
if !pending[status] {
return Outcome{Status: status, Attempts: attempts}, &ErrUnknownStatus{Status: status, Query: spec.StatusQuery}
}
}
}
+365
View File
@@ -0,0 +1,365 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package wait
import (
"context"
"errors"
"io"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
func loopSpec() LoopSpec {
return LoopSpec{
StatusQuery: "result.status",
Terminal: map[string]contract.ResultOutcome{
"COMPLETED": contract.ResultOutcomeSuccess,
"REJECTED": contract.ResultOutcomeFailure,
},
Pending: []string{"NEW", "RUNNING"},
Interval: time.Millisecond,
}
}
func TestExtractStatusResolvesDottedPaths(t *testing.T) {
doc := PollDoc{
"result": map[string]any{
"instance": map[string]any{"status": "RUNNING"},
"count": float64(3),
},
}
if status, ok := ExtractStatus(doc, "result.instance.status"); !ok || status != "RUNNING" {
t.Fatalf("status=%q ok=%v", status, ok)
}
if status, ok := ExtractStatus(doc, "result.count"); !ok || status != "3" {
t.Fatalf("numeric status=%q ok=%v", status, ok)
}
if _, ok := ExtractStatus(doc, "result.missing"); ok {
t.Fatal("missing path resolved")
}
if _, ok := ExtractStatus(doc, "result.instance.status.deep"); ok {
t.Fatal("descending into a scalar resolved")
}
if _, ok := ExtractStatus(doc, ""); ok {
t.Fatal("empty query resolved")
}
}
func TestRunReturnsTerminalOnFirstPoll(t *testing.T) {
polls := 0
outcome, err := Run(context.Background(), loopSpec(), func(context.Context) (PollDoc, error) {
polls++
return PollDoc{"result": map[string]any{"status": "COMPLETED"}}, nil
})
if err != nil {
t.Fatal(err)
}
if polls != 1 || outcome.Attempts != 1 {
t.Fatalf("polls=%d attempts=%d", polls, outcome.Attempts)
}
if outcome.Outcome != contract.ResultOutcomeSuccess || outcome.Status != "COMPLETED" {
t.Fatalf("outcome=%s status=%s", outcome.Outcome, outcome.Status)
}
}
func TestRunPollsUntilTerminal(t *testing.T) {
seen := []string{"NEW", "RUNNING", "RUNNING", "COMPLETED"}
index := 0
outcome, err := Run(context.Background(), loopSpec(), func(context.Context) (PollDoc, error) {
status := seen[index]
index++
return PollDoc{"result": map[string]any{"status": status}}, nil
})
if err != nil {
t.Fatal(err)
}
if outcome.Attempts != len(seen) || outcome.Outcome != contract.ResultOutcomeSuccess {
t.Fatalf("attempts=%d outcome=%s", outcome.Attempts, outcome.Outcome)
}
}
func TestRunTimesOutAsPendingDuringWait(t *testing.T) {
spec := loopSpec()
spec.Timeout = 5 * time.Millisecond
polls := 0
outcome, err := Run(context.Background(), spec, func(context.Context) (PollDoc, error) {
polls++
return PollDoc{"result": map[string]any{"status": "RUNNING"}}, nil
})
if err != nil {
t.Fatal(err)
}
if !outcome.TimedOut || outcome.Outcome != contract.ResultOutcomePending {
t.Fatalf("timedOut=%v outcome=%s", outcome.TimedOut, outcome.Outcome)
}
if outcome.Status != "RUNNING" {
t.Fatalf("status=%q, want last observed", outcome.Status)
}
if polls == 0 {
t.Fatal("timeout during wait must still have polled at least once")
}
}
func TestRunTimesOutAsPendingWhenPollerRespectsDeadline(t *testing.T) {
spec := loopSpec()
spec.Timeout = 5 * time.Millisecond
polls := 0
// A context-aware poller: blocks until the deadline, then reports the
// cancellation as an error — the loop must close it as timed-out pending,
// never as a poll failure.
outcome, err := Run(context.Background(), spec, func(ctx context.Context) (PollDoc, error) {
polls++
<-ctx.Done()
return nil, ctx.Err()
})
if err != nil {
t.Fatalf("deadline during poll closed as error: %v", err)
}
if !outcome.TimedOut || outcome.Outcome != contract.ResultOutcomePending {
t.Fatalf("timedOut=%v outcome=%s", outcome.TimedOut, outcome.Outcome)
}
}
func TestRunTimesOutBeforeFirstPoll(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
outcome, err := Run(ctx, loopSpec(), func(context.Context) (PollDoc, error) {
t.Fatal("poller ran on a pre-cancelled context")
return nil, nil
})
if err != nil {
t.Fatal(err)
}
if !outcome.TimedOut || outcome.Attempts != 0 || outcome.Outcome != contract.ResultOutcomePending {
t.Fatalf("timedOut=%v attempts=%d outcome=%s", outcome.TimedOut, outcome.Attempts, outcome.Outcome)
}
}
func TestRunFailsClosedOnUnknownStatus(t *testing.T) {
_, err := Run(context.Background(), loopSpec(), func(context.Context) (PollDoc, error) {
return PollDoc{"result": map[string]any{"status": "Mystery"}}, nil
})
if !IsUnknownStatus(err) {
t.Fatalf("err=%v want unknown-status", err)
}
}
func TestRunFailsOnMissingStatusQuery(t *testing.T) {
_, err := Run(context.Background(), loopSpec(), func(context.Context) (PollDoc, error) {
return PollDoc{"unexpected": true}, nil
})
if err == nil || !errors.Is(err, err) {
t.Fatalf("err=%v", err)
}
}
func TestRunPropagatesPollerError(t *testing.T) {
boom := errors.New("rpc down")
_, err := Run(context.Background(), loopSpec(), func(context.Context) (PollDoc, error) {
return nil, boom
})
if !errors.Is(err, boom) {
t.Fatalf("err=%v", err)
}
}
func TestUnknownStatusErrorCarriesStatusAndQuery(t *testing.T) {
err := &ErrUnknownStatus{Status: "Mystery", Query: "result.status"}
message := err.Error()
if !strings.Contains(message, "Mystery") || !strings.Contains(message, "result.status") {
t.Fatalf("message=%q", message)
}
}
func TestRunAppliesDefaultIntervalWhenUnset(t *testing.T) {
spec := loopSpec()
spec.Interval = 0
polls := 0
// Terminal on the second poll forces one interval wait; with Interval=0
// the loop must still work using DefaultPollInterval (not spin/panic).
_, err := Run(context.Background(), spec, func(context.Context) (PollDoc, error) {
polls++
if polls == 1 {
return PollDoc{"result": map[string]any{"status": "NEW"}}, nil
}
return PollDoc{"result": map[string]any{"status": "COMPLETED"}}, nil
})
if err != nil {
t.Fatal(err)
}
if polls != 2 {
t.Fatalf("polls=%d", polls)
}
}
func TestRunTimesOutDuringWaitBetweenPolls(t *testing.T) {
spec := loopSpec()
spec.Interval = time.Hour // the deadline wins long before the next poll
spec.Timeout = 5 * time.Millisecond
outcome, err := Run(context.Background(), spec, func(context.Context) (PollDoc, error) {
return PollDoc{"result": map[string]any{"status": "RUNNING"}}, nil
})
if err != nil {
t.Fatal(err)
}
if !outcome.TimedOut || outcome.Outcome != contract.ResultOutcomePending || outcome.Status != "RUNNING" {
t.Fatalf("outcome=%+v", outcome)
}
}
type stringStatus string
func (s stringStatus) String() string { return string(s) }
func TestExtractStatusCoversScalarShapes(t *testing.T) {
doc := PollDoc{
"result": map[string]any{
"flag": true,
"small": 7,
"big": int64(9007199254740993),
"fraction": 1.5,
"custom": stringStatus("CUSTOM"),
"nested": map[string]any{"deep": "x"},
},
}
cases := map[string]string{
"result.flag": "true",
"result.small": "7",
"result.big": "9007199254740993",
"result.fraction": "1.5",
"result.custom": "CUSTOM",
}
for query, want := range cases {
if got, ok := ExtractStatus(doc, query); !ok || got != want {
t.Fatalf("query=%s got=%q ok=%v want=%q", query, got, ok, want)
}
}
if _, ok := ExtractStatus(doc, "result.nested"); ok {
t.Fatal("non-scalar nested map must not resolve")
}
if _, ok := ExtractStatus(doc, "result..flag"); ok {
t.Fatal("empty segment must not resolve")
}
}
func TestNextIntervalCapsAtMax(t *testing.T) {
if got := nextInterval(MaxPollInterval); got != MaxPollInterval {
t.Fatalf("nextInterval(max)=%s", got)
}
if got := nextInterval(10 * time.Millisecond); got != 15*time.Millisecond {
t.Fatalf("nextInterval(10ms)=%s", got)
}
}
type fakeEventStream struct {
events []PollDoc
err error // returned after events are exhausted (nil = clean end)
block bool // hold until the context deadline
}
func (f *fakeEventStream) Recv(ctx context.Context) (PollDoc, error) {
if f.block {
<-ctx.Done()
return nil, ctx.Err()
}
if len(f.events) > 0 {
doc := f.events[0]
f.events = f.events[1:]
return doc, nil
}
if f.err != nil {
return nil, f.err
}
return nil, io.EOF
}
func eventLoopSpec() EventLoopSpec {
return EventLoopSpec{
StatusQuery: "result.status",
MatchField: "process_instance_id",
Terminal: map[string]contract.ResultOutcome{
"COMPLETED": contract.ResultOutcomeSuccess,
"REJECTED": contract.ResultOutcomeFailure,
},
Pending: []string{"RUNNING"},
}
}
func approvalEvent(instance, status string) PollDoc {
return PollDoc{"process_instance_id": instance, "result": map[string]any{"status": status}}
}
func TestRunEventReturnsCorrelatedTerminal(t *testing.T) {
stream := &fakeEventStream{events: []PollDoc{
approvalEvent("other-instance", "COMPLETED"), // other resource: ignored
approvalEvent("job-1", "RUNNING"), // correlated pending: kept waiting
approvalEvent("job-1", "COMPLETED"),
}}
outcome, err := RunEvent(context.Background(), eventLoopSpec(), "job-1", stream)
if err != nil {
t.Fatal(err)
}
if outcome.Outcome != contract.ResultOutcomeSuccess || outcome.Status != "COMPLETED" {
t.Fatalf("outcome=%+v", outcome)
}
}
func TestRunEventFailsClosedOnUnknownCorrelatedStatus(t *testing.T) {
stream := &fakeEventStream{events: []PollDoc{approvalEvent("job-1", "Mystery")}}
_, err := RunEvent(context.Background(), eventLoopSpec(), "job-1", stream)
if !IsUnknownStatus(err) {
t.Fatalf("err=%v", err)
}
}
func TestRunEventRejectsCorrelatedEventWithoutStatus(t *testing.T) {
stream := &fakeEventStream{events: []PollDoc{
{"process_instance_id": "job-1"}, // correlated but no status document
}}
_, err := RunEvent(context.Background(), eventLoopSpec(), "job-1", stream)
if err == nil || !strings.Contains(err.Error(), "status query") {
t.Fatalf("err=%v", err)
}
}
func TestRunEventStreamEndSurfacesFallbackSentinel(t *testing.T) {
stream := &fakeEventStream{events: []PollDoc{approvalEvent("job-1", "RUNNING")}}
_, err := RunEvent(context.Background(), eventLoopSpec(), "job-1", stream)
if !errors.Is(err, ErrEventStreamEnded) {
t.Fatalf("err=%v, want ErrEventStreamEnded", err)
}
failing := &fakeEventStream{err: errors.New("transport reset")}
_, err = RunEvent(context.Background(), eventLoopSpec(), "job-1", failing)
if !errors.Is(err, ErrEventStreamEnded) {
t.Fatalf("err=%v, want ErrEventStreamEnded wrapping the transport error", err)
}
}
func TestRunEventTimesOutAsPendingWhileBlocked(t *testing.T) {
spec := eventLoopSpec()
spec.Timeout = 5 * time.Millisecond
stream := &fakeEventStream{block: true}
outcome, err := RunEvent(context.Background(), spec, "job-1", stream)
if err != nil {
t.Fatal(err)
}
if !outcome.TimedOut || outcome.Outcome != contract.ResultOutcomePending {
t.Fatalf("outcome=%+v", outcome)
}
}
+1 -1
View File
@@ -59,7 +59,7 @@ RUNTIME_CONTRACT_END = "<!-- DWS_RUNTIME_CONTRACT_END -->"
# here only after verifying that the product skill has its own reviewed routing
# section and intent table; compacting a sparse skill without an alternative
# route would make its shortcuts harder to discover.
COMPACT_PRODUCT_SERVICES = {"chat", "doc", "drive"}
COMPACT_PRODUCT_SERVICES = {"aitable", "chat", "doc", "drive"}
def md_escape(value: Any) -> str:
@@ -170,9 +170,6 @@ orphan_scripts_allowlist:
- path: dingtalk-aitable/scripts/aitable_import_via_task.py
disposition: defer
reason: "helper referenced indirectly / pending doc"
- path: dingtalk-drive/scripts/drive_tree_list.py
disposition: defer
reason: "legacy helper retained for compatibility; multi Drive uses bounded +list until the helper is revalidated"
- path: dingtalk-minutes/scripts/minutes_list_parse.py
disposition: defer
reason: "pending doc reference"
+2 -2
View File
@@ -1,6 +1,6 @@
---
name: dws
description: 管理钉钉产品能力(AI表格/AI搜问/日历/通讯录/群聊与机器人/待办/审批/考勤/日志/DING消息/开放平台文档/钉钉文档/钉钉云盘/原生Markdown文件/AI听记/邮箱/在线电子表格/知识库等)。当用户需要操作表格数据、管理日程会议、模糊找人/查谁负责某事项、查询通讯录、管理群聊、机器人发消息、创建待办、提交审批、查看考勤、提交日报周报(钉钉日志模版)、读写钉钉文档、上传下载云盘文件、读取或修改原生.md文件、查询听记纪要、收发邮件、读写在线电子表格(axls)、管理钉钉知识库,或订阅个人 IM 事件或 OA 审批事件、实时监听群成员加入、群成员退出、群改名和群解散、审批实例发起/终止/完成,以及审批任务创建/完成/转交时使用。
description: 管理钉钉产品能力(AI表格/AI搜问/日历/通讯录/群聊与机器人/待办/审批/考勤/日志/DING消息/开放平台文档/钉钉文档/钉钉云盘/原生Markdown文件/AI听记/邮箱/在线电子表格/知识库等)。当用户需要操作表格数据、管理日程会议、模糊找人/查谁负责某事项、查询通讯录、管理群聊、机器人发消息、创建待办、提交审批、查看考勤、提交日报周报(钉钉日志模版)、读写钉钉文档、上传下载云盘文件、读取或修改原生.md文件、查询听记纪要、收发邮件、读写在线电子表格(axls)、管理钉钉知识库,或订阅个人 IM 事件或 OA 审批事件、实时监听群成员加入、群成员退出、群改名和群解散、审批实例发起/抄送/终止/完成,以及审批任务创建/完成/转交时使用。
cli_version: ">=1.0.15"
---
@@ -43,7 +43,7 @@ cli_version: ">=1.0.15"
| 服务 | shortcut 数 | multi skill |
|---|---:|---|
| `aitable` | 92 | `dingtalk-aitable` |
| `aitable` | 93 | `dingtalk-aitable` |
| `attendance` | 19 | `dingtalk-misc` |
| `calendar` | 27 | `dingtalk-calendar` |
| `chat` | 98 | `dingtalk-chat` |
+9 -4
View File
@@ -47,10 +47,11 @@
| `user_oa_approval_task_finished` | 审批任务已完成 | 无 |
| `user_oa_approval_task_redirected` | 审批任务已转交 | 无 |
| `user_oa_approval_instance_started` | 审批实例已发起 | 无 |
| `user_oa_approval_instance_cc` | 审批实例到达抄送节点,发送给被抄送人 | 无 |
| `user_oa_approval_instance_terminated` | 审批实例已终止 | 无 |
| `user_oa_approval_instance_finished` | 审批实例完成,发送给审批单发起人 | 无 |
只承认上表 22 个事件码。默认身份就是当前用户,使用当前用户 OAuth 登录态,不要额外加身份切换 flag。六个 OA 事件订阅当前用户相关的全部审批事件,规则均为 `all`、空 `filterRule`,不需要目标参数。
只承认上表 23 个事件码。默认身份就是当前用户,使用当前用户 OAuth 登录态,不要额外加身份切换 flag。七个 OA 事件订阅当前用户相关的全部审批事件,规则均为 `all`、空 `filterRule`,不需要目标参数。
## Intent mapping
@@ -76,9 +77,10 @@
| "审批任务完成时通知我" | `event consume`,事件码 `user_oa_approval_task_finished`,参数 `--flatten -f ndjson` |
| "审批任务被转交时通知我" | `event consume`,事件码 `user_oa_approval_task_redirected`,参数 `--flatten -f ndjson` |
| "有审批单发起时通知我" | `event consume`,事件码 `user_oa_approval_instance_started`,参数 `--flatten -f ndjson` |
| "有审批抄送给我时通知我" | `event consume`,事件码 `user_oa_approval_instance_cc`,参数 `--flatten -f ndjson` |
| "有审批单终止时通知我" | `event consume`,事件码 `user_oa_approval_instance_terminated`,参数 `--flatten -f ndjson` |
| "监听我发起的审批何时完成" / "审批实例完成时通知我" | `event consume`,事件码 `user_oa_approval_instance_finished`,参数 `--flatten -f ndjson` |
| "同时监听全部已公开 OA 事件" | 一个 consume 放入六个 OA event key,不加目标或消息过滤参数 |
| "同时监听全部已公开 OA 事件" | 一个 consume 放入七个 OA event key,不加目标或消息过滤参数 |
| "查看个人事件 schema" | `dws event schema <event_key> --flatten` |
| "看个人事件订阅状态" | `dws event status --event <event_key>` |
| "停止这个个人事件订阅" | `dws event stop <subscribe_id> --dry-run`,确认后改用 `--yes` |
@@ -130,6 +132,7 @@ dws event schema user_oa_approval_task_created --flatten
dws event schema user_oa_approval_task_finished --flatten
dws event schema user_oa_approval_task_redirected --flatten
dws event schema user_oa_approval_instance_started --flatten
dws event schema user_oa_approval_instance_cc --flatten
dws event schema user_oa_approval_instance_terminated --flatten
dws event schema user_oa_approval_instance_finished --flatten
```
@@ -157,6 +160,7 @@ dws event consume user_oa_approval_task_created --flatten -f ndjson
dws event consume user_oa_approval_task_finished --flatten -f ndjson
dws event consume user_oa_approval_task_redirected --flatten -f ndjson
dws event consume user_oa_approval_instance_started --flatten -f ndjson
dws event consume user_oa_approval_instance_cc --flatten -f ndjson
dws event consume user_oa_approval_instance_terminated --flatten -f ndjson
dws event consume user_oa_approval_instance_finished --flatten -f ndjson
```
@@ -185,13 +189,14 @@ dws event consume \
user_oa_approval_task_finished \
user_oa_approval_task_redirected \
user_oa_approval_instance_started \
user_oa_approval_instance_cc \
user_oa_approval_instance_terminated \
user_oa_approval_instance_finished \
--flatten \
-f ndjson
```
用户类事件共享 `--user` 或 `--open-dingtalk-id`,群类事件共享 `--group`,无目标 IM 事件可加入任一组合。用户类与群类、不同目标或不同过滤条件要拆成多个进程。六个 OA 事件可以同进程消费并共享 personal bus,但各自建立独立订阅。多事件共享 `--query` / `--filter-json` 时,所选事件必须全部是 IM 消息接收事件;OA 事件单独或组合消费都禁止使用这两个消息过滤参数。
用户类事件共享 `--user` 或 `--open-dingtalk-id`,群类事件共享 `--group`,无目标 IM 事件可加入任一组合。用户类与群类、不同目标或不同过滤条件要拆成多个进程。七个 OA 事件可以同进程消费并共享 personal bus,但各自建立独立订阅。多事件共享 `--query` / `--filter-json` 时,所选事件必须全部是 IM 消息接收事件;OA 事件单独或组合消费都禁止使用这两个消息过滤参数。
上述所有 `*_o2o` 命令和 `user_im_message_receive_user` 都可将 `--user <userId>` 替换为 `--open-dingtalk-id <openDingtalkId>`,但两个参数不能同时使用。
@@ -216,7 +221,7 @@ dws event stop --all --yes
## 订阅创建失败与重试预算
以下约束适用于上表全部 22 个公开个人事件(16 个 IM + 6 个 OA)以及多事件命令中的每一项,只治理 `[event] ready` 之前的订阅创建;ready 之后的 Stream 断线由长连接重连机制处理。
以下约束适用于上表全部 23 个公开个人事件(16 个 IM + 7 个 OA)以及多事件命令中的每一项,只治理 `[event] ready` 之前的订阅创建;ready 之后的 Stream 断线由长连接重连机制处理。
- `0/2/1` 是 **Agent/host 编排约束**,不是 CLI 持久化硬总次数上限。每次 `dws event consume` 调用对每个逻辑订阅最多发送一次订阅创建 HTTP 请求,进程内不会自动重试。CLI 本地状态只持久化 `in_flight`、`cooldown`、`terminal_hold` 三种保护状态,不持久化或计算跨调用的 Agent/host 尝试次数。
- 解析人名或群名、执行 `event consume` 以及后续 `event status/stop` 必须使用同一个 `--profile`。不得把其它 profile 下解析出的 userId、openDingtalkId 或 openConversationId 直接带入当前 profile 的订阅。
+84 -183
View File
@@ -11,213 +11,114 @@ metadata:
# 钉钉 AI 表格 Skill
## 前置条件 — 执行操作前必读
<!-- DWS_RUNTIME_CONTRACT_START -->
## 最小 DWS 执行契约
> **CRITICAL — 执行任何 `dws` 操作前,MUST 先用 Read 工具完整读取 [`dingtalk-shared`](../dingtalk-shared/SKILL.md)。**该轻量文件包含全局执行契约、安全底线及 shared references 的按需加载导航;不要预加载其全部 references。
> 命令参考:[aitable.md](references/aitable.md);复杂命令按需加载 `references/aitable/*.md`;剧本:[06-data-analytics.md](references/06-data-analytics.md)。
- 只通过 `dws` CLI 操作钉钉;结构化读取使用 `--format json`,按真实返回判断结果。
- 已知命令直接执行。只有 leaf 参数或安全语义不确定时读取精确 Schema,只有 Cobra flag 不确定时读取精确 leaf Help;不要加载产品级 Catalog 代替选路。
- 不猜命令、flag、字段、ID、账号或时间。后续 ID 必须来自真实返回;零命中、多候选或类型不明时停止并消歧。
- 解析目标、读取上下文和最终执行必须使用同一 profile;不得跨组织复用 userId、openDingTalkId 或 openConversationId。多账号组织只使用明确的 `isOrgCurrent=true` 默认账号;没有默认账号时要求用户指定,禁止选择第一项、最近登录或最近使用账号。
- 不输出或记录 token、refresh token、appSecret、webhook token 等凭据;宿主已注入认证时不要索要凭据。
- 写操作必须符合用户明确意图。是否需要确认以最终 Runtime gate 和 Schema 为准;本轮用户已明确要求执行、目标与影响无歧义的非破坏性写操作时,该明确指令就是本次确认,首次调用直接携带 Runtime 所需的 `--yes`,不先制造 `confirmation_required`。删除、停用自动化等破坏性或高风险动作仍须先说明对象、动作与影响并取得独立确认。
- 写后按任务结果契约验证;不能仅凭退出码宣称成功。部分结果、未知投递状态和失败项必须如实保留。
- 时间戳面向用户展示时转换为带时区的可读时间;默认使用当前会话时区,必要时同时保留原值。
- 遇到认证、权限、profile、confirmation 或未知错误时,只加载 `dingtalk-shared` 中对应 reference;不要连续猜测替代命令。
<!-- DWS_RUNTIME_CONTRACT_END -->
<!-- VISIBLE_SHORTCUTS_START -->
## Shortcuts(无专用脚本/recipe 时优先)
## Shortcut 发现(按需)
以下 shortcut 同时进入公开 catalog 与 Runtime Schema。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。命令已选中时直接执行;只在参数或安全语义不确定时读取 Agent leaf Schema(例如 `dws schema --cli-path "aitable +<shortcut>" --compact --format json`),在当前 Cobra flags 不确定时读取 `dws aitable <shortcut> --help`。只有参数映射、接口绑定或 provenance 审计才省略 `--compact`。仅当现有路由和 reference 都无法定位低频能力时,才用 `dws shortcut list --service aitable --format json` 批量发现。
`aitable` 当前有 93 条公开 shortcut,完整清单保留在 Runtime Catalog 与 Schema,不在高频产品根 Skill 中重复展开。已知意图直接使用下方的优先路由、意图表或任务 reference;命令已选中时直接执行,只在参数/安全语义不确定时读取 leaf Schema,在当前 Cobra flags 不确定时读取 leaf Help。
| Shortcut | 风险 | 适用场景 |
|---|---|---|
| `dws aitable +advperm-disable` | high-risk-write | 关闭指定 Base 的高级权限总开关(所有自定义角色失效) |
| `dws aitable +advperm-enable` | write | 开启指定 Base 的高级权限总开关 |
| `dws aitable +attachment-put` | write | 准备凭证、实际 PUT 本地文件、写入 attachment 单元格并读回验证 |
| `dws aitable +attachment-remove` | high-risk-write | 从 attachment 字段清空全部或按文件名移除,写前确保剩余项具有可重写 fileToken,并读回验证 |
| `dws aitable +attachment-upload` | write | 为 attachment 字段申请 OSS 直传地址(uploadUrl / fileToken) |
| `dws aitable +base-bootstrap` | write | 一次创建 Base、数据表和字段,逐层读回验证并在中断时报告已知副作用 |
| `dws aitable +base-copy` | write | 复制 AI 表格到指定目录(可仅复制结构) |
| `dws aitable +base-delete` | high-risk-write | 删除指定 Base(不可逆) |
| `dws aitable +base-get` | read | 获取指定 Base 的目录信息(tables / dashboards summary) |
| `dws aitable +base-get-primary-doc-id` | read | 根据 baseId/tableId/recordId 获取主键文档的 dentryUuid |
| `dws aitable +base-list` | read | 获取当前用户可访问的 AI 表格 Base 列表(最近访问,支持游标分页) |
| `dws aitable +base-schema-snapshot` | read | 读取 Base、全部数据表、字段和视图的可复用结构快照,并严格校验每层响应 |
| `dws aitable +base-search` | read | 按名称关键词搜索 AI 表格 Base |
| `dws aitable +base-update` | write | 更新 Base 名称(可选备注) |
| `dws aitable +chart-delete` | high-risk-write | 删除指定 chart 及其布局项(不可逆) |
| `dws aitable +chart-get` | read | 获取指定 chart 的详细信息 |
| `dws aitable +chart-share-get` | read | 查询 chart 的分享配置 |
| `dws aitable +chart-share-update` | write | 开启/关闭 chart 分享并可设置分享类型 |
| `dws aitable +chart-update` | write | 更新指定 chart 的配置或布局(--config 必填) |
| `dws aitable +chart-widgets-example` | read | 获取所有图表类型的 widget config 示例 |
| `dws aitable +dashboard-arrange` | write | 对指定仪表盘做服务端智能布局重排 |
| `dws aitable +dashboard-config-example` | read | 获取 dashboard config 的结构示例 |
| `dws aitable +dashboard-delete` | high-risk-write | 删除指定 dashboard(级联删除其 chart,不可逆) |
| `dws aitable +dashboard-get` | read | 获取指定 dashboard 的详细信息(含 charts summary) |
| `dws aitable +dashboard-share-get` | read | 查询 dashboard 的分享配置 |
| `dws aitable +dashboard-share-update` | write | 开启/关闭 dashboard 分享并可设置分享类型 |
| `dws aitable +dashboard-update` | write | 更新指定 dashboard 的配置 |
| `dws aitable +export-data` | read | 导出 AI 表格数据(创建导出任务或按 taskId 续等) |
| `dws aitable +field-delete` | high-risk-write | 删除指定字段(不可逆) |
| `dws aitable +field-get` | read | 批量获取字段详情(含类型相关完整配置) |
| `dws aitable +field-update` | write | 更新字段名称 / 配置 / AI 配置(类型不可改) |
| `dws aitable +find-record` | read | 在指定多维表里按关键词查记录(只读) |
| `dws aitable +form-delete` | high-risk-write | 删除指定表单视图(不可逆) |
| `dws aitable +form-field-hide` | write | 切换表单字段的隐藏/显示状态 |
| `dws aitable +form-field-list` | read | 列出表单视图当前可见的字段及其配置 |
| `dws aitable +form-field-update` | write | 更新表单字段的必填状态或描述 |
| `dws aitable +form-list` | read | 列出指定数据表下的所有表单视图 |
| `dws aitable +form-share-get` | read | 读取视图当前的分享表单配置 |
| `dws aitable +form-share-update` | write | 开启或关闭指定视图的分享表单 |
| `dws aitable +form-update` | write | 更新表单标题 / 描述 |
| `dws aitable +import-data` | write | 将已上传文件导入 AI 表格(新建表或追加到已有表) |
| `dws aitable +import-upload` | write | 为导入任务申请 OSS 直传地址(uploadUrl / importId) |
| `dws aitable +list-tables` | read | 列出某个多维表(base)里的所有数据表(只读,投影 tableId/tableName) |
| `dws aitable +record-bulk-patch` | high-risk-write | 完整查询目标记录后批量合并同一组 cells,自动分片并逐条读回验证 |
| `dws aitable +record-delete` | high-risk-write | 批量删除记录(不可逆),自动按 100 条分片并逐批确认记录已不存在 |
| `dws aitable +record-history-list` | read | 按 recordId 查询单条记录的变更历史 |
| `dws aitable +record-primary-doc-create` | write | 为记录创建主键文档(幂等),fieldId 须为 primaryDoc 类型 |
| `dws aitable +record-primary-doc-get` | read | 查询记录关联的主键文档 nodeId |
| `dws aitable +record-query` | read | 查询表格记录(按 ID 取 / 条件筛选 / 关键词 / 分页) |
| `dws aitable +record-query-empty` | read | 扫描并过滤出完全没填用户字段的空行 |
| `dws aitable +record-share-links` | read | 批量(可 >20 条)获取多维表记录分享链接:去重+分片+合并 |
| `dws aitable +record-share-url` | read | 按 recordId 批量获取记录分享链接,单次最多 20 条 |
| `dws aitable +record-update` | write | 批量更新记录,自动按 100 条分片并逐批读回验证 |
| `dws aitable +record-upsert` | write | 按 recordId 自动拆分 create/update,按 100 条分片并读回验证 |
| `dws aitable +record-upsert-by-key` | write | 按唯一字段值有则更新、无则创建记录,并读回验证 |
| `dws aitable +resolve-base` | read | 按名称搜索多维表 Base 并解析出唯一 baseId(只读) |
| `dws aitable +resolve-table` | read | 在某个多维表 Base 内按名称解析出唯一的数据表 tableId(只读) |
| `dws aitable +role-create` | write | 在指定 Base 下创建自定义角色 |
| `dws aitable +role-delete` | high-risk-write | 删除 Base 下指定的自定义角色(不可逆) |
| `dws aitable +role-get` | read | 获取单个角色的完整配置 |
| `dws aitable +role-list` | read | 列出指定 Base 下的全部角色 |
| `dws aitable +role-update` | write | 按 PATCH 语义增量更新自定义角色 |
| `dws aitable +section-create` | write | 在指定 Base 下创建文件夹(组织 table / dashboard) |
| `dws aitable +section-delete` | high-risk-write | 删除指定文件夹(不可逆) |
| `dws aitable +section-list-empty` | read | 列出指定 Base 下所有没有子节点的空文件夹 |
| `dws aitable +section-list-nodes` | read | 列出指定 Base 当前版本下的全部 nsheet 节点 |
| `dws aitable +section-move-node` | write | 把任意 nsheet 节点移动到目标文件夹下(可选调整位置) |
| `dws aitable +section-rename` | write | 重命名指定文件夹 |
| `dws aitable +section-reorder` | write | 在当前父文件夹下调整文件夹的展示顺序 |
| `dws aitable +table-copy` | write | 跨 Base 同步复制一张表的可创建字段结构,并可同步复制全部记录 |
| `dws aitable +table-delete` | high-risk-write | 删除指定数据表(不可逆) |
| `dws aitable +table-get` | read | 批量获取指定数据表的表级信息、字段目录与视图目录 |
| `dws aitable +table-update` | write | 更新数据表名称 / 备注 / 行命名规则 |
| `dws aitable +template-search` | read | 按名称关键词搜索 AI 表格模板 |
| `dws aitable +url-resolve` | read | 解析 AI 表格 URL 中的 baseId/tableId/viewId/recordId |
| `dws aitable +view-delete` | high-risk-write | 删除指定视图(不可逆) |
| `dws aitable +view-duplicate` | write | 复制视图,生成配置相同的新视图 |
| `dws aitable +view-get` | read | 获取视图完整信息(列顺序、筛选、排序、分组等) |
| `dws aitable +view-get-frozen-cols` | read | 获取视图当前冻结的左侧列数 |
| `dws aitable +view-get-lock` | read | 获取视图锁定状态 |
| `dws aitable +view-get-row-height` | read | 获取视图单元格行高(像素) |
| `dws aitable +view-lock` | write | 锁定视图(默认)或解锁(--off) |
| `dws aitable +view-preset-apply` | write | 按视图精确名称幂等创建或更新预设,并读回校验类型和 config |
| `dws aitable +view-set-fill-color-rule` | write | 全量覆盖 Grid 视图的条件填色规则(传 '[]' 清空) |
| `dws aitable +view-set-frozen-cols` | write | 设置视图冻结列数(0 表示取消冻结) |
| `dws aitable +view-set-row-height` | write | 设置视图单元格行高(像素,合法档位 32/56/88/128) |
| `dws aitable +view-update` | write | 更新视图名称 / 描述 / 配置(visibleFieldIds、filter、sort、group 等) |
| `dws aitable +workflow-deploy` | write | 创建或更新完整 workflow-dsl/v1,强制检查 valid/flowId,并可启用后验证 RUNNING 状态 |
| `dws aitable +workflow-disable` | high-risk-write | 禁用指定 Base 中的自动化工作流(影响业务自动化) |
| `dws aitable +workflow-enable` | write | 启用指定 Base 中的自动化工作流 |
| `dws aitable +workflow-get` | read | 获取单个自动化工作流的详细信息 |
| `dws aitable +workflow-list` | read | 列出指定 Base 中的自动化工作流(分页) |
仅当现有路由和 reference 都无法定位低频能力时,才执行 `dws shortcut list --service aitable --format json` 做最后回退;不要为已知高频意图加载完整 Shortcut Catalog 或产品级 Schema。
<!-- VISIBLE_SHORTCUTS_END -->
## 意图表
## Golden Route
| 用户说 | 命令 |
|--------|------|
| "搜表格 / 找一个 base" | `dws aitable base search --query "<名>"` |
| "创建 AI 表格 / 多维表" | `dws aitable base create --name "<名称>" [--template-id <id>]` |
| "查数据表 / 建数据表" | `dws aitable table get --base-id <baseId>` / `dws aitable table create --base-id <baseId> --name "<表名>" --fields '[...]'` |
| "查字段 / 字段类型" | `dws aitable field get --base-id <id> --table-id <id>` |
| "查记录 / 搜索记录" | `dws aitable record query --base-id <baseId> --table-id <tableId> [--filters '...']` |
| "写记录 / 更新记录 / 删除记录" | `dws aitable record create/update/delete --base-id <baseId> --table-id <tableId> ...` |
| "筛选 / 排序 / 公式 / 跨表引用" | 先读 `references/aitable/aitable-filter-sort.md` / `aitable-formula-guide.md` |
| "批量导入 JSON / CSV" | `python scripts/import_records.py <baseId> <tableId> data.csv\|data.json` |
| "批量加字段" | `python scripts/bulk_add_fields.py --base-id <id> --table-id <id> --fields fields.json` |
| "导入 / 导出表格" | 先读 `references/aitable/aitable-export-import.md`;导出优先 `python scripts/aitable_export_via_task.py <baseId> --scope table --table-id <tableId>` |
| "仪表盘 / 图表" | 先读 `references/aitable/aitable-dashboard-chart.md` |
| "上传附件到记录" | 先读 `references/aitable/aitable-attachment.md`;可用 `python scripts/upload_attachment.py --base-id <id> --file <path>` |
已有 ID 直接使用;完整 URL 先解析;名称先唯一解析为稳定 ID。零命中或多候选时停止,不默认选第一项。
## 标准 SOP(必遵流程)
| 用户意图 | 唯一推荐入口 | 关键边界 |
|---|---|---|
| 从 URL 解析稳定 ID | `dws aitable +url-resolve --url <URL>` | 只解析 URL 中已有的 baseId/tableId/viewId/recordId,不做远端名称搜索 |
| 按名称唯一定位并操作 Base/Table | `dws aitable +resolve-base --name <名称>` → `dws aitable +resolve-table --base <ID> --name <表名>` | 默认精确匹配;只有用户明确接受模糊匹配时才加 `--fuzzy` |
| 浏览 Base 下的数据表 | `dws aitable +list-tables --base <ID>` | 只返回 tableId/tableName,不加载字段 |
| 搜索 Base 候选或检查是否存在 | `dws aitable +base-search --query <关键词>` | 用户说“搜索/找一下/候选/如果没有就创建”时直接走本入口,不先调用 `+resolve-base`;AITable 上下文中的 Base 名称不得路由到 `dws aisearch person` |
| 新建 Base 与整套表字段 | `dws aitable +base-bootstrap --name <名称> --tables '[{"name":"<表名>","fields":[{"fieldName":"<字段名>","type":"text"}]}]'` | 表对象键必须是 `name`,不是 `tableName`;字段使用 `fieldName/type/config`;参数已足够时直接执行,不读 Reference 或 Help |
| 已有 Base 新建一张表与字段 | `dws aitable +table-bootstrap --base-id <ID> --name <表名> --fields '<JSON数组>'` | 字段使用 `fieldName/type/config`;自动按 15 个字段分片并读回验证 |
| 读取字段目录或完整配置 | `dws aitable field list --base-id <B> --table-id <T>` / `dws aitable +field-get --base-id <B> --table-id <T>` | 只需 fieldId/name/type 用 `field list`;需要 config 用 `+field-get`;不存在 `+field-list` 或 `+list-fields` |
| 查询、筛选、排序或字段投影 | `dws aitable +record-query --base-id <ID> --table-id <ID> [--record-ids <IDs>] [--field-ids <IDs>] [--filters <JSON>] [--sort <JSON>] [--query <关键词>]` | 用户要求“只返回/仅查看”指定字段时必须传对应 `--field-ids`,不能只在最终文本删列;明确要求全量时改用原子 `record query --all --page-limit <N>` |
| 查询一条记录的变更历史 | `dws aitable +record-history-list --base-id <ID> --table-id <ID> --record-id <ID>` | 已知 recordId 时直接执行;不要调用 Help、产品 Catalog 或全量 Schema 寻找 history 命令 |
| 新增单条或批量记录 | `dws aitable record create --base-id <ID> --table-id <ID> --records <JSON>` | 当前无 `+record-create`;写前取字段定义,写后按新 ID 回读 |
| 更新已知 recordId | `dws aitable +record-update --base-id <ID> --table-id <ID> --records <JSON>` | 自动分片并读回;只传需修改字段 |
| 按业务唯一键同步 | `dws aitable +record-upsert-by-key --base-id <ID> --table-id <ID> --key-field-id <ID> --key-value <值> --cells <JSON>` | 0 条创建、1 条更新、多条停止;非字符串键改用 `--key-value-json` |
| 按条件批量修改 | `dws aitable +record-bulk-patch --base-id <ID> --table-id <ID> --query <关键词> --patch <JSON> --max-matches <N>` | 也可用 filters/record-ids 选范围;禁止无边界整表写 |
| 删除整个 Base | `dws aitable +base-delete --base-id <ID>` | 先通过只读命令确认真实 ID;按 Runtime confirmation 执行,不用 Drive 删除同名节点 |
| 删除字段 | `dws aitable +field-delete --base-id <ID> --table-id <ID> --field-id <ID>` | 先读取字段目录并确认非主字段;按 Runtime confirmation 执行 |
| 查询/创建记录主键文档 | `dws aitable +record-primary-doc-get|+record-primary-doc-create ...` | create 必须传 primaryDoc 类型的 `--field-id`;正文操作切到 Doc |
| 生成记录分享链接并发送给联系人 | `dws aitable +record-share-links --base <B> --table <T> --record-ids <IDs>` → `dws chat +dm --to <姓名> --text <完整链接文本>` | AITable 只生成链接;用户要求“发送”时必须加载 `dingtalk-chat` 并对每位收件人完成真实发送,不能停在联系人解析 |
| 创建 View / Dashboard / Chart 或导入文件 | 对应 leaf / `+import-*` | 根 Skill 参数足够则直接执行;复杂配置最多读取一个对应操作 Reference,不读取通用索引 |
| 调整视图列顺序 | `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 节点 |
> 命中以下意图**必须**按对应 SOP 顺序执行;**禁止**跳步、替换命令、编造 flag/ID。每条命令必须带 `--format json`,执行后必须按"解析"步取真实字段,不得凭返回结构猜测。`baseId`/`tableId`/`fieldId`/`recordId` 一律先查后用,**禁止默认/编造**。
### 常用 leaf 直达
### SOP-1 定位 Base 与 Table(list / search → table get)
参数已知时直接执行,不探测 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`。
**触发**:找/打开某张 AI 表格、不知 baseId 或 tableId。
### 低频入口
1. **选源(必须)**:有名称/关键词 → `dws aitable base search --query "<名称>"`;列最近访问 → `dws aitable base list`。`base list` 仅返回最近访问,不是全部,**禁止**当作全量清单。
2. **执行(必须)**:`dws aitable base search --query "<完整名>" --format json`(或 `dws aitable base list --format json`)。
3. **解析(必须)**:从 JSON 取真实 `baseId`;**多候选必须输出让用户选,禁止默认取第一个**。
4. **取 tableId(必须)**:`dws aitable table get --base-id <baseId> --format json` → 从 `data.tables[].tableId` 取目标表 ID,并记录 `views[]`。枚举模式不返回 `fields[]`;需要字段目录时必须继续执行 SOP-2 的 `field get`。若只核对某张表,可显式加 `--table-ids <tableId>` 控制返回体。
5. **失败(必须)**:`base list` 为空或不命中 → 换 `base search --query` 关键词重试一次;仍无果**必须如实告知**,禁止臆造 baseId/tableId。
字段配置用 `+field-*`;删记录用 `+record-delete`;附件用 `+attachment-*`。批量分享记录用 `+record-share-links --base <B> --table <T> --record-ids <IDs>`。其余能力使用同名前缀 leaf。
**禁止**:跳过 `table get` 直接用字段名写记录、用模糊名匹配当 baseId、用旧会话里的 ID 不再校验。
## 当前最短路径
### SOP-2 拿字段定义(field get,写记录/改字段前置)
- 已有 ID 直接使用;URL 只解析一次;“唯一定位并操作”用 `+resolve-base` / `+resolve-table`,“搜索候选/存在性检查”直接用 `+base-search`,两条路径不要串行探测。filters/sort 缺 fieldId 时才读取字段目录。
- Golden Route 已给出准确命令和参数时直接执行;不预读或默认读取通用 `references/aitable.md`。只有操作参数、JSON 结构或恢复语义确实缺失时,才读取下方一个精确操作 Reference。
- Shortcut 已含分片或验证时不重复拆步;已有 Base 新建完整表结构直接用 `+table-bootstrap`。
- 单产品线性任务直接执行,不创建 TodoWrite;只有跨产品或多个独立分支的长任务才建计划,并且只在阶段切换时更新,不在每条 CLI 后刷新状态。
- 用户要求资源名带当前时间戳时只取一次并在 Base、Table、Dashboard 等名称中复用同一值;不要为每个资源分别取时间。
- JSON 已返回所需字段时立即复用;不得为寻找同一字段改用 `--verbose`、`raw`、`pretty` 重复请求。
**触发**:建/改/写记录、改字段名或 options、按字段类型拼写入参前。
## 记录输入与结果
1. **前置(必须)**:先按 SOP-1 拿到 `baseId` + `tableId`。
2. **执行(必须)**:`dws aitable field get --base-id <baseId> --table-id <tableId> --format json`(仅展开需要的字段时加 `--field-ids fld1,fld2`,单次最多 10 个)。
3. **解析(必须)**:取每个目标字段的 `fieldId`、`type`、`config`(如 singleSelect/multipleSelect 的 `options[].id|name`);写入 cells 的 key **必须用 `fieldId`**,不是字段中文名;select 字段过滤/写入传**选项名称字面量**,不传 option ID。
4. **衔接(必须)**:拿到字段定义 → 进入 SOP-3 写记录、或 `dws aitable field update --field-id <fieldId> --name <新名>|--config <JSON> --format json` 改字段。
5. **失败(必须)**:字段不存在或类型不符 → 重新 `field get` 核对,**禁止**凭旧名称/旧类型继续写入。
- `cells` key 用当前 fieldId;大 JSON 用相对 `--records-file`。filters 顶层为 `and|or`,sort 使用 `direction`;复杂条件读 [filter-sort](references/aitable/aitable-filter-sort.md)。
- 建表字段类型使用真实枚举:单选为 `singleSelect`;人民币货币字段使用 `type:"currency"` 和 `config:{"currencyType":"CNY","formatter":"FLOAT_2"}`,不要猜 `select` 或 `config.symbol`。
- 用户限定返回字段时,先复用当前字段目录中的真实 fieldId,最终 `+record-query` 必须带 `--field-ids <ID1,ID2>`;工具层投影是业务要求和 token 控制的一部分,不能用最终答复二次过滤替代。
- 按真实字段类型写值,只读字段不得写入。
- 新建从 `data.newRecordIds[]` 取 ID,再用 `+record-query --record-ids` 回读;若用户同时限定列,回读命令一并传 `--field-ids`。
- 批量结果检查 completed/failed、verification、checkpoint;`partial_success` 不是完成。全量查询使用原子 `record query --all` 并检查 `hasMore`;只有 `hasMore=false`,或按指定 ID 全命中时,才声称结果完整。
- 写入效果未知时回读,不重放成功批次。
**禁止**:用字段中文名当 cells key、跳过 `field get` 直接 `record create/update`、对 select 字段传 option ID 当写入值。
## 安全边界
### SOP-3 写/批量写记录(record create)
- 删除不可逆,按 Runtime confirmation 核对真实目标;`base list` 只是最近访问。字段零/多候选、类型不明时停止;多批写保留已完成批次和续跑位置。
**触发**:新增记录、批量加数据、CSV/JSON 入表。
## 按需加载
1. **前置(必须)**:SOP-1 取 `baseId`/`tableId` + SOP-2 取 `fieldId`/类型。
2. **执行(必须)**:`dws aitable record create --base-id <baseId> --table-id <tableId> --records '[{"cells":{"<fieldId>":<值>}}]' --format json`;单次最多 100 条,超长用 `--records-file ./data.json`。
3. **写入格式(必须)**:按 `record create --help` 类型表严格传值(text→字符串、number→数值、singleSelect→"选项名"、date→RFC3339、url→`{"text","link"}`、group→`{"cid"}` 等);`filterUp`/`lookup` 字段只读不可写。
4. **解析与验证(必须)**:从返回 `data.newRecordIds[]` 取全部新记录 ID;不要读取不存在的标量 `recordId`。立即执行 `dws aitable record query --base-id <baseId> --table-id <tableId> --record-ids <id1,id2,...> --format json` 回读写入值。
5. **失败(必须)**:类型/格式错误按返回报错修正后重试,**禁止**降级丢弃字段;不确定格式先 `field get` 复核 config。
每个 Case 最多读取一个操作 Reference。Golden Route 参数足够时读取零个并直接执行;一旦读取了一个 Reference,本 Case 不再读取第二个 Reference、通用 `aitable.md`、产品级 Catalog 或 Help。
**禁止**:编造 fieldId/recordId、跳过 `field get` 凭中文名写、把 URL 字符串直接塞给 url 字段。
| 触发条件 | Reference |
|---|---|
| 记录 CRUD、字段值格式 | [record-ops](references/aitable-record-ops.md) |
| 记录主键文档 | [primary-doc](references/aitable/aitable-primary-doc.md) |
| filters/sort/date 操作符 | [filter-sort](references/aitable/aitable-filter-sort.md) |
| 字段创建或复杂配置 | [field](references/aitable/aitable-field.md) |
| 导入导出任务恢复 | [export-import](references/aitable/aitable-export-import.md) |
| 视图列顺序、筛选、排序、冻结 | [view-config](references/aitable/aitable-view-config.md) |
| Base 内 Section/节点移动或清理 | [section](references/aitable-section.md) |
| 图表配置 | [dashboard-chart](references/aitable/aitable-dashboard-chart.md) |
| 附件、表单、工作流 | 读取 `references/aitable/` 下对应的一个精确文件 |
| 产品边界不明确 | [intent-guide](references/intent-guide.md) |
### SOP-4 查/筛/排记录(record query)
通用 `references/aitable.md` 仅保留为兼容索引,不是默认入口;正常 Case 不预读。低频能力按意图选择一个最精确的 Reference,禁止连读。
**触发**:查记录、按条件筛选、排序、取关联记录、定位待改/待删的 recordId。
## 错误最短路径
1. **前置(必须)**:SOP-1 拿 `baseId`/`tableId`。
2. **执行(必须)**:`dws aitable record query --base-id <baseId> --table-id <tableId> --format json`;已知 ID 直取加 `--record-ids rec1,rec2`(忽略 filters/sort,单次≤100)。
3. **筛选/排序(必须)**:`--filters` 最外层必须 `{"operator":"and|or","operands":[...]}`,select 字段值传**选项名字面量**;日期只能用 `date_eq/before/after/not_before/not_after`,范围用 `not_before`+`not_after` 组合,**禁止** `eq`/区间/相对时间。`--sort` 用 `[{"fieldId":"..","direction":"asc|desc"}]`(**必须用 `direction`**)。公式/引用/关联字段默认不返回,需显式 `--field-ids` 指定。
4. **解析(必须)**:取真实 `recordId` 与字段值;分页用 `--cursor`,全表用 `--all --page-limit N`。
5. **衔接(必须)**:拿到 recordId → SOP-5 更新、`record delete --record-ids --yes` 删除(删前确认)。
1. 零/多候选、字段歧义或分页不完整:停止并返回证据;需要后续页时只透传真实 `nextCursor`。
2. 类型错误只复核目标字段,不删字段或丢输入;`partial_success` 从 checkpoint 续跑,未知写入先回读。
3. 错误包含 `actions` / `available_flags` 时只执行其中的 `next_command`;同一操作最多做一次有证据的参数修正。`retryable=false` 或目标 ID 类型不符时停止,不把 Drive/Wiki/Space/子节点 ID 轮流代入试错。
**禁止**:用字段名做 filter/sort key、对日期用 `eq`、漏掉 `direction` 用旧 `order` 字段、用本地过滤替代服务端 filter。
## 跨产品边界
### SOP-5 更新记录(record update)
**触发**:改记录字段值、批量更新状态、单字段重命名需求之外的记录改动。
1. **前置(必须)**:SOP-1 拿 `baseId`/`tableId`;SOP-2 拿字段类型;SOP-4 拿目标 `recordId`。
2. **执行(必须)**:`dws aitable record update --base-id <baseId> --table-id <tableId> --records '[{"recordId":"recXXX","cells":{"<fieldId>":<新值>}}]' --format json`(每条必含 `recordId`+`cells`,单次≤100;超长用 `--records-file`);只传需改字段,未传保持原值。
3. **解析与验证(必须)**:写入格式同 SOP-3;从返回 `data.recordIds[]` 取实际更新的记录 ID。更新响应不返回“受影响字段”,必须立即用 `record query --record-ids <id1,id2,...> --format json` 回读目标字段确认。
4. **失败(必须)**:recordId 不存在或类型不符 → 回 SOP-4 重新定位,**禁止**编造 ID 强写。
**禁止**:省略 `recordId`、用字段中文名当 cells key、凭空猜测 recordId 直接 update。
## 危险操作
`base delete` / `table delete` / `field delete` / `record delete` 不可逆,必须先向用户确认再加 `--yes`。
## 高频硬约束
- 创建/改字段/写记录是多轮连续任务时,不能在"让我执行/先获取 ID"后停下;必须实际调用对应 `dws aitable` 命令并验证结果。
- 字段重命名使用 `dws aitable field update --base-id <baseId> --table-id <tableId> --field-id <fieldId> --name "<新名称>" --format json`;先 `field get` 找真实 `fieldId`,不要猜字段名能直接更新。
- 写记录前必须 `field get` 获取 `fieldId` 与类型;`record create/update` 的 `cells` key 用 `fieldId`,不是字段中文名。长 JSON 使用 `--records-file`。
- 表或字段创建返回名称被系统自动加后缀时,后续必须使用返回的真实 `tableId`/`fieldId`,不要继续按原名称猜。
- `record update/delete` 先 `record query/list` 定位 `recordId`;删除必须确认,普通新增/更新按用户明确要求可直接执行后读回验证。
- `record query/create/update/delete`、`field create`、导入导出、图表和附件场景必须先读对应 `references/aitable/*.md`,不要凭旧单文件参数猜 flag。
## 字段类型规则
详见本 skill 的 [field-rules.md](references/field-rules.md)。
## 跨产品协作
- 单元格 / 工作表 / 公式 → 切到 `dingtalk-misc`(`references/sheet.md`,命令前缀:`dws sheet`)
## 局部意图
- [局部意图消歧](references/intent-guide.md)。
- Excel 式单元格、区域和公式操作 → `dingtalk-misc` 的 Sheet。
- Base 作为整体在普通文件夹间移动或做外层存储重命名 → Drive;Base 结构复制/删除,以及 Base 内 Table、Dashboard、Section 的创建、复制、移动、重命名、删除 → AITable。
- 记录主键文档正文 → 取得真实 nodeId 后切 `dingtalk-doc`。
@@ -1,137 +1,78 @@
# 记录操作详细指南
# AI 表格记录操作
## 查询记录
仅在根 Skill 的记录 Golden Route 参数不足,或需要字段值格式、删除、历史、分享、附件细节时读取。本文件不负责 Base/Table 选路。
## 查询
```bash
dws aitable record query --base-id <BASE_ID> --table-id <TABLE_ID> --format json
dws aitable +record-query --base-id <B> --table-id <T> --record-ids <R1,R2>
dws aitable +record-query --base-id <B> --table-id <T> --record-ids <R1,R2> --field-ids <F_NAME,F_STATUS>
dws aitable +record-query --base-id <B> --table-id <T> --query "关键词" --limit 100
dws aitable +record-query --base-id <B> --table-id <T> --filters '<JSON>' --sort '<JSON>'
dws aitable record query --base-id <B> --table-id <T> --filters '<JSON>' --all --page-limit 50
```
返回:
```json
{
"data": {
"records": [
{"recordId": "rec001", "cells": {"fldABC": "完成设计", "fldDEF": {"id":"opt1","name":"进行中"}}},
{"recordId": "rec002", "cells": {"fldABC": "编写文档", "fldDEF": {"id":"opt2","name":"待开始"}}}
]
}
}
```
- `record-ids` 用于稳定 ID 精确读取;`query` 用于全文搜索;复杂条件用 `filters`。
- 用户要求“只返回/仅查看”指定列时,查询必须在工具层传 `--field-ids <ID1,ID2>`。不要先拉取全部字段再只在最终答复中删列;字段投影既是结果契约,也是降低响应 token 的手段。
- filters 使用 `{"operator":"and|or","operands":[...]}`,字段引用使用 fieldId。若 Case 明确需要复杂操作符,应一开始把 [filter-sort](aitable/aitable-filter-sort.md) 选为唯一 Reference,而不是先读本文件后继续加载。
- `+record-query` 必须传真实 `base-id` 和 `table-id`。URL 先用 `+url-resolve`;名称先用 `+resolve-base` / `+resolve-table` 唯一解析,禁止自动选第一项。
- 单页 `limit` 为 1-100。返回 `data.records`、`data.hasMore`,存在后续页时还会返回 `data.nextCursor`;把该值原样传给下一次 `--cursor`。
- `+record-query` 不提供 `--all`;明确要求全量时改用原子 `record query --all --page-limit <N>`。达到页上限后仍有 `hasMore=true` 代表截断,应从返回 cursor 续跑;只有 `hasMore=false`,或按 `record-ids` 查询且所有请求 ID 均已返回时,才能声称结果完整。
按条件查询 (`--filters` 结构极易出错,请**强制**套用以下模板)
```bash
# 最外层必须是 "and" 或 "or",单选字段传文本名称。
# 示例:基础条件查询模板(可改内部 operator 为 contain 等)
dws aitable record query --base-id <BASE_ID> --table-id <TABLE_ID> \
--filters '{"operator":"and","operands":[{"operator":"eq","operands":["fld_state","进行中"]}]}' \
--format json
## 新增
# 关键词搜索
dws aitable record query --base-id <BASE_ID> --table-id <TABLE_ID> --keyword "设计" --format json
# 按 ID 查询
dws aitable record query --base-id <BASE_ID> --table-id <TABLE_ID> --record-ids rec001,rec002 --format json
# 游标分页
dws aitable record query --base-id <BASE_ID> --table-id <TABLE_ID> --limit 50 --cursor <CURSOR> --format json
```
## 添加记录
**必须先执行 `field get` 获取 fieldId,再写入。cells 的 key 必须是 fieldId(如 fldXXX),不是字段名。**
当前没有 `+record-create`,使用原子命令:
```bash
# 单条
dws aitable record create --base-id <BASE_ID> --table-id <TABLE_ID> \
--records '[{"cells":{"fldABC":"完成设计","fldDEF":"进行中"}}]' \
--format json
# 多条
dws aitable record create --base-id <BASE_ID> --table-id <TABLE_ID> \
--records '[
{"cells":{"fldABC":"任务A","fldDEF":"待开始"}},
{"cells":{"fldABC":"任务B","fldDEF":"进行中"}}
]' --format json
dws aitable record create --base-id <B> --table-id <T> \
--records '[{"cells":{"fldText":"内容"}}]' --format json
```
返回:
```json
{"data": {"newRecordIds": ["rec-new-001", "rec-new-002"]}}
```
长 JSON 写到 cwd 内相对文件后使用 `--records-file ./records.json`。从真实返回的 `data.newRecordIds[]` 取 recordId,再用 `+record-query --record-ids` 回读;用户限定返回列时同时传 `--field-ids`。不要从输入顺序、名称或行号推断 ID。
从 `data.newRecordIds[]` 提取新记录 ID,并立即执行 `record query --record-ids <id1,id2,...>` 回读写入结果;不要只看命令退出码。
## 更新、同步与批量修改
### --records 格式常见错误
已知 recordId:
```bash
# 正确: 参数名是 --records,cells key 是 fieldId
--records '[{"cells":{"fldABC":"值"}}]'
# 错误: 参数名写成 --data
--data '[{"cells":{"fldABC":"值"}}]'
# 错误: cells key 用了字段名而非 fieldId
--records '[{"cells":{"任务名称":"值"}}]'
# 错误: 用 fields 而非 cells
--records '[{"fields":{"fldABC":"值"}}]'
dws aitable +record-update --base-id <B> --table-id <T> \
--records '[{"recordId":"<R>","cells":{"fldStatus":"完成"}}]'
```
## 更新记录
`+record-update` 自动按 100 条分片并逐批回读;该 shortcut 只接受 `--records`。超长文件输入需要改用原子 `record update --records-file`,不要给 shortcut 猜造 flag。
`--records` 中每条记录必须包含 `recordId`(从 `record query` 获取):
单选、多选等字段的写入值可能是名称字符串,读回则是 `{id,name}` 或对象数组。若 shortcut 因原始类型不同返回 commit-unknown/read-back mismatch,禁止重放更新;只按返回的 recordId 做一次 `+record-query`,把单选按 `name`、多选按名称集合归一化比较。归一化后与目标一致时,按“独立读回已确认写入”报告,并保留 shortcut 的误报信息供排障。
按业务唯一键同步使用 `+record-upsert-by-key`:0 条创建、1 条更新、多条冲突停止。按条件批改使用 `+record-bulk-patch`,必须提供 filters/query/record-ids 中至少一种选择条件,或显式 `--all`,并设置合理 `--max-matches`。
## 删除
```bash
dws aitable record update --base-id <BASE_ID> --table-id <TABLE_ID> \
--records '[{"recordId":"rec001","cells":{"fldDEF":"已完成"}}]' \
--format json
dws aitable +record-delete --base-id <B> --table-id <T> --record-ids <R1,R2>
```
更新响应中的成功记录 ID 位于 `data.recordIds[]`。只需传入需修改的字段,未传入的保持原值;响应不返回“受影响字段”,必须再执行 `record query --record-ids <id1,id2,...>` 回读确认。
删除不可逆。只使用已确认的真实 recordId;shortcut 自动分片并验证记录已不存在。未知结果按 recordId 回读,不重放已完成批次。
## 删除记录
## 常用字段值
```bash
dws aitable record delete --base-id <BASE_ID> --table-id <TABLE_ID> \
--record-ids rec001,rec002 --yes --format json
```
| 字段类型 | 写入值 |
|---|---|
| 文本、单选 | 字符串;单选使用已有选项名称 |
| 多选 | 选项名称数组 |
| 数字、评分 | JSON number |
| 复选框 | boolean |
| 日期 | 按字段配置要求的时间值;不凭展示文本猜格式 |
| URL | 按当前字段 Schema 要求的对象或字符串 |
| 人员、关联记录 | 使用真实 userId/recordId,不用姓名代替 |
| 附件 | 先用 `+attachment-put` 获得 AITable 附件 token,再写字段 |
不可逆操作。调用前建议先 `record query` 确认目标记录。
公式、查找引用、创建人/时间、修改人/时间等只读字段不得写入。字段类型不明时只读取目标字段配置一次。
## 附件上传
## 历史、分享与主键文档
> **不要使用钉盘 (drive) 上传!** 钉盘 fileId 无法写入 attachment 字段。
- 记录历史:`dws aitable +record-history-list --base-id <B> --table-id <T> --record-id <R>`。已有真实 recordId 直接执行,不扫描 Help 或产品 Catalog。
- 批量记录分享:`dws aitable +record-share-links --base <B> --table <T> --record-ids <R1,R2>`;单条也可用 `+record-share-url`。
- 用户要求把分享链接“发给”联系人时,AITable 的职责在链接生成后结束;随后加载 `dingtalk-chat`,用 `dws chat +dm --to <姓名> --text <包含全部链接的文本>` 对每位收件人分别发送并检查真实回执。只解析联系人或只生成 URL 都不算完成。
- 主键文档:`+record-primary-doc-get` / `+record-primary-doc-create`
使用 `upload_attachment.py` 脚本(内部自动完成 prepare + PUT to OSS),**2 步**完成:
```bash
# 步骤 1: 一键上传文件
python3 scripts/upload_attachment.py <BASE_ID> /path/to/report.pdf
# 输出: { "fileToken": "ft_xxx", "fileName": "report.pdf", "size": 204800 }
# 步骤 2: 在 record create/update 中使用 fileToken 写入附件字段
dws aitable record create --base-id <BASE_ID> --table-id <TABLE_ID> \
--records '[{"cells":{"fldAttachId":[{"fileToken":"ft_xxx"}]}}]' --format json
```
> attachment 字段值必须是数组 `[{"fileToken":"ft_xxx"}]`,支持多个附件。
## 字段类型写入规则
| 类型 | 写入格式 | 读取返回格式 |
|------|----------|-------------|
| text | `"fldXXX":"文本值"` | `"fldXXX":"文本值"` |
| number | `"fldXXX":123` | `"fldXXX":"123"` |
| singleSelect | `"fldXXX":"选项名"` | `"fldXXX":{"id":"xxx","name":"选项名"}` |
| multipleSelect | `"fldXXX":["选项1","选项2"]` | `"fldXXX":[{"id":"xxx","name":"选项1"}]` |
| date | `"fldXXX":"2026-03-04"` | ISO 日期字符串 |
| user | `"fldXXX":[{"userId":"123"}]` | `"fldXXX":[{"corpId":"x","userId":"123"}]` |
| attachment | `"fldXXX":[{"fileToken":"ft_xxx"}]`需先用脚本上传 | `"fldXXX":[{"url":"...","filename":"..."}]` |
### 只读字段(不要写入)
- 创建时间、修改时间、创建人、修改人
- 公式字段、引用字段
- 自动编号字段
执行 `field get` 后识别字段类型,跳过只读字段。
创建主键文档必须显式传 primaryDoc 类型的 `--field-id`;字段类型不明时先读取目标字段。正文读写切到 Doc;这里仅管理记录与文档关联。
@@ -0,0 +1,28 @@
# AI 表格 Section 与内部节点
只在用户操作 Base 内文件夹(Section)或把 Table/Dashboard 移入、移出 Section 时读取。这里的节点是 nsheet 业务节点,不是独立 Drive dentry;不要加载 Drive Skill 或尝试 Drive move。
## 高频闭环
创建 Section 并移动节点:
```bash
dws aitable +section-create --base-id <B> --name "归档区" --format json
dws aitable +section-move-node --base-id <B> --node-id <TABLE_OR_DASHBOARD_ID> --new-parent-section-id <S> --format json
dws aitable +section-list-nodes --base-id <B> --format json
```
移动回 Base 根目录时显式传空字符串,不能省略该参数或改用 Drive:
```bash
dws aitable +section-move-node --base-id <B> --node-id <N> --new-parent-section-id '' --format json
```
## 删除空 Section
1. 用 `+section-list-nodes` 核对目标 Section 内节点;需要移出的节点逐个 `+section-move-node`。
2. 用 `+section-list-empty --base-id <B>` 验证目标 sectionId 确实为空。
3. 执行 `+section-delete --base-id <B> --section-id <S>`;按 Runtime confirmation 处理。
4. 再次 `+section-list-nodes` 或 `+section-list-empty`,确认 Section 已不存在且被移动节点仍在预期父级。
Table 本身的创建、复制、改名、删除分别使用 `+table-*`;Section 只管理 Base 内目录关系。
@@ -1,495 +1,113 @@
# AI表格 (aitable) 命令参考
# AI 表格执行 Reference(兼容索引,非默认入口)
> **渐进式文档**:本文件为路由层(索引 + 意图判断),各命令的详细参数、示例和踩坑说明在 [aitable/](./aitable/) 目录下按需加载。
根 Skill 已包含高频 Golden Route 的准确参数,参数足够时直接执行,不读取本文件。本文件仅作兼容索引:只有根 Skill 与精确操作 Reference 都无法覆盖时,才可把它作为该 Case 唯一读取的 Reference;读取后不再加载第二个 Reference、`--help` 或产品级 Catalog。
## 文档地址 (URI)
| 资源 | URI 格式 |
|------|----------|
| Base 文档 | `https://alidocs.dingtalk.com/i/nodes/{baseId}` |
| 指定数据表 | `https://alidocs.dingtalk.com/i/nodes/{baseId}?iframeQuery=sheetId%3D{tableId}` |
| 指定数据表+视图 | `https://alidocs.dingtalk.com/i/nodes/{baseId}?iframeQuery=sheetId%3D{tableId}%26viewId%3D{viewId}` |
| 模板预览 | `https://docs.dingtalk.com/table/template/{templateId}` |
> **操作后请返回文档 URI**:返回链接时必须带上当前操作的数据表 tableId,让用户点击后直接看到目标数据表,而不是落在空白的默认表。
> - 已知 tableId + viewId 时(view create 返回、view get 中提取):拼接 `https://alidocs.dingtalk.com/i/nodes/{baseId}?iframeQuery=sheetId%3D{tableId}%26viewId%3D{viewId}`
> - 已知 tableId 时(table create 返回、base get 中提取、record 操作所用的 tableId):拼接 `https://alidocs.dingtalk.com/i/nodes/{baseId}?iframeQuery=sheetId%3D{tableId}`
> - 仅有 baseId、无明确 tableId 时(如 base list/search):拼接 `https://alidocs.dingtalk.com/i/nodes/{baseId}`
>
> 补充:如果 URL 不是来自 `aitable` 命令返回,而是用户直接贴的原始 `alidocs` URL,先按 [链接规范](url-patterns.md#alidocs-url-类型探测流程) probe,确认是 `able` 后再按 AI 表格处理。
## 命令索引表
### base (Base 管理)
| 命令 | 用途 | 必填参数 | 路由提醒 |
|------|------|----------|----------|
| `base list` | 列出最近访问的 Base | — | 仅返回最近访问过的,优先用 `base search` |
| `base search` | 按名称搜索 Base | `--query` | 关键词 ≥2 字符 |
| `base get` | 获取 Base 信息(含 tables 列表) | `--base-id` | 用户给 URL 时提取末尾 ID |
| `base create` | 创建 Base | `--name` | 创建后直接用返回的 baseId;**默认新建的 base 自带一个空白「数据表」(含 3 行空记录)和一个空白仪表盘**,如需干净的空 base,传 `--template-id 1743` |
| `base update` | 更新 Base 名称 | `--base-id` `--name` | — |
| `base delete` | 删除 Base | `--base-id` | 不可逆 |
### table (数据表管理)
| 命令 | 用途 | 必填参数 | 路由提醒 |
|------|------|----------|----------|
| `table get` | 获取数据表/视图目录 | `--base-id` | 不传 `--table-ids` 枚举全部表,但不返回字段;字段目录使用 `field get` |
| `table create` | 创建数据表 | `--base-id` `--name` `--fields` | fields 为 JSON 数组,至少 1 个 |
| `table update` | 修改表名 / 备注 / 行命名规则 | `--base-id` `--table-id` + 三选一(`--name` / `--description` / `--record-name-key`) | `--record-name-key` 是固定枚举(如 task/project/event/customer/ji_lu 等),非字段 ID |
| `table delete` | 删除表 | `--base-id` `--table-id` | 不可逆 |
### field (字段管理) → 详见 [aitable-field.md](./aitable/aitable-field.md)、[field-properties](./aitable/aitable-field-properties.md)
| 命令 | 用途 | 必填参数 | 路由提醒 |
|------|------|----------|----------|
| `field get` | 获取字段完整配置 | `--base-id` `--table-id` | 按需展开少量字段 |
| `field create` | 创建字段 | `--base-id` `--table-id` + (`--name --type` 或 `--fields`) | 单字段/批量两种模式严格互斥;单字段配置传 `--config`,批量配置写入 `--fields` 每个元素的 `config` |
| `field update` | 更新字段名/配置 | `--base-id` `--table-id` `--field-id` | 不可变更字段类型 |
| `field delete` | 删除字段 | `--base-id` `--table-id` `--field-id` | 不可逆 |
#### 搜索字段选项
```
Usage:
dws aitable field search-options [flags]
Example:
dws aitable field search-options --base-id <BASE_ID> --table-id <TABLE_ID> --field-id <FIELD_ID>
dws aitable field search-options --base-id <BASE_ID> --table-id <TABLE_ID> --field-id <FIELD_ID> --keyword 已完成
dws aitable field search-options --base-id <BASE_ID> --table-id <TABLE_ID> --field-id <FIELD_ID> --limit 100
Flags:
--base-id string Base ID (必填)
--field-id string 目标字段 ID,必须是 singleSelect / multipleSelect 类型 (必填)
--keyword string 模糊搜索关键词,大小写不敏感、contains 匹配 option name;不传返回全部
--limit int 返回的最大 option 数量,默认 3000(全量),最大 3000
--table-id string Table ID (必填)
```
仅适用于 **singleSelect / multipleSelect** 字段。其他类型(text/number/date/...)调用会返回错误。
适用场景:
- options 较多,只想要含某关键词的子集(避免 `field get` 拉取整个字段配置带回所有 options)。
- 写入 record 前预览选项 id ↔ name 的映射,确认要使用的选项确实存在。
> **写 record 时**:`record create / update` 对 singleSelect/multipleSelect 可直接传 option **name**,不需要用本命令。本命令主要用于 **filter** 写法(filters 优先用 option **id**)或选项较多需要精确定位时。
### record (记录管理)
| 命令 | 用途 | 必读 reference | 路由提醒 |
|------|------|----------------|----------|
| `record query` | 查询/搜索记录 | [aitable-record-query.md](./aitable/aitable-record-query.md) | 先 `field get` 拿 fieldId;`--all` 自动翻页;filters 结构见 reference |
| `record get` | 按 ID 取记录(`record query --record-ids` 的窄别名) | [aitable-record-query.md](./aitable/aitable-record-query.md) | 已知 recordId 时首选;必填 `--record-ids`(单次最多 100 条);未暴露 filters/sort/query/cursor/limit |
| `record stats` | 不分组的服务端聚合 | [aitable-record-stats.md](./aitable/aitable-record-stats.md) | statsType 大写;最多 20 项,同字段不可重复;全量统计省略 limit |
| `record group-stats` | 分组、去重和高级服务端聚合 | [aitable-record-stats.md](./aitable/aitable-record-stats.md) | statsType 小写;group 为 JSON 数组字符串;最多 1000 个分组 |
| `record create` | 新增记录 | [aitable-record-create.md](./aitable/aitable-record-create.md) | cells key 必须是 fieldId 不是字段名;单次最多 100 条 |
| `record update` | 更新记录(每条独立 cells) | [aitable-record-update.md](./aitable/aitable-record-update.md) | 需先 query 拿 recordId;`cells` key 支持 fieldId 或当前表内唯一字段名,推荐 fieldId;`--records` 是 `[{recordId,cells},...]` 数组 |
| `record batch-update` | 批量更新(同一 cells 应用到多条 recordId) | [aitable-record-update.md](./aitable/aitable-record-update.md)、[aitable-cell-value.md](./aitable/aitable-cell-value.md) | 适合"统一标记完成/统一改负责人"等共享 patch 场景;`--cells` 是 JSON object(key=fieldId,value 按字段类型见 cell-value.md),与 record update 的单条 cells 结构完全一致;必填 `--record-ids` `--cells`;单次最多 100 条 |
| `record delete` | 删除记录 | [aitable-record-delete.md](./aitable/aitable-record-delete.md) | 不可逆,需先 query 确认 |
| `record history-list` | 查询单条记录的变更历史 | [aitable-record-history.md](./aitable/aitable-record-history.md) | 必填 `--record-id`;分页 `--offset --limit`,limit 范围 [1,50] 默认 20 |
| `record query-empty` | 查询完全没填用户字段的空行 | [aitable-record-query.md](./aitable/aitable-record-query.md) | 一页扫描 `--limit` [1,100] 默认 100;扫完前需用 `--cursor` 翻页(nextCursor 为空才表扫完) |
| `record share-url` | 批量获取记录分享链接 | [aitable-record-share.md](./aitable/aitable-record-share.md) | 必填 `--record-ids`(CSV,单次最多 20 条);可选 `--view-id` 带视图上下文 |
| `record upsert` | 批量创建或更新(按 recordId 是否存在自动拆分) | [aitable-record-upsert.md](./aitable/aitable-record-upsert.md) | --records 同 record update 格式;带 recordId 走 update,不带走 create;单次最多 100 |
| `record primary-doc-get` | 查询记录的主键文档 nodeId | [aitable-primary-doc.md](./aitable/aitable-primary-doc.md) | 返回的 nodeId 可直接用于 `dws doc read/update --node` |
| `record primary-doc-create` | 为记录创建主键文档(幂等) | [aitable-primary-doc.md](./aitable/aitable-primary-doc.md) | fieldId 必须是 primaryDoc 类型;已存在则返回已有 nodeId |
### view (视图管理)
| 命令 | 用途 | 必填参数 | 路由提醒 |
|------|------|----------|----------|
| `view get` | 获取视图配置(不传子命令) | `--base-id` `--table-id` | 不传 `--view-ids` 返回全部视图 |
| `view get <attr>` | 获取视图某个属性 | `--view-id` | 12 个:card/timebar/aggregate/filter/sort/group/visible-fields/field-widths(详见 [aitable-view-config.md](./aitable/aitable-view-config.md))+ lock/frozen-cols/row-height/fill-color-rule(详见 [aitable-view-extras.md](./aitable/aitable-view-extras.md)) |
| `view list` | 列出全部视图(`view get` 的别名) | `--base-id` `--table-id` | 与 `view get` 完全等价 |
| `view create` | 创建视图 | `--base-id` `--table-id` `--view-type` | 类型: Grid/Kanban/Gantt/Calendar/Gallery/FormDesigner;用 `--config` 传 visibleFieldIds/filter/sort/group;**Gantt 创建后必须 `view update timebar` 绑定日期字段** |
| `view update` | 整体更新视图 / 多属性合并更新 | `--base-id` `--table-id` `--view-id` | 可传 `--name --desc --config '{...}'`,**`--config` 路径继续保留** |
| `view update <attr>` | 按属性局部更新(推荐)| `--view-id` + typed flag / `--json` | 12 个:card/timebar/aggregate/field-widths/visible-fields/filter/sort/group/name + frozen-cols/row-height/fill-color-rule |
| `view lock [--off]` | 锁定/解锁视图 | `--base-id` `--table-id` `--view-id` | 默认锁定;`--off` 解锁。详见 [aitable-view-extras.md](./aitable/aitable-view-extras.md) |
| `view duplicate` | 复制视图 | `--base-id` `--table-id` `--view-id` | 可选 `--new-name`;保留源视图全部配置。详见 [aitable-view-extras.md](./aitable/aitable-view-extras.md) |
| `view delete` | 删除视图 | `--base-id` `--table-id` `--view-id` | 不可删最后一个/锁定视图 |
> **优先用 `view get <attr>` / `view update <attr>` 子命令**:每个属性独立命令,typed flag 友好,agent 不必拼 JSON。**`view update --config '{...}'` 仍可用**,适合一次性多属性更新或脚本场景。
> **属性按 attr 分类,决定该读哪份子文档**:
> - card / timebar / aggregate / filter / sort / group / visible-fields / field-widths → [aitable-view-config.md](./aitable/aitable-view-config.md)
> - lock / frozen-cols / row-height / fill-color-rule / duplicate → [aitable-view-extras.md](./aitable/aitable-view-extras.md)
> 后一类**不能**塞进 `view update --config '{...}'`,必须用各自专属子命令;如果错传 `flags` / `frozenColCount` / `cellHeight` / `conditionalFormats` 等 key 进 `--config`,CLI 会在 stderr 提示应改用的命令。
> **`view update --config` 支持的 9 个 key**:
> `visibleFieldIds` / `filter` / `sort` / `group` / `fieldWidths`(Grid) / `aggregate`(Grid) / `kanbanCard`(Kanban) / `ganttTimebar`(Gantt) / `galleryCard`(Gallery)。
> filter/sort/group 必须传**数组**格式(与 `record query --filters` 的对象格式不同;CLI 会自动容错)。其他 key 会被服务端忽略并打 warning。
### form (表单管理) → 详见 [aitable-form.md](./aitable/aitable-form.md)
| 命令 | 用途 | 必填参数 | 路由提醒 |
|------|------|----------|----------|
| `form list` | 列出表单视图 | `--base-id` `--table-id` | 详情见 [aitable-form.md](./aitable/aitable-form.md) |
| `form get` | 按 viewId 取单个表单详情 | `--base-id` `--table-id` `--view-id` | — |
| `form create` | 创建表单视图 | `--base-id` `--table-id` `--name` | — |
| `form update` | 更新表单配置 | `--base-id` `--table-id` `--view-id` | title/name/description 至少一项 |
| `form delete` | 删除表单 | `--base-id` `--table-id` `--view-id` | 不可逆 |
| `form field list/update/hide` | 表单字段管理 | — | 详情见子文档 |
| `form questions create/delete` | 题目管理(=field create/delete) | — | 详情见子文档 |
| `form share get/update` | 表单分享配置 | — | 详情见子文档 |
> **创建表单**有两种等价方式:`form create --name "..."`(推荐)或 `view create --view-type FormDesigner --name "..."`。
### workflow (自动化工作流) → 详见 [aitable-workflow.md](./aitable/aitable-workflow.md)
| 命令 | 用途 | 必填参数 | 路由提醒 |
|------|------|----------|----------|
| `workflow edit-example` | 获取编辑文档与 DSL 示例 | 无 | create/update 前优先调用,内容由服务端提供 |
| `workflow create` | 创建并发布自动化工作流 | `--base-id` `--dsl` | 按子文档 Demo 组装 DSL;必须检查返回的 `data.valid` / `issues`;create 不自动重试 |
| `workflow update` | 更新并发布已有自动化工作流 | `--base-id` `--workflow-id` `--dsl` | 先 get 留底;提交完整目标 DSL;必须检查 `data.valid` / `issues` |
| `workflow list` | 列出 Base 下所有工作流 | `--base-id` | 支持 `--limit [1,100]` / `--offset >=0`;list 出参字段叫 `flowId` |
| `workflow get` | 获取单个工作流详情(含 flowSchema) | `--base-id` `--workflow-id` | `--workflow-id` 接受 list 里的 `flowId`(同值) |
| `workflow enable` | 启用工作流 | `--base-id` `--workflow-id` | 返回 `{enabled: true}` 是动作确认;要确认真启用看 list 的 `status` |
| `workflow disable` | 禁用工作流(高危) | `--base-id` `--workflow-id` `--yes` | 影响业务自动化,建议二次确认;status 变 STOP |
| `workflow run` | 立即执行工作流(需确认) | `--base-id` `--workflow-id`;记录触发另需 `--table-id` `--record-ids` | 返回 `executionId`;不确定时先用 history 核对,避免重复执行 |
| `workflow history` | 查询工作流执行历史 | `--base-id` `--workflow-id` | 支持 status、Unix 毫秒时间范围和 page/size;`instanceId` 对应 run 的 `executionId` |
> 创建/更新的 `--dsl` 使用钉钉 AI 表格 `workflow-dsl/v1`;完整格式和最小 Demo 见 [aitable-workflow.md](./aitable/aitable-workflow.md)。删除工作流暂未开放。
### dashboard & chart → 详见 [aitable-dashboard-chart.md](./aitable/aitable-dashboard-chart.md)
| 命令 | 用途 |
|------|------|
| `dashboard get/create/update/delete` | 仪表盘管理 |
| `dashboard config-example` | 查看仪表盘配置模板 |
| `dashboard arrange` | 自动重排仪表盘图表布局(智能填满网格,避免空缺) |
| `chart get/create/update/delete` | 图表管理 |
| `chart widgets-example` | 查看图表 widgets 配置模板 |
### export & import → 详见 [aitable-export-import.md](./aitable/aitable-export-import.md)
| 命令 | 用途 |
|------|------|
| `export data` | 导出数据(异步两阶段轮询) |
| `import upload` | 申请文件导入上传凭证 |
| `import data` | 触发导入 |
### attachment → 详见 [aitable-attachment.md](./aitable/aitable-attachment.md)
| 命令 | 用途 | 路由提醒 |
|------|------|----------|
| `attachment upload` | 准备附件上传凭证 | 不要用钉盘 drive 上传! |
### template (模板搜索)
| 命令 | 用途 | 必填参数 |
|------|------|----------|
| `template search` | 搜索模板 | `--query` |
### advperm (高级权限/自定义角色) → 详见 [aitable-advperm.md](./aitable/aitable-advperm.md)
| 命令 | 用途 | 必填参数 | 路由提醒 |
|------|------|----------|----------|
| `advperm enable` | 开启 Base 高级权限总开关 | `--base-id` | 不开启时角色规则不生效 |
| `advperm disable` | 关闭 Base 高级权限总开关(高危) | `--base-id` `--yes` | 关闭后全员回退默认权限 |
| `advperm role-list` | 列出 Base 下所有角色 | `--base-id` | 同时返回自定义角色和系统角色;`roleType == "custom"` 是自定义,前缀 `system_` 是系统角色 |
| `advperm role-get` | 获取单角色完整配置 | `--base-id` `--role-id` | 含 subRoles 与字段/行级规则 |
| `advperm role-create` | 创建自定义角色 | `--base-id` `--name` | 可选 `--sub-roles` 同时指定子角色权限规则 |
| `advperm role-update` | 增量更新自定义角色(PATCH) | `--base-id` `--role-id` | 未传字段不变;`--sub-roles` 按 (targetId,targetType) 合并 |
| `advperm role-delete` | 删除自定义角色 | `--base-id` `--role-id` `--yes` | 不可逆;系统角色禁删;**调用者必须是该 AI 表格的管理员/Owner**,非管理员会得到 401 AUTH_ERROR |
> **角色 CRUD 已全支持**:create/get/list/update/delete 都可走 CLI。
> 所有写命令(enable/disable/role-create/role-update/role-delete)需要 Base 管理员权限;非管理员只能调 `role-list` / `role-get`(只读)。
> "角色 ↔ 成员"绑定当前 CLI 不支持,仍需在 AI 表格 Web 端 → Base 设置 → 高级权限面板手动完成。
### section (文件夹与节点管理)
> 用于在 Base 的导航树中组织 table / dashboard / 表单视图 / 文档等节点(类似文件夹)。
> 操作前建议先用 `section list-nodes` 拿到 nodeId / sectionId 与父级关系。
#### 创建文件夹
```
Usage:
dws aitable section create [flags]
Example:
dws aitable section create --base-id <BASE_ID> --name 我的文件夹
dws aitable section create --base-id <BASE_ID> --name 子文件夹 --parent-section-id <SECTION_ID> --index 0
Flags:
--base-id string Base ID (必填)
--name string 文件夹名称 (必填)
--parent-section-id string 父文件夹 ID;不传或空字符串表示创建在 Base 根目录下
--index int 在父文件夹下的目标位置(0-based);不传则追加到末尾
```
返回 `data.sectionId` 与 `data.name`。
#### 重命名文件夹
```
Usage:
dws aitable section rename [flags]
Example:
dws aitable section rename --base-id <BASE_ID> --section-id <SECTION_ID> --new-name 新名称
Flags:
--base-id string Base ID (必填)
--section-id string 目标文件夹 ID (必填)
--new-name string 新的文件夹名称 (必填)
```
#### 删除文件夹
```
Usage:
dws aitable section delete [flags]
Example:
dws aitable section delete --base-id <BASE_ID> --section-id <SECTION_ID>
Flags:
--base-id string Base ID (必填)
--section-id string 目标文件夹 ID (必填)
```
> **注意**:删除不可逆;删除前可先用 `section list-empty` 确认是否为空文件夹。
#### 调整文件夹顺序
```
Usage:
dws aitable section reorder [flags]
Example:
dws aitable section reorder --base-id <BASE_ID> --section-id <SECTION_ID> --target-index 0
Flags:
--base-id string Base ID (必填)
--section-id string 目标文件夹 ID (必填)
--target-index int 目标位置(0-based)(必填)
```
> 在**当前父文件夹下**调整展示顺序。跨父级移动请用 `section move-node`。
#### 列出空文件夹
```
Usage:
dws aitable section list-empty [flags]
Example:
dws aitable section list-empty --base-id <BASE_ID>
Flags:
--base-id string Base ID (必填)
```
返回 `data.items: [{sectionId, name, parentSectionId}]` 与 `data.total`,用于清理或诊断导航树(parentSectionId 为空串表示在根目录下)。
#### 列出全部节点
```
Usage:
dws aitable section list-nodes [flags]
Example:
dws aitable section list-nodes --base-id <BASE_ID>
Flags:
--base-id string Base ID (必填)
```
返回 `data.items: [{nodeId, nodeType, parentSectionId, name?}]` 与 `data.total`,涵盖文件夹 / AI 表格 / 表单视图 / 仪表盘 / 文档 / 查询视图。
> **与其他命令的关联**:是 `section move-node` / `section reorder` 的前置定位命令——先用它拿到 nodeId 与 parentSectionId。
#### 移动节点
```
Usage:
dws aitable section move-node [flags]
Example:
dws aitable section move-node --base-id <BASE_ID> --node-id <NODE_ID> --new-parent-section-id <SECTION_ID>
dws aitable section move-node --base-id <BASE_ID> --node-id <NODE_ID> --new-parent-section-id "" --target-index 0
Flags:
--base-id string Base ID (必填)
--node-id string 要移动的节点 ID(文件夹/AI表格/表单视图/仪表盘/文档/查询视图)(必填)
--new-parent-section-id string 目标父文件夹 ID;空字符串表示移到 Base 根目录 (必填)
--target-index int Base 内节点的全局位置(0-based);不传则不调整
```
> 服务端自动识别节点类型,无需区分文件夹与非文件夹。返回 `data.nodeId / newParentSectionId / nodeType`。
> 对文件夹节点带 `--target-index` 时会先 move 再 reorder,中间失败会返回 `MOVE_OK_REORDER_FAILED`,可用 `section reorder` 重试。
## 复杂操作
### 仪表盘 / 图表(建议顺序)
## 高频 Golden Route(准确 flags)
```bash
# 1) 先看配置模板(JSONC)
dws aitable dashboard config-example --format json
dws aitable chart widgets-example --format json
# 新建 Base 和整套表字段;tables 必须是非空数组
dws aitable +base-bootstrap --name "项目管理" --tables '[{"name":"任务","fields":[{"fieldName":"标题","type":"text"}]}]'
# 2) 先拿 dashboard,再拿 chart 详情
dws aitable dashboard get --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --format json
dws aitable chart get --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --chart-id <CHART_ID> --format json
# 只新建空 Base;folder-id / template-id 均为可选
dws aitable base create --name "项目管理"
dws aitable base create --name "项目管理" --folder-id <FOLDER_NODE_ID>
dws aitable base create --name "项目管理" --template-id <TEMPLATE_ID>
# 已有 Base 新建一张完整表;超过 15 个字段由 shortcut 自动分片
dws aitable +table-bootstrap --base-id <BASE_ID> --name "任务" --fields '[{"fieldName":"标题","type":"text"}]'
# 只补字段;单字段与批量字段二选一,单次最多 15 个
dws aitable field create --base-id <BASE_ID> --table-id <TABLE_ID> --name "状态" --type singleSelect --config '{"options":[{"name":"待办"},{"name":"完成"}]}'
dws aitable field create --base-id <BASE_ID> --table-id <TABLE_ID> --fields '[{"fieldName":"状态","type":"singleSelect","config":{"options":[{"name":"待办"}]}}]'
# 创建视图;view-type 合法值:Grid/FormDesigner/Gantt/Calendar/Kanban/Gallery
dws aitable view create --base-id <BASE_ID> --table-id <TABLE_ID> --view-type Grid --name "默认表格"
# Gantt 必须再绑定日期字段,否则只是空壳
dws aitable view update timebar --base-id <BASE_ID> --table-id <TABLE_ID> --view-id <VIEW_ID> --start-field <DATE_FIELD_ID>
# 创建仪表盘和图表;chart 的 config/layout 都必填
dws aitable dashboard create --base-id <BASE_ID> --name "运营看板"
dws aitable chart create --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --config '<WIDGET_CONFIG_JSON>' --layout '{"x":0,"y":0,"w":12,"h":4}'
# 查询记录;选择器按意图传一个,不猜 flag
dws aitable +record-query --base-id <BASE_ID> --table-id <TABLE_ID>
dws aitable +record-query --base-id <BASE_ID> --table-id <TABLE_ID> --record-ids <RECORD_ID_1,RECORD_ID_2>
dws aitable +record-query --base-id <BASE_ID> --table-id <TABLE_ID> --record-ids <RECORD_ID_1,RECORD_ID_2> --field-ids <FIELD_ID_1,FIELD_ID_2>
dws aitable +record-query --base-id <BASE_ID> --table-id <TABLE_ID> --filters '<FILTER_JSON>'
dws aitable +record-query --base-id <BASE_ID> --table-id <TABLE_ID> --query "关键词"
# 文件导入:先申请凭证并上传,再用真实 importId 导入
dws aitable +import-upload --base-id <BASE_ID> --file-name data.xlsx --file-size <BYTES>
curl -X PUT "<UPLOAD_URL>" -H "Content-Type:" --data-binary @data.xlsx
dws aitable +import-data --import-id <IMPORT_ID>
dws aitable +import-data --import-id <IMPORT_ID> --table-id <TABLE_ID>
```
要点:
写命令是否追加 `--yes` 只以 Runtime confirmation 为准,不把 `--yes` 固化进示例。字段对象统一使用 `fieldName` / `type` / 可选 `config`;不要改写成相似的 `field-name`、`field-type` 或 `table-name`。建表时第一个字段会成为主字段,应优先使用 `text`;传空字段数组时由服务自动补标题主字段。导入 OSS PUT 的 `Content-Type` 必须显式置空,否则可能返回 403。
- `dashboard get` 返回的 `charts[].chartId` 可直接给 `chart get` 使用。
- `dashboard share get` 可能返回 `404`(资源不存在或未开通),需按可重试错误处理,不要误判为参数拼错。
- `chart share get` 可正常返回 `enabled/shareUrl`,用于分享状态判断。
## 错误恢复
### 导出数据(两阶段轮询)
- 结构化错误存在 `actions` / `available_flags` 时,`actions` 中的完整命令就是唯一下一步;不要试相似 ID 或 flag。
- `partial_success`:保留 `knownSideEffects` 与 `checkpoint`,只执行返回的 `nextCommand` 检查或恢复,不重放已成功批次。
- `unknown`:先按 `nextCommand` 或业务唯一名称确认远端效果;未确认前不得重试非幂等创建。
- `retryable=false`:停止。目标文件夹无效时只用返回的 `dws drive +info --node <ID> --format json` 核对,不把其他类型 ID 轮流代入。
- 验证成功必须看到 `verification.status=verified`;退出码为 0 或写接口空回包本身不构成成功。
`export data` 常见为异步任务:首次调用可能只返回 `taskId`,需要继续轮询。
## 加载规则
```bash
# 第一步:创建任务(按 scope 传必要参数)
dws aitable export data --base-id <BASE_ID> --scope table --table-id <TABLE_ID> --format excel --timeout-ms 1000
- 先根据用户意图定位下表中的唯一命令族,再读取至多一个对应 leaf reference。
- 已知命令但参数不确定,只读一次该 leaf Schema;不要加载产品级 Schema 或完整 Catalog。
- 命令清单只在本索引仍无法定位时用 `dws shortcut list --service aitable --format json` 回退。
- 不使用 Python recipe 绕过已有 shortcut;不根据命令名猜 flag。
# 第二步:拿 taskId 继续轮询,直到返回 downloadUrl
dws aitable export data --base-id <BASE_ID> --task-id <TASK_ID> --timeout-ms 3000
```
## 目标与 ID
参数约束
| 已有信息 | 最短入口 | 输出复用 |
|---|---|---|
| AI 表格 URL | `dws aitable +url-resolve --url <URL>` | 解析 URL 中已有的 baseId/tableId/viewId/recordId;不做远端名称搜索 |
| 已知 Base/Table ID | 直接传给最终命令 | 不重复解析 |
| Base 精确名称,且要唯一定位后操作 | `dws aitable +resolve-base --name <名称>` | 默认精确匹配;明确需要模糊匹配时加 `--fuzzy`,零/多候选均停止 |
| 搜索 Base 候选、关键词查找或存在性检查 | `dws aitable +base-search --query <关键词>` | 直接检查返回候选,不先执行 `+resolve-base`;对象明确为 Base 时不得改走人员搜索 |
| Base ID + Table 名称 | `dws aitable +resolve-table --base <B> --name <T>` | 默认精确匹配;明确需要模糊匹配时加 `--fuzzy` |
| Base ID,只需目录 | `dws aitable +list-tables --base <B>` | 只投影 tableId/tableName,不额外读取字段 |
| 需要字段或视图目录 | `dws aitable +field-get ...` / `dws aitable +view-get ...` | 只读取当前任务需要的目标范围 |
- `scope=all`:只需 `base-id`
- `scope=table`:必须 `table-id`
- `scope=view`:必须同时 `table-id + view-id`
`+record-query` 只接受真实 `base-id` / `table-id`,因此 URL 或名称须先按上表解析。`+base-list` 只表示最近访问,不是组织内全量 Base;`+base-search --query <Q>` 是单次搜索。唯一操作目标零命中或多候选时停止,不选第一项;“搜索候选/如果没有就创建”不要再串行调用 resolver 和 search。
## 意图判断
## 低频命令族
用户说"表格/多维表/AI表格":
- 查看/查找/列表 → `base search`(优先)或 `base list`(仅浏览最近访问)
- 详情 → `base get`
- 创建 → `base create`
- 修改 → `base update`
- 删除 → `base delete`
| 意图 | 推荐 shortcut | 精确 reference |
|---|---|---|
| Base 新建整套结构 | `+base-bootstrap` | 使用本文上方准确 flags |
| Base 查看、搜索、复制、改名、删除、快照 | `+base-get` / `+base-search` / `+base-copy` / `+base-update` / `+base-delete` / `+base-schema-snapshot` | 对应 leaf Schema |
| Table 新建、查看、复制、改名、删除 | `+table-bootstrap` / `+table-get` / `+table-copy` / `+table-update` / `+table-delete` | 新建使用本文上方准确 flags;其余对应 leaf Schema |
| Field 完整配置、修改、删除 | `+field-get` / `+field-update` / `+field-delete` | [field](aitable/aitable-field.md);属性细节才读 [field-properties](aitable/aitable-field-properties.md) |
| Record 历史、空行、分享、主键文档 | `+record-history-list` / `+record-query-empty` / `+record-share-*` / `+record-primary-doc-*` | [record-ops](aitable-record-ops.md) |
| Record 统计、分组聚合和去重率 | `record stats` / `record group-stats` | [record-stats](aitable/aitable-record-stats.md) |
| Filter、sort、日期操作符 | `+record-query` / `+record-bulk-patch` | [filter-sort](aitable/aitable-filter-sort.md) |
| View 配置、复制、锁定、冻结列、行高、填色 | 对应 `+view-*` | [view-config](aitable/aitable-view-config.md);冻结列/行高/填色才读 [view-extras](aitable/aitable-view-extras.md) |
| Form 字段、分享、修改、删除 | 对应 `+form-*` | [form](aitable/aitable-form.md) |
| Dashboard 与 Chart | 对应 `+dashboard-*` / `+chart-*` | [dashboard-chart](aitable/aitable-dashboard-chart.md) |
| 导入、导出和任务恢复 | `+import-upload` / `+import-data` / `+export-data` | [export-import](aitable/aitable-export-import.md) |
| 上传或移除附件 | `+attachment-put` / `+attachment-remove` | [attachment](aitable/aitable-attachment.md) |
| 自动化工作流 | 对应 `+workflow-*` | [workflow](aitable/aitable-workflow.md) |
| 普通角色和高级权限 | 对应 `+role-*` / `+advperm-*` | [advperm](aitable/aitable-advperm.md) |
| AI 表格内部 Section/节点 | 对应 `+section-*` | [section](aitable-section.md) |
| 模板检索 | `+template-search` | leaf Schema 足够,不再读其他 reference |
用户说"数据表/子表/table":
- 查看 → `table get`
- 创建 → `table create`
- 重命名 / 改备注 / 改行命名规则 → `table update`(三选一:`--name` / `--description` / `--record-name-key`)
- 用户说"行命名规则/记录别名/卡片显示成 task/project/event 这种" → `table update --record-name-key <枚举键>`,**中文 → 枚举键**对照见 [aitable-record-name-key.md](./aitable/aitable-record-name-key.md)
- 删除 → `table delete`
## 写操作通用约束
用户说"字段/列/column":
- 查看 → `field get`
- 添加 → `field create`(读 [aitable-field.md](./aitable/aitable-field.md))
- 修改 → `field update`
- 删除 → `field delete`
- 创建/更新记录前只读取目标 Table 或目标 Field 的真实配置;`cells` key 优先使用 fieldId。
- 只读字段(公式、查找引用、创建/修改信息等)不写入。
- 删除 Base/Table/Field/Record、停用工作流、删除附件和关闭高级权限均按 Runtime confirmation 执行。
- 批量操作检查 completed/failed/checkpoint/nextCommand;`partial_success` 不等于成功。
- 写入效果未知时按返回的稳定 ID 或业务唯一键回读,不能整批盲目重放。
用户说"记录/行/数据/row":
- 查看/搜索 → `record query`(读 [aitable-record-query.md](./aitable/aitable-record-query.md))
- 总数/求和/平均值/中位数/完整率等标量统计 → `record stats`(读 [aitable-record-stats.md](./aitable/aitable-record-stats.md))
- 分组统计/唯一实体计数/去重率 → `record group-stats`(读 [aitable-record-stats.md](./aitable/aitable-record-stats.md))
- 找空行 / 没填东西的行 → `record query-empty`(读 [aitable-record-query.md](./aitable/aitable-record-query.md))
- 已知 recordId 反查字段值 → `record get`(按 ID 取专用,等价 `record query --record-ids`)
- 添加/写入 → `record create`(读 [aitable-record-create.md](./aitable/aitable-record-create.md))
- 修改/更新(每条独立 cells) → `record update`(读 [aitable-record-update.md](./aitable/aitable-record-update.md))
- **批量更新同一字段值**(统一标记/统一改值) → `record batch-update --record-ids ... --cells '{...}'`
- 删除 → `record delete`
- **查记录的字段变更历史 / 操作审计** → `record history-list`(读 [aitable-record-history.md](./aitable/aitable-record-history.md))
- **取记录分享链接 / 把这行发给同事** → `record share-url`(读 [aitable-record-share.md](./aitable/aitable-record-share.md))
- **不知道有没有 → 有就改、没有就建** → `record upsert`(读 [aitable-record-upsert.md](./aitable/aitable-record-upsert.md))
兼容包仍包含 `scripts/aitable_export_via_task.py` 与 `scripts/bulk_add_fields.py`;当前主路径分别是 `+export-data` 与 `+table-bootstrap` / `field create`,只有旧版运行时缺少对应命令时才使用脚本。
用户说"视图/view":
- 列出/查看全部视图 → `view list`(或 `view get` 不传 --view-ids,二者等价)
- 看某个视图详情 → `view get --view-ids <ID>`
- 创建 → `view create`
- 修改(含"调整字段顺序/隐藏字段") → `view update --config '{"visibleFieldIds":[...]}'`
- 修改某一项配置(filter/sort/group/card/timebar/aggregate 等)→ `view update <attr>`(读 [aitable-view-config.md](./aitable/aitable-view-config.md))
- 锁定 / 冻结列 / 行高 / 数据高亮规则 / 复制视图 → 读 [aitable-view-extras.md](./aitable/aitable-view-extras.md)
- 删除 → `view delete`
## 跨产品边界
用户说"锁定视图/解锁视图/lock view" → `view lock` / `view lock --off`,详见 [aitable-view-extras.md](./aitable/aitable-view-extras.md)
用户说"冻结列/冻结首列/frozen columns" → `view update frozen-cols --count N`,详见 [aitable-view-extras.md](./aitable/aitable-view-extras.md)
用户说"行高/单元格高度/紧凑模式/cell height" → `view update row-height --cell-height N`(合法档位 32/56/88/128),详见 [aitable-view-extras.md](./aitable/aitable-view-extras.md)
用户说"数据高亮/条件格式/单元格上色/fill color rule" → `view update fill-color-rule --json '[...]'`,详见 [aitable-view-extras.md](./aitable/aitable-view-extras.md)
用户说"复制视图/duplicate view" → `view duplicate --view-id ... [--new-name ...]`,详见 [aitable-view-extras.md](./aitable/aitable-view-extras.md)
用户说"筛选/过滤/filter" → 读 [aitable-filter-sort.md](./aitable/aitable-filter-sort.md)
用户说"统计/分析/聚合/TOP N/全量" → 先读 [aitable-data-analysis-sop.md](./aitable/aitable-data-analysis-sop.md),聚合参数见 [aitable-record-stats.md](./aitable/aitable-record-stats.md)
用户说"公式/formula/计算字段/派生指标" → 读 [aitable-formula-guide.md](./aitable/aitable-formula-guide.md)
用户说"查找引用/lookup/filterUp/跨表" → 读 [aitable-formula-guide.md](./aitable/aitable-formula-guide.md)(§5.4 跨表引用)
用户说"表单/form/收集表/问卷/催办填写" → 读 [aitable-form.md](./aitable/aitable-form.md)
用户说"自动化/工作流/流程/触发/automation/workflow" → 读 [aitable-workflow.md](./aitable/aitable-workflow.md)
- 新建自动化 → 按子文档的最小 Demo 组装完整 DSL,再 `workflow create --dsl @file`
- 修改自动化 → `workflow get` 留底,按最新 DSL 文档生成完整目标 DSL,再 `workflow update --dsl @file`
- 看 Base 里有哪些流程 / 哪些在跑 → `workflow list`(看 `recordCount` / `runningCount`)
- 看某个流程具体配置(触发条件、动作步骤) → `workflow get`
- 启用流程 → `workflow enable`
- 临时停掉流程(调试 / 数据迁移)→ `workflow disable --yes`
- 删除流程:当前不支持,引导用户到 AI 表格 Web 端 → 数据表 → 自动化 面板手动完成
用户说"仪表盘/图表/chart" → 读 [aitable-dashboard-chart.md](./aitable/aitable-dashboard-chart.md)
用户说"仪表盘排版乱了/图表对不齐/重新排布/自动布局/美化仪表盘" → `dashboard arrange`(读 [aitable-dashboard-chart.md](./aitable/aitable-dashboard-chart.md))
用户说"附件/上传文件" → 读 [aitable-attachment.md](./aitable/aitable-attachment.md)
用户说"导入/导出/import/export" → 读 [aitable-export-import.md](./aitable/aitable-export-import.md)
用户说"模板" → `template search`
用户说"高级权限/角色/权限控制/谁能看/谁能改" → 读 [aitable-advperm.md](./aitable/aitable-advperm.md)
- 开/关高级权限 → `advperm enable` / `advperm disable --yes`
- 看角色配置 → `advperm role-list` 或 `advperm role-get`
- 建角色(可同时指定子角色权限) → `advperm role-create --name ... --sub-roles '[...]'`
- 改角色名 / 改子角色权限(PATCH 语义,未传字段不变) → `advperm role-update --role-id ... [--name ...] [--sub-roles '[...]']`
- 删角色 → `advperm role-delete --yes`
- **角色 ↔ 成员绑定**:当前 CLI 不支持,仍需在 AI 表格 Web 端面板手动完成
命令报错/操作失败 → 读 [aitable-error-recovery.md](./aitable/aitable-error-recovery.md)
**关键区分**: base=表格文件, table=数据表, field=列, record=行
## 核心工作流
```bash
# 1. 搜索/列出 Base — 提取 baseId
dws aitable base search --query "项目" --format json
# 2. 获取 Base 信息 — 提取 tableId
dws aitable base get --base-id <BASE_ID> --format json
# 3. 获取字段目录 — 提取 fieldId
dws aitable field get --base-id <BASE_ID> --table-id <TABLE_ID> --format json
# 4. 查询记录
dws aitable record query --base-id <BASE_ID> --table-id <TABLE_ID> --format json
# 5. 新增记录 (cells 用 fieldId 作 key)
dws aitable record create --base-id <BASE_ID> --table-id <TABLE_ID> \
--records '[{"cells":{"fldXXX":"值"}}]' --format json
```
## 上下文传递表
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `base list/search` | `baseId` | 所有后续命令的 --base-id,拼接文档 URI |
| `base create` | `baseId` | 后续命令 + 文档 URI |
| `base get` | `tables[].tableId` | --table-id,拼接指定数据表 URI |
| `table create` | `tableId` | 后续命令 + 拼接指定数据表 URI |
| `table get` | `tables[].tableId`、视图目录 | 定位数据表和视图;字段需继续调用 `field get` |
| `field get` | `fields[].fieldId` | record 操作的 cells key, field update/delete |
| `record query` | `recordId` | record update/delete;按 ID 反查字段值用 `record get` |
| `template search` | `templateId` | base create --template-id,拼接模板预览 URI |
## URL → baseId 提取
用户提供 `https://alidocs.dingtalk.com/i/nodes/{baseId}` 链接时:
1. 提取 `/nodes/` 后的路径段作为 `baseId`
2. 去掉尾部的查询参数(`?` 及其后内容)
3. 传入 `--base-id` 参数
> 如果该 URL 来自 `dws aitable` 返回或已在当前链路 probe 过,可直接复用;
> 如果是用户直接提供的原始 `alidocs` URL,则先按 [链接规范](url-patterns.md#alidocs-url-类型探测流程) probe,确认 `extension=able` 后再继续。
## 注意事项
- 所有操作使用 ID(baseId/tableId/fieldId/recordId),不使用名称
- records 的 cells key 是 fieldId,不是字段名称
- cells 写入/读取格式见 [aitable-cell-value.md](./aitable/aitable-cell-value.md)
- 最佳实践见 [aitable-best-practices.md](./aitable/aitable-best-practices.md)
## 自动化脚本
| 脚本 | 场景 |
|------|------|
| [bulk_add_fields.py](../scripts/bulk_add_fields.py) | 批量添加字段 |
| [import_records.py](../scripts/import_records.py) | 从 JSON/CSV 批量导入记录 |
| [aitable_export_via_task.py](../scripts/aitable_export_via_task.py) | 文件导出(export_data 轮询 + 下载) |
| [upload_attachment.py](../scripts/upload_attachment.py) | 上传附件到 AI 表格记录 |
## 相关产品
- [doc](../../dingtalk-doc/references/doc.md) — 富文本文档编辑,不是结构化数据表格
- AI 表格记录、字段、视图和自动化留在 AITable。
- Base 结构复制/删除与 Base 内 Table、Dashboard、Section 操作走 AITable;只有整个 Base 的普通文件夹位置移动或外层存储重命名走 Drive。
- 记录主键文档正文拿到真实 nodeId 后走 Doc。
- Excel 式单元格、区域和公式操作走 Sheet,而不是 AITable。
@@ -3,18 +3,29 @@
## 建议操作顺序
```bash
# 1) 先看配置模板(JSONC)
# 1) 只在缺少配置结构时读取模板
dws aitable dashboard config-example --format json
dws aitable chart widgets-example --format json
dws aitable +chart-widgets-example --format json
# 2) 先拿 dashboard,再拿 chart 详情
dws aitable dashboard get --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --format json
dws aitable chart get --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --chart-id <CHART_ID> --format json
```
只按名称创建、改名并确认时,不需要读取配置示例或 Help:
```bash
dws aitable dashboard create --base-id <BASE_ID> --name <名称> --format json
dws aitable +dashboard-update --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --name <新名称> --format json
dws aitable +dashboard-get --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --format json
```
部分服务端更新回执可能仍回显更新前名称;只做一次 `+dashboard-get` 读回,以该后续权威读回为最终状态。读回已是目标名称时判定更新完成,不重放写操作,也不因旧回执继续探测 Help。
## 要点
- `dashboard get` 返回的 `charts[].chartId` 可直接给 `chart get` 使用
- 删除 dashboard 会级联删除其全部 chart;确认前必须说明该影响
- `dashboard share get` 可能返回 `404`(资源不存在或未开通),需按可重试错误处理,不要误判为参数拼错
- `chart share get` 可正常返回 `enabled/shareUrl`,用于分享状态判断
@@ -25,7 +36,7 @@ dws aitable chart get --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --chart-
| `dashboard get` | 获取仪表盘详情(含 charts 列表) | `--base-id` `--dashboard-id` | — |
| `dashboard create` | 创建仪表盘 | `--base-id` + (`--config` 或 `--name`) | `--name` 简化版创建空看板;`--config` 传完整 JSON |
| `dashboard update` | 更新仪表盘 | `--base-id` `--dashboard-id` + (`--config` 或 `--name`) | `--name` 仅改名;`--config` 更新完整配置 |
| `dashboard delete` | 删除仪表盘 | `--base-id` `--dashboard-id` `--yes` | — |
| `dashboard delete` | 删除仪表盘 | `--base-id` `--dashboard-id` | 级联删除全部 chart,不可逆;由 Runtime 请求确认,Reference 不携带确认绕过参数 |
| `dashboard config-example` | 查看仪表盘配置模板 | 无 | 创建前先调此命令了解 config 结构 |
| `dashboard arrange` | 自动重排图表布局 | `--base-id` `--dashboard-id` | 把图表按行铺满网格,避免某行只占半幅、留下大片空白;返回 `{totalColumns, layout, alignedChartCount}` |
@@ -34,11 +45,11 @@ dws aitable chart get --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --chart-
| 命令 | 用途 | 必填参数 |
|------|------|----------|
| `chart get` | 获取图表详情 | `--base-id` `--dashboard-id` `--chart-id` |
| `chart create` | 创建图表 | `--base-id` `--dashboard-id` `--config` |
| `chart create` | 创建图表 | `--base-id` `--dashboard-id` `--config` `--layout` |
| `chart update` | 更新图表配置 | `--base-id` `--dashboard-id` `--chart-id` `--config` |
| `chart delete` | 删除图表 | `--base-id` `--dashboard-id` `--chart-id` `--yes` |
| `chart widgets-example` | 查看图表 widgets 配置模板 | 无 |
| `chart delete` | 删除图表 | `--base-id` `--dashboard-id` `--chart-id` | 不可逆;由 Runtime 请求确认,Reference 不携带确认绕过参数 |
| `+chart-widgets-example` | 查看所有图表类型的 widgets 模板 | 无 |
## 配置获取流程
创建图表前,必须先调用 `chart widgets-example` 查看配置模板,了解每种图表类型需要的字段结构,然后根据实际 tableId 和 fieldId 填充配置。
已有符合当前 leaf Schema 的合法 config 时直接创建/更新,不读取模板。只有缺少结构时调用一次 `+chart-widgets-example`;该命令当前返回所有图表类型示例,随后只使用目标类型,并按真实 tableId/fieldId 填充后执行。
@@ -33,6 +33,16 @@ dws aitable form field list --base-id BASE_ID --table-id TABLE_ID --view-id VIEW
dws aitable form share get --base-id BASE_ID --table-id TABLE_ID --view-id VIEW_ID --format json
```
开启并取得可发送链接的最短闭环使用 canonical shortcut:
```bash
dws aitable form create --base-id BASE_ID --table-id TABLE_ID --name "表单名" --format json
dws aitable +form-share-update --base-id BASE_ID --table-id TABLE_ID --view-id VIEW_ID --enabled true --format json
dws aitable +form-share-get --base-id BASE_ID --table-id TABLE_ID --view-id VIEW_ID --format json
```
从最后一次返回直接读取 `data.shareFormUuid`,分享地址为 `https://alidocs.dingtalk.com/i/form/{shareFormUuid}`。字段已存在时不要再调用 Help、Catalog、`form get`,也不要换 `--verbose`、`raw`、`pretty` 重复请求。用户还要求发送时,把该完整 URL 交给 Chat 的发送命令并检查真实发送回执。
## 要点
- **创建表单**有两种等价方式:
@@ -9,7 +9,7 @@
### 查询主键文档
```bash
dws aitable record primary-doc-get --base-id BASE_ID --table-id TABLE_ID --record-id RECORD_ID
dws aitable +record-primary-doc-get --base-id BASE_ID --table-id TABLE_ID --record-id RECORD_ID
```
**参数:**
@@ -22,7 +22,7 @@ dws aitable record primary-doc-get --base-id BASE_ID --table-id TABLE_ID --recor
### 创建主键文档
```bash
dws aitable record primary-doc-create --base-id BASE_ID --table-id TABLE_ID --field-id FIELD_ID --record-id RECORD_ID
dws aitable +record-primary-doc-create --base-id BASE_ID --table-id TABLE_ID --field-id FIELD_ID --record-id RECORD_ID
```
**参数:**
@@ -38,6 +38,7 @@ dws aitable record primary-doc-create --base-id BASE_ID --table-id TABLE_ID --fi
## 注意事项
- `fieldId` 必须是 primaryDoc 类型,否则返回 `INVALID_FIELD_TYPE` 错误
- `primaryDoc` 是建表时的首字段能力,不能在已有普通首字段之后补建,也不能把普通字段改成 primaryDoc。需要该能力时应新建以 primaryDoc 为首字段的数据表并迁移数据;未经用户明确授权不要自动迁移。
- 传入不存在的 `recordId` 会返回 `RECORD_NOT_FOUND` 错误
- 创建后可通过 `dws doc update --node <nodeId>` 写入文档内容,或 `dws doc read --node <nodeId>` 读取
@@ -47,8 +48,8 @@ dws aitable record primary-doc-create --base-id BASE_ID --table-id TABLE_ID --fi
# 1. 查询字段目录,拿到 primaryDoc 字段的 fieldId
dws aitable field get --base-id BASE_ID --table-id TABLE_ID
# 2. 为某条记录创建主键文档
dws aitable record primary-doc-create --base-id BASE_ID --table-id TABLE_ID --field-id FIELD_ID --record-id RECORD_ID
# 2. 为记录创建主键文档
dws aitable +record-primary-doc-create --base-id BASE_ID --table-id TABLE_ID --field-id FIELD_ID --record-id RECORD_ID
# 3. 拿到返回的 nodeId,用 dws doc 写入内容
dws doc update --node <data.nodeId> --content "# 项目方案\n\n文档正文内容..."
@@ -130,7 +130,7 @@ dws aitable view update field-widths --view-id GRID_ID --json '{"fldA":120,"fldB
### view update visible-fields(通用)
整组替换可见字段列表与顺序。首列字段(primaryDoc)必须保留在数组第一位。
整组替换可见字段列表与顺序。`field get` 返回的第一个字段是系统行索引/主字段;无论它显示为 text 还是 primaryDoc,都必须保留在数组第一位,且不能隐藏。不要仅凭字段类型猜主字段。
> ⚠️ 注意:服务端**只接受 reorder,不接受真"隐藏字段"**——如果传入的列表比当前 columns 短,缺失的字段不会被隐藏。需要真正隐藏字段请到 AI 表格 Web UI。
@@ -144,6 +144,24 @@ dws aitable view update visible-fields --view-id VIEW_ID --field-ids fldPrimary,
dws aitable view update visible-fields --view-id VIEW_ID --json '["fldPrimary","fldA","fldB"]'
```
### 列顺序最短闭环
用户说“客户名称最左、状态在金额前”时,不要用通用 `+view-update --config` 探索:
1. `dws aitable field get --base-id <B> --table-id <T> --format json` 取字段有序列表;第一个 fieldId 固定为数组第 1 项。目标 viewId 从真实上下文或 `view get` 返回中取得。
2. `dws aitable view get visible-fields ...` 取当前完整列数组;必须保留全部现有字段,因为该接口只支持 reorder,不是真隐藏。
3. 只重排目标:`[主字段, 客户名称, ..., 状态, 金额, ...]`,其他字段保持相对顺序;一次执行 `view update visible-fields`。
4. 再次 `view get visible-fields`,数组完全一致才算完成。遇到 `PRIMARY_FIELD_CANNOT_BE_MOVED/HIDDEN` 立即停止,重新按步骤 1 构造一次;禁止继续猜排列。
“固定/冻结左侧列”与“放到最左边”不是同一操作。只有 Grid 支持冻结;若要冻结主字段后的目标列,需要冻结前 N 列(例如目标位于第 2 列则 count=2):
```bash
dws aitable +view-set-frozen-cols --base-id <B> --table-id <T> --view-id <V> --count <N>
dws aitable +view-get-frozen-cols --base-id <B> --table-id <T> --view-id <V>
```
Kanban/Gallery 等视图只调整列顺序,不尝试冻结。
### view update filter / sort / group(通用,纯 --json)
```bash
@@ -1,10 +1,17 @@
# aitable 局部意图消歧
# AITable 局部意图消歧
本文件从单 Skill `intent-guide.md` 拆分而来,仅保留与本产品相关的跨产品消歧规则。
| 用户表达 | 归属 | 理由 |
|---|---|---|
| AI 表格、多维表、Base、Table、字段、记录、视图、表单、仪表盘、自动化 | AITable | 操作 AITable 的业务数据与配置 |
| 搜索 Base 候选、按关键词找 Base、检查某 Base 是否存在 | AITable | 直接使用 `+base-search --query`;即使关键词像人名,只要对象是 Base,也不得改走 `aisearch person` |
| 表格链接,需要读取记录 | AITable | 先用 `+url-resolve` 取稳定 ID,再用 `+record-query` |
| 只有 Base/Table 名称,需要读取记录 | AITable | 先用 `+resolve-base` / `+resolve-table` 唯一解析,再查询记录 |
| 只复制 Base 结构、删除整个 Base | AITable | 复制到已知文档文件夹用 `+base-copy --target-folder-id ... --only-struct`;删除用真实 baseId。不要 Drive 完整复制后逐表删数据 |
| Base 整体移动到普通文件夹、外层存储重命名 | Drive | 这是 Base 作为单个存储节点的外层位置/名称动作 |
| Base 内 Table、Dashboard、Section 的复制/移动/重命名/删除 | AITable | 这些是 Base 内 nsheet/业务结构,不是独立 Drive dentry |
| Base 角色、高级权限 | AITable | `+role-*` / `+advperm-*`;仅普通文件 ACL 才走 Drive |
| 记录主键文档正文 | Doc | AITable 只取/建关联,正文由 Doc 处理 |
| Excel 式单元格、区域、工作表、公式 | Sheet(dingtalk-misc) | 二维电子表格,不是多维表记录模型 |
| CSV/JSON 数据进入现有 AI 表格 | AITable import 或 record create | 需要保留导入任务语义时用 import;已映射字段时直接写记录 |
| 用户说... | 真实意图 | 应该用 | 不要用 | 理由 |
|---|---|---|---|---|
| "帮我建一个项目跟踪表" | 创建数据表格 | `aitable` | `doc` / `sheet` | 涉及结构化数据/行列操作,不是富文本文档或电子表格 |
| "帮我写个项目周报" | 创建钉钉文档 | `doc` | `aitable` | 富文本内容创作,不是数据表 |
| "创建一个电子表格" | 创建表格文档 | `sheet` | `aitable` | Excel 式表格/单元格操作,不是多维表记录 |
| "帮我读一下表格 A1:D10 的数据" | 读取单元格数据 | `sheet` | `aitable` | 按单元格区域读写,不是按记录查询 |
若链接类型不明确,先做 URL 类型预检;不要仅凭 URL 文本猜产品。明确是 AI 表格后才加载本 Skill。
@@ -1,130 +0,0 @@
#!/usr/bin/env python3
"""
递归列出钉盘目录树结构(可指定深度)
用法:
python drive_tree_list.py # 列出根目录
python drive_tree_list.py --depth 2 # 递归 2 层
python drive_tree_list.py --parent-id <id> # 指定目录
python drive_tree_list.py --dry-run
"""
import sys
import json
import subprocess
import argparse
from typing import List, Any, Optional
def run_dws(
args: List[str], dry_run: bool = False,
) -> Optional[Any]:
cmd = ['dws'] + args
if dry_run:
print(f"[dry-run] {' '.join(cmd)}")
return None
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=60
)
if result.returncode != 0:
print(f"错误:{result.stderr.strip()}", file=sys.stderr)
return None
return json.loads(result.stdout)
except (subprocess.TimeoutExpired, json.JSONDecodeError,
FileNotFoundError) as e:
print(f"错误:{e}", file=sys.stderr)
return None
def list_dir(
parent_id: str = '', dry_run: bool = False,
) -> list:
cmd_args = [
'drive', 'list', '--max', '50', '--format', 'json',
]
if parent_id:
cmd_args.extend(['--parent-id', parent_id])
data = run_dws(cmd_args, dry_run=dry_run)
if not data:
return []
if isinstance(data, list):
return data
if isinstance(data, dict):
inner = data.get('result', data)
if isinstance(inner, dict):
return inner.get('items', inner.get('dentryList', []))
if isinstance(inner, list):
return inner
return []
def print_tree(
items: list, depth: int, max_depth: int,
prefix: str = '', dry_run: bool = False,
):
for i, item in enumerate(items):
is_last = (i == len(items) - 1)
connector = '└── ' if is_last else '├── '
name = item.get('name') or item.get('fileName', '?')
item_type = item.get('type') or item.get('dentryType', '')
is_dir = str(item_type).lower() in (
'folder', 'directory', '1', 'FOLDER'
)
icon = '📁' if is_dir else '📄'
size_str = ''
size = item.get('size') or item.get('fileSize')
if size and not is_dir:
size = int(size)
if size > 1024 * 1024:
size_str = f" ({size / 1024 / 1024:.1f}MB)"
elif size > 1024:
size_str = f" ({size / 1024:.1f}KB)"
else:
size_str = f" ({size}B)"
print(f"{prefix}{connector}{icon} {name}{size_str}")
if is_dir and depth < max_depth:
child_prefix = prefix + (' ' if is_last else '│ ')
dentry_id = (item.get('dentryUuid')
or item.get('id', ''))
if dentry_id:
children = list_dir(dentry_id, dry_run=dry_run)
print_tree(
children, depth + 1, max_depth,
child_prefix, dry_run,
)
def main():
parser = argparse.ArgumentParser(
description='递归列出钉盘目录树'
)
parser.add_argument(
'--parent-id', default='', help='起始目录 ID'
)
parser.add_argument(
'--depth', type=int, default=1,
help='递归深度 (默认 1, 最大 5)',
)
parser.add_argument('--dry-run', action='store_true')
args = parser.parse_args()
args.depth = min(args.depth, 5)
root_name = args.parent_id or '我的文件'
print(f"📁 {root_name}")
items = list_dir(args.parent_id, dry_run=args.dry_run)
if args.dry_run:
return
if not items:
print(' (空目录)')
return
print_tree(items, 0, args.depth, '', args.dry_run)
print(f"\n共 {len(items)} 个项目 (根目录)")
if __name__ == '__main__':
main()
+5 -5
View File
@@ -1,6 +1,6 @@
---
name: dingtalk-event
description: 钉钉个人 IM 与 OA 审批事件长连接监听。Use when 用户说监听消息/@我/某人/某群/全部消息、已读/撤回/reaction、群成员加入/群成员退出/群状态变化,或监听审批任务创建/完成/转交、审批实例发起/终止/完成。命令前缀:dws event。
description: 钉钉个人 IM 与 OA 审批事件长连接监听。Use when 用户说监听消息/@我/某人/某群/全部消息、已读/撤回/reaction、群成员加入/群成员退出/群状态变化,或监听审批任务创建/完成/转交、审批实例发起/抄送/终止/完成。命令前缀:dws event。
metadata:
cli_version: ">=0.2.14"
category: product
@@ -45,7 +45,7 @@ metadata:
- `group` 必须且只能传 `--chat-id` 或 `--chat-query` 之一。
- `--query` 只用于纯 `message` 监听;混入 reaction/read/recall 时不得使用。
OA 事件不进入 `+listen-im`。六个公开 OA EventKey 都订阅当前 OAuth 用户相关的全部审批事件,使用 `ruleType=all`、`filterRule={}`;不接受 `--user`、`--open-dingtalk-id`、`--group`、`--query` 或 `--filter-json`。六项可放入同一个 consume,每项建立独立订阅并共享 bus。
OA 事件不进入 `+listen-im`。七个公开 OA EventKey 都订阅当前 OAuth 用户相关的全部审批事件,使用 `ruleType=all`、`filterRule={}`;不接受 `--user`、`--open-dingtalk-id`、`--group`、`--query` 或 `--filter-json`。七项可放入同一个 consume,每项建立独立订阅并共享 bus。
自然姓名和群名由 CLI 内部唯一解析:零命中或多候选返回结构化失败,在创建任何订阅前停止。`--dry-run` 走同一解析链。解析、监听、状态和停止必须使用同一个 `--profile`,不得跨组织搬运 ID。
@@ -65,7 +65,7 @@ user_im_group_member_added user_im_group_member_exited
user_im_group_disbanded
```
六个 OA EventKey 及其输出字段见 [OA 事件参考](references/event-oa.md)。
七个 OA EventKey 及其输出字段见 [OA 事件参考](references/event-oa.md)。
用户类事件传 `--user` 或 `--open-dingtalk-id`,群类事件传 `--group`。群生命周期输出可含 `operator_open_dingtalk_id` 和 `members`;成员项使用 `open_dingtalk_id`。精确组合、兼容性和 Filter 规则见 reference。
@@ -98,7 +98,7 @@ kind + events + target
- `event stop` 会取消订阅并影响本地 consumer:先 `--dry-run`,用户确认后再加 `--yes`。
- 多事件属于一次原始操作;任一订阅启动失败时 Runtime 回滚本次已创建项,不拆成新命令绕过重试预算。
- 这套 `0/2/1` 是 **Agent/host** 编排预算,适用于全部 22 个公开个人 EventKey(16 个 IM + 6 个 OA):`retryable=false` 对应 `max_additional_attempts=0`;`retryable=true` 对应 `max_additional_attempts=2`;`retryable=unknown` 对应 `max_additional_attempts=1`。它不是 CLI 持久化硬总次数上限;每次调用最多创建一次,进程内不会自动重试,CLI 也不持久化或计算跨调用的 Agent/host 尝试次数。
- 这套 `0/2/1` 是 **Agent/host** 编排预算,适用于全部 23 个公开个人 EventKey(16 个 IM + 7 个 OA):`retryable=false` 对应 `max_additional_attempts=0`;`retryable=true` 对应 `max_additional_attempts=2`;`retryable=unknown` 对应 `max_additional_attempts=1`。它不是 CLI 持久化硬总次数上限;每次调用最多创建一次,进程内不会自动重试,CLI 也不持久化或计算跨调用的 Agent/host 尝试次数。
- 重试必须遵守 `retry_after_seconds` / `next_retry_at`。遇到 `in_flight`、`cooldown`、`terminal_hold` 不并发或递归重启同一逻辑订阅,也不换 `subscribe_id` / `trace_id` 绕过保护。
- 认证、profile、订阅保护状态和 bus 排障按失败类型读取 [订阅运维](references/event-im-operations.md),不要在正常路径预加载完整运维手册。
@@ -125,4 +125,4 @@ kind + events + target
| ready、bounded consume 与退出清理 | [event-im-lifecycle.md](references/event-im-lifecycle.md) | 启动/托管/关闭 consumer |
| 扁平字段与事件到 Chat 交接 | [event-im-output.md](references/event-im-output.md) | 解析事件或自动回复 |
| Filter、status/stop、重试与排障 | [event-im-operations.md](references/event-im-operations.md) | 订阅控制或失败恢复 |
| OA 审批事件 | [event-oa.md](references/event-oa.md) | 选择六个 OA EventKey、组合消费或解析审批字段 |
| OA 审批事件 | [event-oa.md](references/event-oa.md) | 选择七个 OA EventKey、组合消费或解析审批字段 |
@@ -1,6 +1,6 @@
# OA 个人审批事件
先读事件产品入口 [SKILL.md](../SKILL.md) 的命令规则、调用流和子进程契约。本参考覆盖当前公开的六个 OA 个人事件:审批实例发起、终止和完成,以及审批任务创建、完成和转交。
先读事件产品入口 [SKILL.md](../SKILL.md) 的命令规则、调用流和子进程契约。本参考覆盖当前公开的七个 OA 个人事件:审批实例发起、抄送、终止和完成,以及审批任务创建、完成和转交。
<!-- dws-intent: event.listen.oa -->实时监听审批事件必须使用 `dws event consume` 长连接,不要轮询 OA 待办或审批实例列表来模拟事件。
@@ -22,10 +22,11 @@ dws auth login
| `user_oa_approval_task_finished` | `all` | 审批任务已完成 | 无 |
| `user_oa_approval_task_redirected` | `all` | 审批任务已转交 | 无 |
| `user_oa_approval_instance_started` | `all` | 审批实例已发起 | 无 |
| `user_oa_approval_instance_cc` | `all` | 审批实例到达抄送节点,发送给被抄送人 | 无 |
| `user_oa_approval_instance_terminated` | `all` | 审批实例已终止 | 无 |
| `user_oa_approval_instance_finished` | `all` | 审批实例完成,发送给审批单发起人 | 无 |
只承认上表 6 个 OA 事件码。CLI 为每个事件发送 `ruleType=all`、`filterRule={}` 的独立订阅请求;不要添加 `--user`、`--open-dingtalk-id`、`--group`、`--query` 或 `--filter-json`。
只承认上表 7 个 OA 事件码。CLI 为每个事件发送 `ruleType=all`、`filterRule={}` 的独立订阅请求;不要添加 `--user`、`--open-dingtalk-id`、`--group`、`--query` 或 `--filter-json`。
## Intent mapping
@@ -35,13 +36,14 @@ dws auth login
| “审批任务完成时通知我” | `dws event consume user_oa_approval_task_finished --flatten -f ndjson` |
| “审批任务被转交时通知我” | `dws event consume user_oa_approval_task_redirected --flatten -f ndjson` |
| “有审批单发起时通知我” | `dws event consume user_oa_approval_instance_started --flatten -f ndjson` |
| “有审批抄送给我时通知我” | `dws event consume user_oa_approval_instance_cc --flatten -f ndjson` |
| “有审批单终止时通知我” | `dws event consume user_oa_approval_instance_terminated --flatten -f ndjson` |
| “监听我发起的审批何时完成” / “审批实例完成时通知我” | `dws event consume user_oa_approval_instance_finished --flatten -f ndjson` |
| “同时监听全部已公开 OA 事件” | 一个 consume 放入六个 OA event key,不加目标或过滤参数 |
| “同时监听全部已公开 OA 事件” | 一个 consume 放入七个 OA event key,不加目标或过滤参数 |
| “查看 OA 事件目录” | `dws event list --category oa` |
| “查看 OA 事件输出字段” | 对对应事件运行 `dws event schema <event_key> --flatten` |
三个审批任务事件分别表达任务已创建、已完成和已转交;三个审批实例事件分别表达实例已发起、已终止和已完成。扁平字段来自六类事件的预发联调样本;`status` 和 `result` 保留服务端原值,不把当前样本值推断为完整枚举。
三个审批任务事件分别表达任务已创建、已完成和已转交;四个审批实例事件分别表达实例已发起、到达抄送节点、已终止和已完成。`status` 和 `result` 保留服务端原值,不把当前样本值推断为完整枚举。
## Commands
@@ -52,6 +54,7 @@ dws event schema user_oa_approval_task_created --flatten
dws event schema user_oa_approval_task_finished --flatten
dws event schema user_oa_approval_task_redirected --flatten
dws event schema user_oa_approval_instance_started --flatten
dws event schema user_oa_approval_instance_cc --flatten
dws event schema user_oa_approval_instance_terminated --flatten
dws event schema user_oa_approval_instance_finished --flatten
```
@@ -63,11 +66,12 @@ dws event consume user_oa_approval_task_created --flatten -f ndjson
dws event consume user_oa_approval_task_finished --flatten -f ndjson
dws event consume user_oa_approval_task_redirected --flatten -f ndjson
dws event consume user_oa_approval_instance_started --flatten -f ndjson
dws event consume user_oa_approval_instance_cc --flatten -f ndjson
dws event consume user_oa_approval_instance_terminated --flatten -f ndjson
dws event consume user_oa_approval_instance_finished --flatten -f ndjson
```
同时监听六种事件:
同时监听七种事件:
```bash
dws event consume \
@@ -75,13 +79,14 @@ dws event consume \
user_oa_approval_task_finished \
user_oa_approval_task_redirected \
user_oa_approval_instance_started \
user_oa_approval_instance_cc \
user_oa_approval_instance_terminated \
user_oa_approval_instance_finished \
--flatten \
-f ndjson
```
多事件 consume 会为六个 event key 分别创建订阅和逻辑 consumer,并共享当前组织的 personal bus、远程连接、stdout 和生命周期。不要给 OA 命令加 `--query` 或 `--filter-json`;这两个 flag 只用于兼容的 IM 消息接收事件。
多事件 consume 会为七个 event key 分别创建订阅和逻辑 consumer,并共享当前组织的 personal bus、远程连接、stdout 和生命周期。不要给 OA 命令加 `--query` 或 `--filter-json`;这两个 flag 只用于兼容的 IM 消息接收事件。
## Output contract
@@ -105,7 +110,7 @@ dws event consume \
- `type` 是当前 event key;`event_id` 可用于去重;`timestamp` 是 transport 事件发生时间;`subscribe_id` 标识对应的独立订阅。
- `process_instance_id` 是审批实例 ID,可传给 OA 审批命令的 `--instance-id`;`process_code` 是审批流程模板编码。
- `create_time`、`finish_time` 和 `event_time` 都是毫秒时间戳。`event_time` 是审批业务事件时间,`timestamp` 是 transport 事件时间。
- 六类事件的额外字段如下;具体事件始终以 `dws event schema <event_key> --flatten` 为准。
- 七类事件的额外字段如下;具体事件始终以 `dws event schema <event_key> --flatten` 为准。
| 事件 | 额外顶层字段 |
|---|---|
@@ -113,6 +118,7 @@ dws event consume \
| `user_oa_approval_task_finished` | `task_id`、`result`、`finish_time` |
| `user_oa_approval_task_redirected` | `task_id`、`result`、`finish_time` |
| `user_oa_approval_instance_started` | 无 |
| `user_oa_approval_instance_cc` | 无 |
| `user_oa_approval_instance_terminated` | `finish_time` |
| `user_oa_approval_instance_finished` | `result`、`finish_time` |
@@ -144,6 +150,6 @@ dws event consume \
## Lifecycle
- 单事件等待 `[event] ready event_key=<key> bus_pid=<pid> subscribe_id=<id>`。
- 六事件先保存六条 `[event] subscription event_key=<key> subscribe_id=<id>`,再等待 `[event] ready event_count=6 bus_pid=<pid>`。
- 七事件先保存七条 `[event] subscription event_key=<key> subscribe_id=<id>`,再等待 `[event] ready event_count=7 bus_pid=<pid>`。
- 临时验证使用 `--max-events 1` 或 `--duration 10m`;任务完成后优雅结束 consume,本次新建的订阅会自动取消。
- 外部停止已有订阅时先运行 `dws event stop <subscribe_id> --dry-run`,确认后再加 `--yes`。不要 `kill -9`,否则会跳过自动退订。
+4
View File
@@ -1190,6 +1190,10 @@ Agent 安装 dws skill 后,仅依据 skill 提供的参考文档,将自然
- Prompt: 有和我相关的审批实例发起时实时通知我
- Expected: `dws event consume user_oa_approval_instance_started --flatten -f ndjson`
**event_event_consume_oa_instance_cc_001**
- Prompt: 有审批实例抄送给我时实时通知我
- Expected: `dws event consume user_oa_approval_instance_cc --flatten -f ndjson`
**event_event_consume_oa_instance_terminated_001**
- Prompt: 和我相关的审批实例终止时实时通知我
- Expected: `dws event consume user_oa_approval_instance_terminated --flatten -f ndjson`
+3 -2
View File
@@ -280,7 +280,7 @@ func TestEventSkillFrontmatterAdvertisesGroupMemberLifecycle(t *testing.T) {
"群成员加入",
"群成员退出",
"审批任务创建/完成/转交",
"审批实例发起/终止/完成",
"审批实例发起/抄送/终止/完成",
} {
if !strings.Contains(frontmatter, required) {
t.Errorf("%s frontmatter missing event discovery trigger %q", path, required)
@@ -306,7 +306,7 @@ func TestStandaloneEventSkillOwnsAllPersonalEventContracts(t *testing.T) {
"<!-- dws-intent: event.listen.im -->",
"<!-- dws-intent: event.listen.oa -->",
"16 个 EventKey",
"22 个公开个人 EventKey",
"23 个公开个人 EventKey",
} {
if !strings.Contains(string(skillContent), required) {
t.Errorf("%s missing standalone event contract %q", skillPath, required)
@@ -360,6 +360,7 @@ func TestStandaloneEventSkillOwnsAllPersonalEventContracts(t *testing.T) {
"user_oa_approval_task_finished",
"user_oa_approval_task_redirected",
"user_oa_approval_instance_started",
"user_oa_approval_instance_cc",
"user_oa_approval_instance_terminated",
"user_oa_approval_instance_finished",
}